fix(e12): close the eighteen findings the E12 review round left standing

The deep review of the branch raised 47 findings, 18 of which survived adversarial
verification. Grouped by what they actually were:

**The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user
already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one
user over quota was the only one downloading with no ceiling at all. `check_enqueue`
could not catch it: it ran while the job was queued and the user was still under the cap.
The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision
lives, and where it can be tested), and the worker now fails the job outright rather than
proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help
until space is freed.

**Silent failures in the new account panel.** All three actions went through `quiet: true`
endpoints, which suppress the global error dialog on the promise that the caller reports
inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On
the broken-SMTP instance this panel exists for, an admin would read silence as "the mail
went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`,
shared with the pending-access queue, so the two copies of these mutations cannot drift
apart again — which is how half of this was lost in the first place.

**A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps
back one migration, but a release routinely ships several (0.80.0 shipped four): it drops
the newest one's columns and leaves the schema at a revision the older image also cannot
resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is
now the recommended path, and the alembic route asks the target version for its own head
revision instead of counting backwards.

Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they
share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as
a 500; the pull cooldown extended to the sibling route spending the same QuotaAction;
restore stops the app before dropping its tables; the PowerShell cleanup can no longer
decide the script's verdict on 5.1, nor report the wrong exit code; the documented
one-liners hardened to match the scripts; the registry's retention window stated where
pinning is advertised; the two banners made mutually exclusive so neither can shadow the
other; `hover:bg-hover` (a utility this project never generated) replaced by the
`selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to
the two callers that discarded it.

Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset
banner UAT'd on localdev.
This commit is contained in:
2026-09-05 03:41:58 +02:00
parent f98b060e2b
commit fe38dfbcca
28 changed files with 475 additions and 84 deletions
+61 -12
View File
@@ -172,22 +172,41 @@ Dump and restore the database with these two commands. They also exist as script
(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have
the source checkout:
```bash
# Back up (writes backups/siftlode-<timestamp>.dump)
mkdir -p backups && docker compose -f docker-compose.selfhost.yml exec -T db \
pg_dump -U siftlode -Fc siftlode > "backups/siftlode-$(date +%Y%m%d-%H%M%S).dump"
If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below —
the scripts read them from the environment, these one-liners cannot.
# Restore into the running instance
```bash
# Back up. Write to a .part name and rename only on success: redirecting straight into the final
# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte
# file in backups/ that you will not discover until the day you need it.
mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \
docker compose -f docker-compose.selfhost.yml exec -T db \
pg_dump -U siftlode -Fc siftlode > "$out.part" \
&& [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \
|| { rm -f "$out.part"; echo "BACKUP FAILED"; }
# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a
# running api holds locks that make those DROPs queue — abort the wait and you are left with a
# half-dropped database. Restoring with the app down also means nothing writes into a schema that
# is only partly back.
docker compose -f docker-compose.selfhost.yml stop api worker
docker compose -f docker-compose.selfhost.yml exec -T db \
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
docker compose -f docker-compose.selfhost.yml up -d
```
On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the
container and copy it out instead:
container and copy it out instead. Check the exit code between the two steps and use a timestamped
name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would
copy out the previous run's file and quietly overwrite your only backup with a stale one.
```powershell
New-Item -ItemType Directory -Force -Path backups | Out-Null
$out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump"
docker compose -f docker-compose.selfhost.yml exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump
docker compose -f docker-compose.selfhost.yml cp db:/tmp/s.dump backups\siftlode.dump
if ($LASTEXITCODE -ne 0) { throw "BACKUP FAILED - pg_dump returned $LASTEXITCODE" }
docker compose -f docker-compose.selfhost.yml cp db:/tmp/s.dump $out
docker compose -f docker-compose.selfhost.yml exec -T db rm -f /tmp/s.dump
```
## Upgrading
@@ -211,23 +230,53 @@ Then `pull` + `up -d` stays on that version until you change the line. Released
changed in them are listed in the app itself, under **Release Notes** in the account menu, and in
[`CHANGELOG.md`](../CHANGELOG.md).
**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is
"decide when you take the next hop", not "stay here forever": once you are several releases behind,
your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your
Docker images, or move to a new machine. A container that is already running is unaffected (Docker
keeps the image locally), and you can always go back to `latest` or build the image yourself from the
repository. If you want to sit on one version long term, keep your own copy:
`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`.
### Rollback
If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough
**as long as the release did not change the database schema**. If it did, the older image will refuse
to start against the newer schema, and you need to step the schema back first:
to start against the newer schema.
**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back
exactly where the old image expects it, in one step, with no revision arithmetic:
```bash
# 1. stop the app (leave the database up)
docker compose -f docker-compose.selfhost.yml stop api worker
# 2. step the schema back one revision
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade -1
# 2. restore the dump you took before upgrading (see Backups above)
docker compose -f docker-compose.selfhost.yml exec -T db \
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
# 3. pin the previous version in .env, then start again
docker compose -f docker-compose.selfhost.yml up -d
```
If you are not sure whether the schema moved, restoring your pre-upgrade dump is the safer path — it
puts the database back exactly where the old image expects it.
If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade
-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the
newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that
migration's data and still get a crash loop. Downgrade to the revision the target version expects,
which that version can tell you itself:
```bash
# 1. stop the app (leave the database up)
docker compose -f docker-compose.selfhost.yml stop api worker
# 2. ask the version you are rolling back TO which schema revision it expects
IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads
# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image,
# because it is the one that ships the migrations being undone
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade <revision from step 2>
# 4. pin the previous version in .env, then start again
docker compose -f docker-compose.selfhost.yml up -d
```
A downgrade drops the columns the newer version added, so anything stored only in those columns is
gone. That is the price of not having the dump.
### A migration failed on start