diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 56c51163..040eb6f4 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -164,9 +164,12 @@ def build_ydl_opts( opts.update(extra) # SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason - # `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set. A - # non-positive budget is left unset: "0 bytes left" is a refusal for enqueue to make, not - # something to express here as an impossible yt-dlp filter. + # `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set. + # + # A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because + # nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused` + # → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the + # cap. Delete the worker's refusal and this branch silently uncaps the one user already over it. if max_bytes and max_bytes > 0: opts["max_filesize"] = max_bytes diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index e200cb27..d1293eb6 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -567,9 +567,9 @@ def _download( _reset_asset_pending(asset_id) log.info("job %s aborted (pause/cancel)", job_id) except _OwnerRefused as exc: - # Fail this job only. The asset is untouched and goes back to `pending`, so another user's - # job for the same video is claimed and downloaded normally instead of inheriting a reason - # that is about someone else's account. + # Fail this job only. The asset is never marked `error`; it goes back to `pending` (below), + # so another user's job for the same video is claimed and downloaded normally instead of + # inheriting a reason that is about someone else's account. msg = str(exc)[:500] with SessionLocal() as db: service.finalize_if_running( diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 51479081..d61b2737 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -350,10 +350,9 @@ function PendingAccess() { onError: (err) => notify({ level: "error", - message: - isEmailOff(err) - ? t("users.pendingAccess.resendEmailOff") - : t("users.pendingAccess.resendFailed"), + message: isEmailOff(err) + ? t("users.pendingAccess.resendEmailOff") + : t("users.pendingAccess.resendFailed"), }), }); diff --git a/frontend/src/components/accountPanel.dom.test.tsx b/frontend/src/components/accountPanel.dom.test.tsx index 81a9a0e8..3de8c62e 100644 --- a/frontend/src/components/accountPanel.dom.test.tsx +++ b/frontend/src/components/accountPanel.dom.test.tsx @@ -111,9 +111,7 @@ describe("the account panel reports a refused recovery action", () => { await clickAction("users.account.resend"); - await waitFor(() => - expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"), - ); + await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff")); }); it("reports a genuine send failure as a failure, not as a sent mail", async () => { @@ -137,8 +135,6 @@ describe("the account panel reports a refused recovery action", () => { await clickAction("users.account.activate"); - await waitFor(() => - expect(errorMessages()).toContain("users.pendingAccess.activateFailed"), - ); + await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed")); }); }); diff --git a/frontend/src/lib/verifyEntry.dom.test.tsx b/frontend/src/lib/verifyEntry.dom.test.tsx index e2d80349..728c00c8 100644 --- a/frontend/src/lib/verifyEntry.dom.test.tsx +++ b/frontend/src/lib/verifyEntry.dom.test.tsx @@ -158,6 +158,39 @@ describe("confirming an emailed verification link", () => { expect(screen.getByTestId("reset").textContent).toBe("reset-pending"); }); + // The flag must not outlive the token it describes. Navigating away from a `#reset=` fragment — + // a pasted verify link, the back button — leaves the address bar with no reset token, so a + // still-true flag promises a recovery that signing out can no longer complete AND (being checked + // first by VerifyBanner) hides whatever arrived in its place. + it("drops the reset notice once the fragment carrying it is gone", async () => { + const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" }); + + const outcome = await mountAt("/#reset=going-away", verifyEmail); + expect(screen.getByTestId("reset").textContent).toBe("reset-pending"); + + await act(async () => { + window.history.replaceState(window.history.state, "", "/#verify=tok-next"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + + expect(screen.getByTestId("reset").textContent).toBe("-"); + expect(outcome()).toBe("ok:someone@example.com"); + }); + + // …and a fragment change that carries neither token drops it too (the back button out of a + // reset link), which a one-way "set it when you see one" listener would not. + it("drops the reset notice when the fragment changes to no token at all", async () => { + await mountAt("/#reset=going-away", vi.fn()); + expect(screen.getByTestId("reset").textContent).toBe("reset-pending"); + + await act(async () => { + window.history.replaceState(window.history.state, "", "/#somewhere-else"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + + expect(screen.getByTestId("reset").textContent).toBe("-"); + }); + // A reset link can arrive over an unread confirmation (and a slow /auth/verify can resolve after // one did), so both states can be set at once. Neither may eat the other: `dismiss` used to clear // both, which is how the reset guidance could be destroyed before it was ever rendered. diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 index 05ccb7b7..93a494b6 100644 --- a/scripts/backup.ps1 +++ b/scripts/backup.ps1 @@ -46,7 +46,12 @@ if ($dumpCode -ne 0) { docker compose cp "db:$remote" $out $cpCode = $LASTEXITCODE Remove-RemoteDump $remote -if ($cpCode -ne 0) { throw "BACKUP FAILED — could not copy the dump out of the container (exit $cpCode)." } +# Remove the partial file: `cp` streams, so a mid-stream failure leaves a truncated dump under +# the final name — non-zero length, so the size check below would not catch it either. +if ($cpCode -ne 0) { + Remove-Item $out -ErrorAction SilentlyContinue + throw "BACKUP FAILED — could not copy the dump out of the container (exit $cpCode)." +} if (-not (Test-Path $out) -or (Get-Item $out).Length -eq 0) { if (Test-Path $out) { Remove-Item $out } throw "BACKUP FAILED — the dump came out empty; no backup was written." diff --git a/scripts/restore.ps1 b/scripts/restore.ps1 index b1ae4d2f..a99b4048 100644 --- a/scripts/restore.ps1 +++ b/scripts/restore.ps1 @@ -63,10 +63,13 @@ if ($restoreCode -ne 0) { throw "pg_restore reported errors while restoring $DumpFile (exit $restoreCode) — read the output above before treating this instance as restored." } Write-Output "Restored $DumpFile" -$cf = if ($env:COMPOSE_FILE) { " -f $env:COMPOSE_FILE" } else { "" } -Write-Output "api and worker are stopped. Start them again the way you normally start Siftlode:" -Write-Output " docker compose$cf up -d" -Write-Output "Use the SAME compose file you started with — 'up -d' recreates from whatever file it reads," +# Deliberately not a runnable line when we could not resolve the file: both compose files can be +# present (a source checkout), and the two declare DIFFERENT volume names — so a pasted bare +# 'docker compose up -d' would recreate the database on an empty volume and strand the restore. +$cf = if ($env:COMPOSE_FILE) { $env:COMPOSE_FILE } else { "" } +Write-Output "api and worker are stopped. Start them again with:" +Write-Output " docker compose -f $cf up -d" +Write-Output "It must be the SAME file you started with: 'up -d' recreates from whatever file it reads," Write-Output "and a different one points at different volumes. Rolling a release back? Pin the older" Write-Output "IMAGE_TAG in .env FIRST: every start re-runs the migrations, and 'start' would relaunch the" Write-Output "existing container on the image you are rolling back FROM." diff --git a/scripts/restore.sh b/scripts/restore.sh index 4e89d9c0..ca5a748a 100644 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -42,9 +42,16 @@ fi # not swallowed and not dressed up as success. if docker compose exec -T db pg_restore -U "$USER_NAME" -d "$DB_NAME" --clean --if-exists < "$FILE"; then echo "Restored $FILE" - echo "api and worker are stopped. Start them again the way you normally start Siftlode:" - echo " docker compose ${COMPOSE_FILE:+-f $COMPOSE_FILE }up -d" - echo "Use the SAME compose file you started with — 'up -d' recreates from whatever file it reads," + echo "api and worker are stopped. Start them again with:" + if [ -n "${COMPOSE_FILE:-}" ]; then + echo " docker compose -f $COMPOSE_FILE up -d" + else + # Deliberately NOT a runnable line. Both compose files can be present (a source checkout), and + # the two declare DIFFERENT volume names — so a pasted bare 'docker compose up -d' would recreate + # the database on an empty volume and strand what was just restored. + echo " docker compose -f up -d" + fi + echo "It must be the SAME file you started with: 'up -d' recreates from whatever file it reads," echo "and a different one points at different volumes. Rolling a release back? Pin the older" echo "IMAGE_TAG in .env FIRST: every start re-runs the migrations, and 'start' would relaunch the" echo "existing container on the image you are rolling back FROM."