182e712a0e396bc3d114362767521a780d61fe66
219 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
182e712a0e |
fix(export): a resumed download can no longer splice two archives together
`serve_file` emitted no validator — no ETag, no Last-Modified — and ignored If-Range entirely, while `resolve_export_file` re-reads `export_current` on EVERY request and a download ticket survives 20 redemptions over 6 hours. So: a guest's 500 MB Gallery.zip drops at 500 MB. The host takes a photo down — epoch bumps, the rebuild lands, the old generation is pruned. The client resumes with `Range: bytes=500000000-`. The ticket and session are both still valid, the handler resolves the NEW archive, seeks 500 MB into a different file of a different length, and streams. The client concatenates the halves into a structurally corrupt ZIP. Nothing logs an error anywhere; a 404 would have been the correct answer. Now every response carries an ETag over the generation-stamped filename plus the length, and a partial is served only against a matching If-Range. A Range with no validator — curl -C -, wget -c, the Android download manager, all of which resume blindly — gets the whole file instead. Restarting a download is a cost; a corrupt keepsake is not recoverable. Browsers send If-Range, so this is also the first release where their resume works at all: with no validator to send, they simply refused to try. |
||
|
|
f403222200 |
fix(deploy): a permanent upload outage, a dead-on-arrival Caddy, and 11pm commands that don't run
* Caddy had no read_body, on the reasoning that "a slow body still has to actually send bytes". That is an argument about disk, and disk is not the scarce resource: upload_admission budgets concurrent bodies at 4096 MiB and reserves the DECLARED cap, so a video/* upload reserves 500 MiB. Eight connections that stall mid-body hold the whole budget, every other guest waits 20s and gets a 503, and it never recovers on its own — the permit is held until the handler returns. No attacker needed: eight guests starting real videos and walking out of AP range does it, and TCP will not reap those sockets for hours. 30m carries a 500 MB upload at ~2.2 Mbit/s, so it does not fail the uploads this product exists to collect. * APP_PORT is presented in .env.example as an ordinary editable line, while the healthcheck hardcodes 127.0.0.1:3000 and the Caddyfile hardcodes app:3000. Change it and the app boots and serves happily on the new port, the healthcheck fails forever, app never turns healthy — and because caddy is gated on service_healthy, CADDY NEVER STARTS. Port 443 dead for the whole event, sole diagnostic "dependency failed to start". Pinned in compose beside MEDIA_PATH and EXPORT_PATH, which are there for this reason. * Runbook §12's recovery commands do not run as written: unwrapped "$POSTGRES_USER" is expanded by the operator's shell, which does not have it, so psql answers `FATAL: role "" does not exist`. §9 documents that trap two hundred lines earlier and wraps its own calls in sh -c; §12 did not. This is the block you run with the app crash-looping behind a live Caddy. Its DELETE also hard-coded versions 21,22,23 as if to be copied verbatim, on a tree that now has 31 migrations — now explicitly an example, with the instruction to take the numbers from the actual boot error. * Migration counts corrected across the runbook and .env.example (22 -> 31, commit count 154 -> 196). All four were presented as literal command output the operator is invited to reproduce. |
||
|
|
4b61f4552b |
test(e2e): fix a flake that went red when the app behaved correctly
storage-purge failed roughly one run in three on two unrelated races, both of
which blamed whatever change happened to be in flight.
`page.goto('/admin')` rejected with "interrupted by another navigation" or
ERR_ABORTED when the admin layout redirected to /admin/login first — i.e. the
test went red precisely when the app did the right thing, quickly. The
assertion is the waitForURL that follows, which does not care how the
navigation ended, so the goto is now allowed to reject. (waitUntil: 'commit'
narrows the window but an abort can beat commit too.)
And the PIN test read the page's execution context while the layout's boot
hydration was still in flight, which surfaced as an intermittent "Execution
context was destroyed". Settles the page first.
Verified with 50 consecutive runs, previously ~1 in 3 red.
|
||
|
|
5aa2b2e886 |
fix(frontend): stop a parked photo being stranded for the session, and fix an SSE id
A parked upload has three ways to be released, and two were weaker than the toast that promises "wird gesendet, sobald die Sperre aufgehoben ist": * The live user-shown / event-opened events only reach a tab with an open stream, and streams are opened by /feed, /diashow, /export, /host and /admin — NOT /upload, which is exactly where the toast sends the guest to watch their queue. * The boot-time release ran once and swallowed any failure, so a single failed request on venue wifi — the condition the whole parking mechanism exists for — skipped it for the entire session, leaving the row reading "Du bist gesperrt." after the ban was long lifted. Now retried once. Deliberately NOT on a 401: api.get already answered that by clearing auth and redirecting to /join, so a second attempt can only fire a second redirect two seconds later, by which time the guest may have navigated away. (That is not hypothetical — it made an existing browser-chaos spec fail while I was writing this.) Guarded rather than an early return, so the SSE listener registrations below still run. Also: noteDelivered mapped upload-processed to p.id, but that payload carries upload_id, so it recorded nothing — the docstring claimed a property the code did not have. And it recorded user-shown against a `carried` clause that only ever means "hidden", where it could only suppress a later genuine signal. |
||
|
|
9f239882ac |
fix(export-viewer): make the self-contained guard, and its spec, actually load-bearing
The font-inlining guard only caught a RENAME. It asked "did /fonts/<listed family>.woff2 disappear?", so an ADDITION walked straight past it — and an addition is the likelier accident: someone doing ordinary app work adds a display font or a decorative background to the shared theme, has no reason to open a viewer build config, and ships a keepsake that reaches for /fonts/Playfair.woff2 on the guest's own disk. font-display: swap hides it, so the artifact looks right to everyone who happens to have the file locally and renders in Times New Roman for the couple. It now asserts the invariant instead of a list: nothing in the emitted keepsake may reference an external URL. Self-maintaining, and it covers fonts, images and stylesheets alike. In writeBundle rather than generateBundle — generateBundle runs more than once and the stylesheet is not inlined on the earlier pass, so asserting there fails a perfectly good build. viewer-no-broken-tiles gets a positive anchor. Its "nothing is broken" check filters img elements, so a viewer that rendered NOTHING yields [] and passes: the one spec whose whole subject is that the images resolve was the one that would have stayed green through a total viewer regression. Everything else it checks comes from the backend and the classic head script, neither of which needs the viewer bundle to have run. And a CI job, because neither of the above fires on its own: no workflow, Dockerfile or script built this viewer, so the guard could sit disarmed indefinitely, and the committed artifact — compiled into the binary with include_dir! — could drift from its source with nothing to say so. |
||
|
|
a2b3cb0e8d |
fix(db): run migrations on their own connection, not a pooled one
after_connect puts lock_timeout = 5s on every pooled connection, and the migrator inherited it. Migrations that take ACCESS EXCLUSIVE — 026's index swap, 027's ADD COLUMN — then turn a short WAIT into a hard FAILURE. The runbook installs an hourly pg_dump (§10.2) and tells the operator to back up before deploying; pg_dump holds ACCESS SHARE on `upload` and `"user"` for its whole run, and the runbook is full of psql snippets that do the same. Boot into that window and the migration aborts, create_pool errors, main exits 1, and `restart: unless-stopped` crash-loops the app behind a live Caddy. The rollback is clean and a later retry succeeds, which is precisely what makes it a baffling intermittent outage rather than an obvious one. 026's own comment reasons that "this runs at boot before the server accepts requests, so the brief lock costs nothing" — true of the app's own sessions, and it does not cover anything else on the database. statement_timeout is dropped for the migrator too: a migration on a real table can legitimately outlast the 15s a request is allowed. |
||
|
|
a428fe6957 |
fix(social): make the counts clients patch with ban-aware, like the view
Migration 028 added `NOT is_banned` to v_feed.like_count and v_feed.comment_count, but not to the two scalar counts in social.rs — which are returned in the response AND broadcast over SSE, and which clients use to patch a card in place rather than refetching. So the two disagreed the moment anyone was banned: the host bans a guest, the feed correctly drops to the lower number, and the very next like on that photo pushes the unfiltered count back to every open client — including the host's, who is watching that number to confirm the ban took. It stayed wrong until a full page-1 refetch. Both call sites carried comments asserting they mirror the view. 028 made those comments false without touching them; this makes them true again. |
||
|
|
6afb33e5b6 |
fix(export): four ways the keepsake could be lost, stranded, or published empty
* The HTML completeness guard counted manifest ROWS, and there are up to two per upload — a thumbnail and a full variant. Thumbnails are 400px JPEGs the export generates itself into its own temp dir, so they are no evidence that any original was captured. After the guard was relaxed to bail only on "nothing written at all", that case could no longer fire while thumbnails kept succeeding: if the media volume became unreadable after the stat pass, every original open failed, every thumb open succeeded, and a keepsake with 100 thumbnails and ZERO full-resolution photos published green, done at the live epoch, with the download button lit. Boot recovery skips a done job, so nothing would ever have rebuilt it. Now counts photos, not files. * A decoder panic failed the ENTIRE keepsake. The `?` was on the JoinError, not on the closure's Result, so a panic in the image crate propagated out where the same file merely failing costs one tile — and it was deterministic, because "Neu erzeugen" reads the same poison file and dies the same way. That is the exact failure shape the completeness guard was relaxed to eliminate, arriving through the other door. * delete_account armed both export jobs and then spawned the workers AFTER an awaited file-removal loop. Axum drops a handler future on client disconnect, and every other invalidate_and_arm call site spawns with no intervening await. Dropped inside that loop, the keepsake is left with the epoch bumped, both rows pending at that epoch, and no worker: the downloads 404 and the UI sits on "Wird vorbereitet..." until someone reboots the app. Deleting your account from a phone that walks out of range is enough. * The daily download quota was charged before the ticket could fail, so a store-capacity 503 — a server-side condition the guest cannot see or cause — still cost one of their three downloads. There is no refund path. |
||
|
|
9b38d31f97 |
fix(upload): stop a late retry from undoing a host takedown
Migration 026 freed the idempotency key as soon as deleted_at was set, so a
retry after a delete uploads afresh instead of 409ing forever. That rationale
only considered the GUEST deleting. deleted_at is also set by
host_delete_upload, and there the same rule reverses a moderation decision:
1. Guest uploads; the row commits and the photo appears, but the response
is lost on the way back — the flaky-wifi case the key exists for — so
the phone keeps the queue item.
2. The host takes the photo down. Epoch bumped, keepsake rebuilt without it.
3. The phone reconnects ten minutes later and retries. The key is free, the
INSERT succeeds, and the photo is back — in the feed and in the next
keepsake, under a NEW uuid that matches nothing in the host's moderation
history, with nothing logged to say a takedown was reversed.
Migration 031 keeps the key claimed for a host takedown and releases it only
for a guest's own delete, so the retry resolves to the duplicate path and is
refused. The refusal now says why ("von den Gastgebern entfernt") rather than
"already processed", which invites another try.
The index predicate and the ON CONFLICT arbiter are changed in lockstep;
these queries are not compile-checked, so a drift between them is a 500 on
exactly the retries the index exists to serve. Verified against a real
Postgres: live retry suppressed, host takedown holds the key, guest delete
releases it. The integration test's copy of the insert is updated too — it is
verbatim by design, and a stale copy would have kept passing.
|
||
|
|
1b3ca46f8a |
fix(auth): three ways one guest on the venue NAT could lock everyone else out
All three are the same mistake in different clothes: a limit keyed on an IP that, behind the venue's NAT, is the entire party plus the host. * join_ip_rate_per_min was raised 60 -> 300 last round and it never took effect. A config default is only a fallback for a MISSING key, and migration 017 seeds this one, so the seed won and the raise was dead code on every real install. Migration 030 raises the seeded value the way 015 already did for upload_rate_per_hour. The e2e guard could not see this: it fires 12 concurrent joins, which is green at 60 and at 300 alike. * /recover's per-(IP, name) bucket charged EVERY request, including successful ones, and refused before verifying the PIN. Its ceiling clamps to 4. So four POSTs naming "Braut Sophie" with PIN 0000, from any phone on the venue wifi, locked Sophie out of her own recovery for fifteen minutes WITH THE CORRECT PIN — and four more every fifteen minutes sustained it indefinitely, at a rate far under every volume ceiling above it. The benign version needs no attacker: the host mistypes their own PIN four times. Hosts are promoted guests whose only credential is that PIN, and /recover is their only way back after losing a session. Now it counts failures, and a spent budget changes what a FAILURE answers instead of refusing outright. Guessing is bounded exactly as before — wrong PINs are what spend it — with the per-account lockout underneath. * /admin/login's pre-verify ceiling had the same shape, and the escape hatch was circular: admin_login_rate_enabled is only flippable through PATCH /admin/config, which needs the session being refused. One phone posting twice a minute cost the operator moderation, gallery release and every config key, including the ones that would undo it. Exceeding the ceiling now shortens the hash-permit wait rather than refusing: the CPU bound was always the semaphore, never this bucket, so a flood still sheds itself while a correct password gets a truthful answer. Adds a regression test that reads the value a fresh database actually ends up with, by replaying the migrations — the drift that made the first bullet invisible is not otherwise detectable from the code. |
||
|
|
0c0d5d5981 |
fix(deploy): give Postgres a CPU floor that Docker actually honours
`deploy.resources.reservations.cpus` was doing nothing. Outside Swarm, `docker compose up` silently drops it — verified by inspecting a running container, where CpuShares, CpuQuota and CpusetCpus were all unset while `limits.cpus` and `reservations.memory` came through as NanoCpus and MemoryReservation. So the comment calling it "the piece that actually protects the database" described a guarantee the box never had. It matters on the CX22 the runbook targets: the ceilings sum to 1.2 + 0.6 + 0.5 = 2.3 on 2 vCPU, so the other services can oversubscribe the machine, and with every container on the default weight Postgres competed on equal footing with two image resizes and an ffmpeg poster. Replaced with `cpu_shares`, which does survive the translation — db 2048, caddy 1024, app 512, frontend 256 — so the weighting only binds when the CPU is actually saturated, which is the moment the database must not lose. The Caddyfile gains a 10s header-read timeout: there was no read timeout anywhere, so a client could hold a connection, a tokio task and a `.tmp` file open indefinitely by sending one byte a minute, and the upload sweeper is keyed on mtime precisely so a live upload never ages out. Body reads stay unbounded — a 500 MB video over cellular legitimately takes minutes, and a body timeout would fail exactly the uploads this product exists to collect. .env.example documents that estimated_guest_count is a live input to the quota divisor rather than the inert setting both it and the runbook previously implied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
32dfe6874a |
test(e2e): make nine red specs assert the contracts the code actually implements
The e2e suite had never been run during this audit. It failed 9 of 256; seven of those predated the audit's changes, established by building a stack from a clean HEAD worktree and running the same specs against it rather than guessing. Most were stale assertions rather than product defects: - quota.spec solved for a target limit using the observed uploader count, but the divisor is max(active, estimated_guest_count, 1) and that config seeds at 100 — so every limit it aimed for came out 100x small and every "within quota" upload 413'd. - rate-limit-shared-nat destructured `ticket` from a 429 body and fetched with `ticket=undefined`, turning the 429 under test into an unrelated 401. It also faked a release with no archive on disk, so the mint's pre-check 404'd and the per-day limiter was never reached; it now does a real release and asserts 200 rather than "not 429". - ddos allowed only [200,429] from ten concurrent streams, so it failed on the very defence it exercises: four tickets per session survive and the rest correctly 401. Now asserts exactly four, which a tightened cap or an inverted eviction order would catch. - auth-tampering asserted a throttled IP is refused EVEN with the correct password. That contract was deliberately removed — it let any phone on the venue NAT lock the operator out of their own admin panel, with a circular escape hatch. Inverted, plus a new check that a success does not refill an attacker's bucket. - moderation-ui assumed a ban leaves a comment "stuck on screen"; `list_for_upload` filters banned authors, so it is hidden from everyone including the host. Now pins the pair that matters — the ban hides it, and the host's permanent removal survives an unban — and the UI leg it used to own is restored as a separate test on a reachable comment. The export specs mint with `?kind=` now that a download ticket is bound to one archive, and four of them assert the mint's 404 rather than the download's: with the kind always known, the pre-check refuses up front instead of after charging a daily download for an archive that cannot be served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a53729a704 |
fix(frontend): park uploads that cannot succeed, and stop two false signals
The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f5c55d6f92 |
chore(backend): route wiring, error mapping, and a crossbeam-epoch bump
Cargo.lock moves crossbeam-epoch to 0.9.20, clearing RUSTSEC-2026-0204. Targeted rather than a broad `cargo update` across 406 crates, which is not a change to make days before a live event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8720571beb |
fix(export-viewer): inline the webfonts so the offline keepsake is self-contained
The viewer inherits `src: url('/fonts/Inter.woff2')` from the shared theme CSS. That is
correct for the app, which serves `static/fonts/` from the site root — but the keepsake is
opened from file:// off a USB stick or a Downloads folder, where `/fonts/...` resolves to
the root of the guest's DISK. Both requests 404, and `font-display: swap` makes it silent:
the viewer renders in a fallback system font with nothing server-side able to report it.
Found by opening a real released keepsake in a browser and watching `requestfailed` — no
other signal exists, which is the recurring lesson about this artifact.
Fixing it in the shared CSS would inline ~154 KB into every app page load for nothing, and
shipping a `fonts/` folder beside index.html gives the guest a directory they can break by
moving one file. So the substitution belongs in the build that knows its output has no
origin. The plugin errors the build if the theme ever stops referencing those URLs, rather
than silently shipping another keepsake in Times New Roman.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c9a4d4a9c0 |
fix(export): stop the keepsake guards from destroying the keepsake
Two guards added to protect the archive each had a failure mode worse than the one they prevented, and both were unrecoverable — which is what makes them worth reverting rather than tuning. The completeness gate refused to publish once skips passed max(2, 10% of expected). That refusal is DETERMINISTIC ACROSS RETRIES: the unreadable files are still unreadable when the host taps "Neu erzeugen", and the gallery is already released so the uploads cannot be collected again. On a 30-photo event, four bad files meant nobody ever got the other 26. That is precisely the "one-photo gap becomes total loss" outcome MAX_SKIPPED_FRACTION's own comment says it exists to avoid. Anything short of an empty archive now publishes and logs the counts at error level. `written == 0` stays fatal — a wrong MEDIA_PATH is a misconfiguration the host CAN fix and retry, and it once shipped a few-hundred-byte ZIP containing zero photos that passed every automated check. The space reclaim refused to prune unless it freed the entire shortfall, to protect an archive that no handler can serve: a download resolves through `export_current`, which requires `job.epoch = event.export_epoch`, and the epoch only increments. Meanwhile `reclaimable` is scoped to the caller's own prefix — one old archive — while `deficit` is sized for both halves plus the reserve. So on a tight disk each worker measured its own share as insufficient and neither pruned, though the two shares were jointly sufficient. Every "Neu erzeugen" reran the identical arithmetic and refused identically: permanently stuck, with dead archives nothing would reclaim and nothing could serve. It now prunes what it can and lets the re-check decide, so the sibling's prune lets the host's retry converge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7154b3a810 |
fix(upload): remove the /original rate limit that would have broken the feed
The limiter added here was justified as bounding "100 guests occasionally tapping Original anzeigen". That is not what this route is. `pickMediaUrl` resolves to `preview_url ?? thumbnail_url ?? /original`, and a freshly committed upload has BOTH derivatives null until the compression worker reaches it — at COMPRESSION_WORKER_CONCURRENCY=2 that is minutes during a post-ceremony burst. So /original is the feed's hot path for exactly the newest photos, in a newest-first grid, at the busiest moment. With every guest behind one NAT the 600/min bucket is venue-wide: six new photos fanned out by `upload-new` to ~100 open feeds exhausts it, and then every original fetch from anyone 429s for the rest of the window. The tiles' own 4-second retry uses a fresh `?r=` nonce, so the clients hold the bucket saturated themselves — the whole venue watching the newest photos render as broken tiles while the projector skips slides. A per-IP bucket cannot separate one scraper from the entire party when they share an address, and these media routes are unauthenticated by design (an `<img>` cannot send a bearer token), so there is no per-user key to move to. Bandwidth abuse belongs at the proxy. Also here: the release/lock check order. `release ⇒ lock`, so testing the lock first made the `GalleryReleased` arm unreachable dead code and every post-release upload answered `uploads_locked`. The codes are not interchangeable to the client — `uploads_locked` charges a retry attempt and re-pushes the whole photo on the backoff ladder against an answer that cannot change, while `gallery_released` parks it and says the photo is safe but the hosts must reopen. Both sites now test release first, so the fast path and the commit-time re-check agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ec7c7f18ca |
fix(auth): stop one guest on the venue NAT from locking everyone else out
Every guest at the venue shares one public IP, so an IP-keyed limiter throttles the whole party as a single client. Three separate limits got that wrong, and the host — whose only credential is a 4-digit PIN — was the one who could not absorb it. /recover carried a cross-name failure budget checked BEFORE the account lookup, so it refused a CORRECT PIN. Thirty POSTs with invented names spent the shared budget for fifteen minutes and ~2 requests/minute sustained it indefinitely, denying PIN recovery to everyone including a host locked out of their own event. The budget is now carried as a flag: a correct PIN authenticates regardless, while wrong ones answer 429 instead of 401. Guessing stays bounded where it always really was — the per-(IP,name) ceiling and the per-account 3-strike lockout, neither of which an attacker on any IP can evade. join_ip went from 60/min to 300. A 100-guest wedding does not trickle in; it arrives when the QR code goes up, all from one address, and guests 61-100 were turned away on the one screen with no auto-retry. This limit only bounds raw volume — the per-name bucket is the anti-spam control and BCRYPT_PERMITS is the CPU bound — so it can sit well above the peak. Download tickets are now bound to ONE archive via `TicketKind::Download(ExportKind)`. Both download routes share an authenticator, so a bare ticket opened either; combined with the resume budget that made a single mint worth 40 transfers of a multi-GB keepsake while the per-day limiter, charged only at mint, never moved. `kind` is consequently required at /export/ticket; every shipped client already sends it. The per-session ticket cap is now per-kind. A 6-hour download ticket is always the oldest entry for its session, so ordinary SSE churn evicted it first — and /export opens its own SSE connection on the session that just minted it. A couple of wifi flaps mid-transfer killed the ticket, 401'd the resume, and cost the guest another of three daily downloads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
963f6449a1 |
feat(db): idempotency keys, ban-aware counts, and a host audit trail
Four migrations, all additive against a database that already has 001-025 applied. 026 narrows the client-upload idempotency index with `AND deleted_at IS NULL`. The old index made a soft-deleted row keep its key forever, so a guest who deleted a photo and re-sent the same one had the retry silently swallowed. The new indexed set is a strict subset of the old, so it cannot fail on existing rows. 027 adds `client_join_id`, which lets a join retry after a lost response resume the same account instead of 409ing on a name the caller itself owns. Every existing row gets NULL and the partial index excludes NULLs, so it indexes nothing at creation. 028 brings the feed view's like/comment counts in line with what the feed actually renders: a banned guest's rows were still counted, so a card showed "3 comments" above two. `comment.rs` gets the matching `NOT u.is_banned` on the live read path — the export and hashtag queries already filtered it, so the two views of one moderation action disagreed. 029 records host moderation actions, which were previously invisible after the fact. Verified by applying 001-029 to a real Postgres against seeded data, including a soft-deleted row holding a key and a banned user's like and comment. 026's down-migration legitimately fails where a deleted and a live row share a key — that is inherent to the direction, documented in the file, and sqlx never runs downs at boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ef6d3a077a |
fix: close what nine adversarial reviews found, most of it mine
Some checks failed
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m5s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m55s
Checks / E2E — typecheck + lint (push) Failing after 49s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 10m42s
E2E / Cross-UA smoke matrix (push) Failing after 7m57s
Audit / cargo audit (backend) (push) Failing after 11m12s
Audit / npm audit (frontend) (push) Successful in 44s
Nine focused reviews (export state machine, upload path, auth/abuse, client queue, guest UI, database, deploy/ops, regression hunt, test honesty). Every finding below was re-verified against the code before being acted on; several plausible-sounding ones were checked and rejected. ## Data loss and denial of service **One request could OOM-kill the app container.** `client_upload_id` was read with `Field::text()` — axum builds its multipart reader with no SizeLimit, so the only bound was the route's 576 MiB body limit, then decoded into a second full String. `caption` and `hashtags` go through `read_text_field_bounded` for exactly this reason; this field arrived later and missed it. Any guest, one request, and every SSE stream drops and every in-flight temp file is stranded. **Nothing bounded concurrent upload bodies.** The headroom gate can only refuse to COMMIT — the body is already streamed to a temp file by the time it runs, and neither axum, the tower stack nor Caddy limits how many stream at once. ~100 guests tapping "upload all" after the ceremony puts 10-20 GB of .tmp on a 40 GB volume, invisible to the gate, eating the reserve that keeps Postgres able to write WAL. New `UploadAdmission` budgets bytes (not requests, so one video and two hundred photos coexist) via a permit that releases on drop, so every exit path returns it. **The export decode bypassed the memory permit the compression path takes.** Same class of work — decode + resize every image in the gallery — in a bare spawn_blocking. A release fired while the last photos were still compressing put both in the same 1 GiB cgroup; the OOM kill marks the export failed and `recover_exports` re-spawns it into the same conditions on the next boot. The permit is now process-wide in `imaging`, because the constraint it expresses is the container's memory, not one worker's. **`MediaTotalCache` cached its own failure as 0.** For the whole TTL the gate then saw an empty event and collapsed to the flat reserve — the behaviour the two-halves design replaced — with no log line. And the trigger correlates with the danger: with max_connections 10 the query fails exactly during a burst. Now falls back to the last good reading and says so. **V8's heap ceiling sat above the frontend container's entire budget** (measured: 259 MB inside a 256M limit), so GC could never intervene and the only backpressure was SIGKILL under an arrival burst. ## Guest-visible **The feed stopped being newest-first after the first reconcile.** It fetches whole 100-item server pages while `uploads` grows in 20s, so everything in the gap was absent from `present`, classified as new, and prepended — ~80 photos from earlier in the evening above the newest ones. It also stalled infinite scroll, since the cursor still pointed at item 20 and the observer only re-fires on a change. The union is now sorted on the server's own (created_at, id) key, which additionally places an SSE arrival correctly. **A stale `loadMoreError` outlived every refresh and filter change**, leaving a false error above a button that returns immediately on `!nextCursor`. **A failed derivative toasted "Ein Upload konnte nicht verarbeitet werden."** for a photo sitting right there on screen — the handler still assumed 1d9fb11's pre-fix behaviour (row deleted, quota refunded, card evicted), none of which is true any more. It was the last surviving route for the "your photo is gone" signal that fix set out to remove. ## Enforcement that existed only in comments `recover_name_rate_per_15min` is clamped at the point of use: the ordering `3 x ceiling <= PIN_LOCK_THRESHOLD` is the whole control against one source locking any guest whose name is on the feed, it was asserted in a comment, and `patch_config` accepted 1..100_000. The test pinned the default constant rather than the enforced bound; it now pins the bound. ## Tests that could not fail - The gate test asserted only its own premise (`500MB x 100 > 35GB`) and never touched the gate. It now checks both controls against the same state and requires them to disagree in the right direction. - `the_banner_always_fires_before_the_upload_gate_closes` reduced to `G < G + G/4` — true for any margin, including zero, so it could not detect the banner moving to exactly the gate. It now pins the gap. - `disk_is_low`'s `free < LOW_DISK_FLOOR_BYTES` clause was unreachable (warn_at is always >= 12.5 GB against a 10 GB floor). Two tests were named after it and neither could fail if it were deleted. Clause and constant removed. - The suspension test I added last commit hard-coded the credit cap instead of importing it, so changing STALL_TIMEOUT_MS would leave it passing against a system that no longer exists. Now imports MAX_SUSPEND_CREDIT_MS. ## Stale comments corrected The prune doc still argued at length for the pre-build ordering that |
||
|
|
214f9e3062 |
fix: close four confirmed defects an adversarial review found
Findings from a multi-angle review, most of them in code I wrote in the last
few commits. Each was verified against the code before being acted on.
## Backend
**The export daily limit was bypassable ~60x/minute.** `SseTicketStore` is
untyped, and the export download quietly started reusing it. `POST
/stream/ticket` is free and rate-limited at 60/min per user; `POST
/export/ticket` charges one of three PER-DAY downloads. So a guest could mint
at the cheap endpoint and redeem at the expensive one, each redemption
streaming the whole multi-GB keepsake, `no-store`, off the same filesystem
Postgres writes WAL to. Tickets now carry a `TicketKind` and `consume` requires
it to match, asserted in both directions. The comment claiming "one mint is at
most one download" was simply false.
**`export_ticket` answered 200 `{"ticket": null}` when the store was full** —
after charging a daily slot. `issue` returns `Option`; `sse.rs` handles the
None with a 503 and this call site unwrapped it into the JSON body. The page
toasted success, the iframe navigated to `?ticket=null`, and one of three
downloads was gone. That is the phantom-success failure the pre-validation in
|
||
|
|
253878e027 |
fix(export): give the keepsake viewer the two-phase preflight it was meant to get
The two-phase preflight from
|
||
|
|
5b705317ef |
fix: close the last three guest-facing dead ends (items 7-9)
C1 — a failed page-append silently ended infinite scroll `loadMore`'s catch showed a toast and changed no state, unlike every sibling error path in the file. `nextCursor` survived so the feed was still technically paginable, but the IntersectionObserver only fires on a CHANGE: after a failed append nothing scrolls and no rows are added, so it never re-fires. One 429 or wifi blip and the guest concluded the gallery was 20 photos. Now leaves a retry control at the sentinel — a toast that fades in 5s is not an affordance — resuming from the untouched cursor. C2 — the export page reported downloads that never happened `downloadFile` toasted 'Download gestartet' the instant it assigned the iframe's src, before a single byte existed. Since the iframe swallows errors BY DESIGN (a top-level navigation to a 404 would unload the PWA), a failure produced a green success message, a consumed single-use ticket, and one of only three daily slots spent — repeatable until the day's allowance was gone, on the screen that is the whole point of the app. Root cause is two sources of truth: `export_status` reports `done` from `export_job` and enables the button, while the download resolves through `export_current.file_path` plus a `Path::exists()`. They can legitimately disagree. `export_ticket` now takes a `kind` and calls the existing `resolve_export_file` BEFORE charging the rate slot, so a missing archive fails honestly on a plain fetch that `toastError` already renders. Not the HEAD probe ruled out elsewhere: it reads the same indexed row the download will read and touches no ticket, so it cannot consume anything. The parameter is optional, so an older client degrades to today's behaviour rather than breaking. C3 — the WhatsApp journey could dead-end with no error at all The join link travels through guest group chats, and a link tapped inside one opens in that app's browser, where the file picker and getUserMedia both depend on the host app having wired them up. When they aren't, the buttons do nothing — no error, nothing to act on. Two targeted changes rather than a UI rebuild: the camera error panel now offers "Aus Galerie wählen" (its advice to change "Browsereinstellungen" refers to settings that do not exist in a webview, so retrying could never help those guests), and the sheet carries a standing one-line hint to open the link in Safari or Chrome. Deliberately no user-agent sniffing: a sniff list is wrong for every browser it has not heard of, while a quiet standing hint costs one line and is never wrong. The hint lives in UploadSheet rather than the root layout because both layout banners are gated on `$showBottomNav`, which `/upload` turns off — one there would never render on the composer. Verified: 151/151 backend tests against a live Postgres, clippy clean, 58/58 vitest, svelte-check 0 errors, eslint clean, both builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
05063694d2 |
fix: close eight regressions the audit pass found, five of them mine
Two adversarial reviews over |
||
|
|
23e2f485dd |
fix(upload): correct two errors in the keepsake headroom gate
Both found reviewing my own change rather than by a test, which is the point. DOUBLE-SUBTRACTION. The gate computed `free - size`, but the body is streamed to its temp file during multipart parsing — far above the gate — so the free-space reading already excludes those bytes. Subtracting again refused uploads a full file-size early; with max_video_size_mb at 500 that is half a gigabyte of phantom pressure. `media_total` genuinely does need `+ size` (its row is not committed yet), which is what made the asymmetry easy to miss. BLOCKING SCAN ON THE HOTTEST PATH. It called `disk::free_bytes`, whose doc comment says it deliberately bypasses DiskCache — but that rationale is the export preflight's: a rare, high-stakes decision where a sibling worker can move free space by tens of GB inside the TTL. Per upload it means sysinfo re-scanning every mount, synchronously, on the async runtime, on a 2 vCPU box with two worker threads. Now uses the cached snapshot, the same 15s staleness the quota check immediately below already accepts for the same question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eb0e405562 |
fix: gate uploads on keepsake headroom, and close five unattended-event gaps
The box is 2 vCPU / 4 GB / 40 GB, not the 4 vCPU / 8 GB / 80 GB that the audit, the committed comments and README's sizing section all assumed. That correction is what the first change is about; the rest are the remaining pre-event items. THE ARCHIVE COULD BECOME UNBUILDABLE WHILE UPLOADS KEPT SUCCEEDING `required_free_bytes` is `media × 1.1 × 2` — the ZIP and the HTML viewer are each gallery-sized — and the export preflight also wants DISK_RESERVE_BYTES on top. The upload gate, though, only refused below a FLAT 10 GB reserve. On 40 GB that let uploads run to ~25 GB of media while a release needed `2.2 × 25 + 10` = 65 GB free. Every upload in that band succeeded and the keepsake could then never be built: the product's entire promise, failing silently at the end of the night with nobody there. The gate now enforces the invariant that actually matters — never accept an upload that would make the keepsake unbuildable — sharing `required_free_bytes` with the preflight so the two cannot drift into disagreeing about the same question. Uploads stop at ~8 GB of media on this disk, with a German message naming the cause. Refusing the 1001st photo beats losing all 1000. `media_total.rs` backs it: SUM(user.total_upload_bytes) over ~100 rows, cached 5s, rather than `estimate_export_bytes`'s join across every upload. It counts hidden and banned users' bytes, which the export excludes — skew in the SAFE direction, so the gate closes marginally early rather than late. Fails open on a query error. A test pins the gate against the preflight across the whole gallery-size range, and a second asserts the per-user floor alone would over-commit the volume — i.e. that the global gate is what must bind. THE WATCHDOG ABORTED HEALTHY UPLOADS EVERY TIME A PHONE WAS POCKETED `Date.now()` advances while a backgrounded phone is frozen but `setInterval` does not, so the first tick after a screen lock read the whole sleep as silence and aborted — re-sending a video from byte zero and burning one of five PERMANENT auto-attempts. The interval is now its own suspension detector: a tick that arrives 125s late for a 5s schedule credits that window back, because a period the watchdog could not observe is not evidence of silence. Chosen over a `visibilitychange` listener, which only covers causes that fire that event — a throttled-but-visible tab, a closed lid and an occluded window all freeze timers without one — and which would have needed module state, an SSR guard and a teardown for strictly less coverage. `performance.now()` was rejected because Safari pauses it across system sleep on some paths and Chrome does not. The credit buys one fresh window, not immunity: a socket iOS reaped while backgrounded still aborts ~90s after resume rather than hanging for `xhr.timeout` (5-60 min) with the queue's `processing` latch held. Two latent leaks found while in there: `xhr.abort()` on a request already in readyState DONE emits no `abort` event, so `settle()` never ran and the interval re-aborted every 5s forever while `activeUploads` kept a stale entry (the ✕ button silently stopped working); and a synchronous throw from `xhr.send` — a blob whose backing store the OS purged — leaked the same way. Both closed. OKLCH MADE THE DELETE BUTTON INVISIBLE ON SAMSUNG'S DEFAULT BROWSER red/amber/green were never in the @theme block and fell through to Tailwind v4's `oklch()` defaults, which Safari <15.4, Chrome <111 and Samsung Internet <22 cannot parse: `var(--color-red-600)` is then invalid at computed-value time, `background-color` falls back to transparent, and `.btn-danger` renders white text on nothing. Pinned to Tailwind's own defaults gamut-mapped to sRGB by Lightning CSS — the converter already in this pipeline — so modern browsers render exactly what they render today. Verified against seven hex fallbacks it had already emitted for the /alpha forms. rose and teal (avatar chips) had the same leak. The app CSS goes from 40 oklch declarations to 0. Also fixes `--color-purple-950`, which was simply missing: `dark:bg-purple-950/50` on the host dashboard was rendering default violet on EVERY browser, off-brand. The keepsake viewer only picks this up on a rebuild, so its committed artefact is rebuilt here too — still single-file, still zero external references. A BRICKED BOOT LOOKED LIKE A SPINNER FOREVER With `ssr = false` the page is empty until the bundle mounts, so a chunk 404 after a redeploy or a dead uplink left the guest on the boot spinner with no message, no reload control, and in a standalone PWA no URL bar. A 15s timeout in the existing nonce'd IIFE (no CSP change) swaps in German copy and a reload button. Deliberately a timeout rather than feature detection: a SyntaxError in the bundle is invisible to any capability check. Plus a <noscript>, since there was nothing at all to see without JS. EVERY 4xx WAS INVISIBLE AT ANY LOG LEVEL tower_http counts 4xx as a success, so it logs at DEBUG while production runs at info. If guests spend the evening hitting 429s or 413s, the post-event logs said nothing. Now one WARN per client error; 5xx excluded because Internal already logs its source chain and the pool-exhaustion 503 logs at construction. A DEAD FRONTEND SERVED A BLANK 502 `handle_errors 5xx` with an inline German page (the caddy service mounts only the Caddyfile, so there is no volume to ship a static file through). Verified empirically against this config, not from documentation: an upstream 404 through `reverse_proxy` still arrives as untouched `application/json`, and only a dial failure renders the page. That mattered — the keepsake download navigates a hidden iframe and DEPENDS on a real 404/429 arriving, and swallowing those would have been worse than the blank 502. CONFIG CORRECTIONS FOR THE REAL HARDWARE DATABASE_MAX_CONNECTIONS 30 → 15: sized to 2 vCPU rather than to the guest count. Since migration 024 a feed page costs well under a millisecond, so connections are no longer spent waiting, and 30 backends crowd the db container's 1 GB on a 4 GB host. COMPRESSION_WORKER_CONCURRENCY stays at 2 — the merged heavy-image permit already serialises anything over 150 MiB, so the "two 48 MP photos" worst case that number was sized against is unreachable; dropping to 1 would halve light-path throughput and push more feed tiles onto full-size originals. README's sizing section rewritten for the actual disk. Verified: 149/149 backend tests against a live Postgres, clippy clean, 57/57 vitest, svelte-check 0 errors, eslint clean, vite build, export-viewer rebuild, caddy validate, compose YAML parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1d9fb11c7b |
fix: close the nine ways an unattended event loses photos or dies
Every one of these was found in the pre-event audit, verified against source, and
survives to production on the current main. Grouped by what actually goes wrong.
PHOTOS DISAPPEAR
* compression.rs no longer soft-deletes on a failed derivative. The guest got a
201, watched the card appear, then watched it vanish — the row left v_feed,
find_visible_media and BOTH keepsakes, while its bytes sat on disk for 14 days
waiting for a cleanup nothing announced. No screen anywhere lists compression
failures, so recovery meant hand-written SQL that also had to re-add the
refunded quota. Now it does exactly what the ENOSPC arm beside it already did
and documented as correct: keep the row, serve the original, retry on the next
boot (bounded by derivative_attempts). `upload-deleted` is no longer emitted;
`upload-processed` is, so the card re-renders instead of sitting on a
placeholder.
* A 413 is now a reversible lock, so the blob survives. The quota moves — free
disk falls, uploader count rises — so a guest goes over it having done nothing,
and treating that as permanent meant a 400 MB video was pushed across cellular
in full and THEN deleted from IndexedDB. Gone on both sides, and unrecoverable
for an in-app camera capture that exists nowhere else.
* quota_limit_bytes gained a floor and a stable divisor. The ceiling used to
decrease monotonically all evening; it now settles at max(uploaders,
estimated_guest_count) — a config key that was seeded, validated in the admin
whitelist, and read by no code at all. The floor is clamped to what the disk
can actually back, so a full volume still yields zero rather than handing out
an allowance it cannot honour.
* Because that floor gives up the aggregate guarantee the formula used to imply,
uploads now check a hard 10 GB reserve first, independent of every quota
toggle. postgres_data, media_data and exports_data share one filesystem: the
end state was not a degraded feature, it was Postgres unable to write WAL.
THE ARCHIVE DISAPPEARS
* prune_superseded_archives runs only after the new generation lands. It ran
before the preflight, reasoning the old archive was already unreachable — true
of reachability, false of recoverability. An epoch is a value that can be
rolled back; deleted bytes cannot. Any failed rebuild left the event with NO
keepsake at all.
* The export preflight reserves the same 10 GB. `free < needed` authorised an
export sized at exactly free, which ran for half an hour and landed the box at
zero with the keepsake still unfinished.
THE APP DIES
* The feed reconcile re-reads the id set after its awaits instead of reusing one
captured up to three round-trips earlier. The new-upload SSE handler prepends
during exactly that window, so the row was both already present and absent from
the stale set — prepended twice, and a duplicate key in a keyed {#each} throws
in production, not just dev. The SSE handler and loadMore now dedupe too.
* Added routes/+error.svelte. Without it any uncaught error fell through to
SvelteKit's unstyled English 500 with no reload control — inside a chromeless
standalone PWA with no URL bar, for the rest of the evening.
THE OPERATOR IS LOCKED OUT
* admin_login verifies the password BEFORE charging the rate bucket, and a
correct password is never throttled. The old order made this a denial of
service against its own operator: every guest shares one NAT IP, the check ran
first, so five requests a minute from any phone in the room kept the bucket
full — and the escape hatch needed the admin session being blocked. A generous
separate ceiling still bounds bcrypt CPU.
THE PROJECTOR DIES
* The preload budget is now strictly inside the dwell. At the 3s option the 4s
budget could never land a commit on a slow uplink, so the wall froze on one
photo while the queue drained silently behind it.
* The wake lock retries every 30s while visible, and the page says so on screen
when the browser has no wake lock API. visibilitychange was the only retry
trigger and a kiosk never changes visibility, so one refusal — iOS in Low Power
Mode, say — was permanent.
* Caddy: /api/v1/upload/*/display joins the cacheable carve-out. The backend set
max-age=300 on it and the blanket no-store silently replaced it, so a projector
re-fetched a full-size JPEG per slide, ~2-4 GB over an evening on the uplink
the guests are uploading over.
Also removes Upload::soft_delete, now unreferenced and an unscoped footgun next
to soft_delete_in_event.
Verified: 146/146 backend tests against a live Postgres, clippy clean, 51/51
vitest, svelte-check 0 errors, eslint clean, vite build, caddy validate, compose
YAML parse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
61119be817 |
Merge branch 'fix/deploy-unattended-blockers' into main
Two independent lines of production hardening diverged at |
||
|
|
e1c689d1a7 |
chore(migrations): renumber 021-023 to 023-025 to clear the collision with main
main independently shipped 021_hashtag_counts_respect_bans and 022_client_upload_idempotency. sqlx::migrate! embeds ./migrations and refuses two files per version, so these three had to move before the branch could merge at all. Contents are untouched — only the version prefixes change. Renumbering (rather than renumbering main's) is the safe direction: main is already deployed, so its 021 and 022 are applied in production and their versions are now immutable. |
||
|
|
d5b4bf0ac1 |
fix(camera): get the upload sheet out from in front of the shutter button
Some checks failed
Audit / cargo audit (backend) (push) Failing after 12m25s
Audit / npm audit (frontend) (push) Failing after 23m21s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 55s
Checks / Frontend — vitest + svelte-check (push) Failing after 42m2s
Checks / E2E — typecheck + lint (push) Failing after 20m58s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 19m54s
E2E / Cross-UA smoke matrix (push) Failing after 20m27s
Tapping "Kamera" opened the viewfinder with the Galerie/Kamera sheet still sitting over the bottom of it, covering the capture controls. The sheet is `fixed`, so it could not be scrolled out of the way: the only route to the shutter was the phone's back button, which is not a discoverable step and is one most guests would read as "the camera is broken". Two independent causes, both fixed, because either one alone leaves a gap. The sheet never closed. It stays mounted for its translate-y animation and nothing told it the camera had taken over, so it kept its panel, its backdrop and its `aria-modal` while a full-screen overlay was up. `CameraCapture` now reports when its preview is live and the sheet dismisses itself on that signal. Deliberately on the preview, not on the tap. Closing when "Kamera" is pressed would dismiss the sheet before we know the camera works at all — and it often does not: a denied permission, no camera, or any non-secure context (where `navigator.mediaDevices` is simply absent) all end at the error panel. Closing early would leave the guest looking at that error with nothing behind it. Gated on `loadedmetadata`, the sheet is still there when the camera fails, so "Schließen" returns them to where they were. The signal is one-shot, because flipping the lens or switching photo/video re-acquires the stream and re-announcing "ready" would ask the caller to redo a dismissal it has already done. And the stacking was ambiguous. Both elements were `z-50` and the sheet is rendered after the camera, so it won on paint order. The overlay moves to `z-[60]` — the tier the Toaster already occupies, so toasts still surface above the viewfinder on DOM order. This is the part that holds regardless of timing: the controls are now reachable during the permission prompt and on the error panel, before anything has been dismissed. Focus follows the same reasoning. When the camera closes the sheet, restoring focus immediately would put it on the FAB *behind* the overlay, where a Tab could walk the page underneath; it is restored when the overlay goes away instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ae5a64e77 |
Merge branch 'chore/production-readiness'
Some checks failed
Audit / cargo audit (backend) (push) Failing after 12m13s
Audit / npm audit (frontend) (push) Successful in 44s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 37s
Checks / Frontend — vitest + svelte-check (push) Successful in 10m49s
Checks / E2E — typecheck + lint (push) Failing after 42s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m27s
E2E / Cross-UA smoke matrix (push) Failing after 6m24s
|
||
|
|
edc5f1f62c |
chore(export-viewer): rebuild the embedded bundle, and fix the lint ignores
The offline keepsake viewer had the same two filter defects as the app: typed suggestions were capped so a matching tag could be unselectable, and the dropdown used `onmousedown` with a backdrop that swallowed the selection. Rebuilt into `backend/static/export-viewer/index.html`, which `include_dir!` embeds in the binary. The eslint ignores were unanchored, so once the export-viewer's dependencies were installed its nested `.svelte-kit` output was linted as source. Anchored with `**/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
46bb2e5174 |
docs: correct the claims that no longer match the code
Checked each against the implementation and fixed the doc, never the code: - FEATURES claimed the ban modal offers a choice about hiding existing uploads. There is no such choice — a ban always hides. USER_JOURNEYS §9 was already right. - FEATURES claimed hosts may demote other hosts. It is admin-only, enforced in the backend, and the two documents contradicted each other on it. - FEATURES showed the quota widget as guest-facing; it is deliberately staff-only. - The first-visit tour has six steps, not four. - USER_JOURNEYS §12.7 said export downloads are rate-limited per IP. They are per USER — a materially different thing at a shared-NAT venue, where per-IP would have locked out the fourth guest to fetch their keepsake. - §15 still described "Event verlassen"; that button is now Abmelden / Auf allen Geräten abmelden. - §4, §13, §14, §16 and §18 were marked "(planned)" and have shipped. - The lightbox row now describes what exists after this branch: prev/next controls, arrow keys and swipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b1500e624 |
fix(ui): make the dashboards agree with each other and with what the code does
Host and admin implement the same four operations with independently written copy, and admin was stale or wrong in every case. Its release button was always enabled and always read "Galerie freigeben", so a second tap returned a 409; it showed no release state, no keepsake progress, no failure reason, no rebuild, and never refreshed after releasing. It now matches the host page. Both dashboards subscribed to SSE and never opened the connection — `onSseEvent` only registers a handler. Every subscription was inert, so the keepsake progress bar sat frozen after a release and PIN requests appeared only on a manual refresh. It happened to work when arriving straight from /feed, which connects, and /feed disconnects on destroy, so navigating to the dashboard killed it again. The unban confirm named neither of the two things a host most needs to know: unbanning also restores ALL of that guest's previously hidden photos to the gallery, diashow and export, and it retires and rebuilds a released keepsake, during which every guest's download is briefly unavailable. The ban modal warns that uploads vanish; nothing said they come back. Both now do, gated on the gallery actually being released. "Event verlassen" implied the account was being deleted, then the dialog said the guest could log back in. It calls `DELETE /session` — this device only, nothing deleted — so it is "Abmelden" now. Gallery release now states it locks uploads and is reversible; PIN reset states the guest is signed out on all devices. The keepsake download failed silently: nothing inspected the iframe result and the ticket POST always succeeded, so an over-limit tap did nothing at all. It now surfaces the (newly visible) 429 and confirms the download started. `/export` rendered "Export noch nicht verfügbar / Schau nach der Veranstaltung noch einmal vorbei" when the status request had merely FAILED — telling a guest to come back after an event that already happened. Both dashboards' error states gained a retry, which a host on a PWA with no URL bar otherwise has no way to reach. Modals were centred with no max-height, so on a short viewport the join PIN dialog clipped equally top and bottom — potentially putting "Weiter zur Galerie" off-screen at the moment a first-time guest must proceed. The ten moderation buttons were ~28px tall side by side, on the screen where a mis-tap bans the wrong guest; they are 44px now. Six German quotation marks paired the opening „ with an ASCII straight quote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
89ca819529 |
fix(diashow): never project a video, however its poster turned out
`merge` decided by whether a usable still existed, so the same clip was included or dropped depending on whether ffmpeg happened to extract a poster — a video WITH a thumbnail was queued and shown as a frozen frame, one without was skipped. Keyed on the mime type instead: the projector shows stills only. The test factory casts through `as unknown as FeedUpload`, so adding a field the queue reads does not fail typechecking — it fails at runtime, which is how this surfaced as ten broken tests rather than a compile error. The factory now carries `mime_type` and the comment says why keeping it in step matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fffa2d556c |
fix(upload): stop the queue from wedging, and tell the guest when it fails
A STALLED UPLOAD BLOCKED EVERYTHING, FOREVER. The XHR set no timeout and had no stall detection, so a half-open connection from an AP roam left the item `uploading` indefinitely — which kept `processQueue`'s `processing` flag set, so the whole rest of the queue stopped draining. The UI offered no control at all for an `uploading` item. The guest saw "Wird hochgeladen 43%" all evening with four photos stuck behind it and no button to press; the only escape was force-quitting the PWA, which nobody guesses. Now: a watchdog aborts when no bytes move for 90s, disarmed on `loadend` so the server may take its time storing a file it already has; a size-scaled timeout as a generous backstop that will not kill slow-but-progressing LTE; and a cancel button. FAILURES WERE INVISIBLE. `handleSubmit` navigates to /feed immediately, and the queue component is mounted only on /upload — so a 5xx, a captive-portal error or an uploads-locked 403 wrote a German message into an item that nothing ever rendered. The guest believed the photo was uploading; it never appeared. Same for the documented rate-limit countdown banner, which lives in that same unreachable component and is now also rendered from the layout. RETRIES WERE UNCAPPED. `requeueRetriable` flipped every errored item back to pending on the `online` event AND on every `feed-delta` — i.e. every SSE reconnect — with no attempt counter and no backoff. On a flapping network a large failing video was re-uploaded from byte zero all evening, saturating the AP for everyone. Now a persisted attempt count, exponential backoff and a cap of five. INDEXEDDB COULD STRAND THE COMPOSER. `openDB` had no `blocked` handler, so a second tab holding an older version made it never settle, and it rejects outright on iOS private mode; `handleSubmit` had no try/catch and never reset `submitting`, so both buttons stayed disabled reading "Wird hochgeladen…" permanently, with no error and nothing queued. There is now a `blocked` handler plus a settle timeout, an in-memory fallback so uploading still works when persistence is unavailable, and a `finally`. A 401 during a background upload cleared the session without redirecting — and api.ts documents exactly why that strands a guest: the nav and FAB are gated on `isAuthenticated` so they vanish, route guards only run on mount, and a standalone PWA has no URL bar. Three early-return paths wrote status only to memory and never to IndexedDB, leaving blob-less error rows that could never be evicted and held the red FAB badge lit all night. A banned guest was offered the entire upload flow — FAB, camera, staging — and only the POST 403'd, while the new read-only banner told them uploading was disabled. The sheet now consults the ban, the layout subscribes to `user-hidden` so a live ban reaches the UI instead of arriving as a stream of 403 toasts, and the banner clears `env(safe-area-inset-bottom)` so the bottom nav stops covering it on notched iPhones. The /upload submit bar gets the same inset — it sat in the home-indicator zone, where the system swallows the first tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
51e55b1ace |
fix(feed): survive a bad network, and let the lightbox actually browse
Four things a guest on congested venue wifi would have hit, and one they would have
hit immediately.
A FAILED FEED LOAD CLAIMED THE GALLERY WAS EMPTY. `loadFeed` caught, toasted for five
seconds and left `uploads` empty, so the page fell through to "Noch keine Fotos. Tippe
auf den Kamera-Button unten!" — the most likely first impression at the party, and a
lie. There is now a distinct error state with "Erneut laden". Refreshes suppressed the
toast entirely, so pull-to-refresh and the "Neue Beiträge" pill failed in total
silence; they now report, and the pill survives its own failure instead of clearing
before the request.
THE FILTER-EMPTY STATE WAS DEAD CODE. With filtering server-side `displayUploads` is a
plain alias of `uploads`, so the grid's "Keine Treffer für die gewählten Filter." plus
its reset button sat behind an identical earlier branch and could never render — a
guest tapping a chip with no matches was told to go take a photo.
SSE COULD FREEZE THE FEED FOR THE WHOLE EVENING. Nothing in the feed ever refetched on
a timer; every update path was triggered exclusively by a stream event. Behind a proxy
that buffers `text/event-stream` `onopen` never fires, so the guest saw only the photos
that were on screen when they arrived; and a socket left half-open by an AP roam is
worse, because `connectSse` early-returns on a non-null EventSource and nothing ever
reconnects. A pure silence timer is not implementable — the backend sends keep-alives
as SSE comments, which the EventSource parser discards without dispatching — so
liveness is established on evidence instead: a jittered 60-120s `/feed/delta` backstop
that reconnects when a poll returns content the stream never delivered. The ticket
round-trip also seeds the delta cursor before the EventSource is created, so the
backstop has a `since` even if `onopen` never fires.
THE PILL COLLAPSED A DEEPLY-SCROLLED FEED to 20 items and dumped the guest at an
arbitrary scroll position — the exact yank the pill exists to avoid. It merges now.
The refresh debounce was 800ms + jitter, which during a burst is roughly one feed query
per client every two seconds; at 100 guests that approaches the 60/min per-user limit,
and the resulting 429s were swallowed by a bare `catch {}`, so the feed would simply
stop updating with no signal. Now 8s + jitter, coalescing, and skipped entirely while
the page is hidden.
Not one `<img>` in the app had an `onerror`. `pickMediaUrl` falls back to the original
whenever preview and thumbnail are null — i.e. for everything still compressing, which
during a burst is the top of the feed — so a 404 there rendered an empty grey box with
`alt=""`, not even a message. Each now retries once, then shows the placeholder.
The lightbox had no swipe, no prev/next and no arrow keys, so browsing 300 photos meant
closing and reopening the modal for every one — while FEATURES.md and USER_JOURNEYS
both claimed swipe shipped. It now has chevrons (44px, German aria-labels, hidden at
the ends), arrow keys, and horizontal swipe, with focus handed to the surviving control
so a disappearing chevron can't drop focus to `<body>`. Comment deletion was a ~14px
`✕` four pixels from the text that deleted permanently on one tap, while deleting a
POST two components away goes through a ConfirmSheet; it now matches.
`feed-filter.ts` and its test are deleted — with the server filtering, they were dead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
87d01a8a26 |
fix(export): charge the download limit where the client can see the answer
The keepsake download is an iframe navigation, so its response is invisible to the page. The rate limit was enforced inside the zip/html handler — i.e. inside that navigation — while the ticket POST in front of it always returned 200. A guest over the limit therefore tapped "Herunterladen" and absolutely nothing happened, forever, with no explanation, on the one screen that is the emotional payoff of the whole app. With the default of 3/day, ZIP + HTML costs 2 and one retry locks them out until tomorrow. Minting is a normal `fetch`, so the limit moves there and the 429 reaches the user. The limit is not weakened: tickets are single-use with a 30s TTL and can only be obtained from that authenticated endpoint, so one mint is at most one download — and charging it in both places would have cost every download two slots. The message named the wrong timescale too. It shared the generic "warte kurz" wording with the per-minute limiters, but this bucket is a DAY, so a guest was told to wait a moment for something that could not work again until tomorrow. Verified live: three mints succeed, the fourth returns 429 in German; raising `export_rate_per_day` through the admin API takes effect on the next request with no restart, and the HTML keepsake then downloads. Also here, from the same pass: - `looks_bcrypt` checks the SHAPE of ADMIN_PASSWORD_HASH, not just placeholder-ness. A hash corrupted by shell or Compose escaping is not a placeholder, so the app booted green, `/health` said ok, and every admin login 401'd — unrecoverable mid-event, because the Admin row is only created BY a successful admin login and promoting a host requires one. - The rate limiter indexed `timestamps[0]` while holding its mutex, so a `max == 0` configuration panicked and poisoned the lock process-wide. Uses `first()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
496dba5a1f |
fix(feed): filter on the server, exactly, and keep banned uploads out of the chips
Filtering was split across two independent client-side states and applied to whatever page 1 happened to hold, by caption SUBSTRING. So a tag chip selected in the list view was silently still applied in the grid without being shown; a filter matched photos whose caption merely contained the text; and anything past the first page was invisible to it. Verified against the seeded data: `hashtag=tanz` returned 6 photos by substring, 1 by tag. `FeedQuery` now carries `hashtag` (single, list view), `hashtags` (CSV, OR'd, grid chips) and `uploader` (exact, AND'd), normalised through one function that trims, strips `#`, lowercases and dedupes, and yields None when empty — so an empty filter means "no filter", never "match nothing". The two SQL branches collapse into one with `h.tag = ANY($4)`. Tag-OR plus tag+user-AND is a specified feature, not an accident: `e2e/specs/03-feed/filter-search.spec.ts` and USER_JOURNEYS §8 pin it, which is why the semantics moved to the server rather than being simplified away. Tags travel as CSV safely because the backend restricts them to ASCII alphanumerics and `_`; `uploader` stays a single exact parameter because a display name can contain a comma. New `GET /api/v1/uploaders` reads `v_feed`, so banned and hidden uploaders are excluded for free. Migration 021 gives `v_hashtag_counts` the same treatment. It counted every upload regardless of the uploader's ban state, so banning a guest left their tags in the chip list as ghost filters that lead to an empty feed. Verified: after banning the guest who owned all six `tanz*` photos, the chips went 6 -> 0. `?limit=-5` returned a 500 — only the upper bound was clamped, so Postgres was asked for `LIMIT -4`. Clamped at both ends. `is_banned` is added to `/me/context` so the client can show a read-only notice instead of letting a banned guest discover the ban one 403 toast at a time. `add_comment` sorts and dedupes hashtags on the normalised key, matching the upload path — the two disagreed, which is a lock-ordering deadlock between concurrent upserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d0df3ebf6 |
feat(upload): make uploads idempotent so a lost response cannot duplicate a photo
The ordinary mobile failure, not an exotic one: the server receives the body, validates it, commits the row — and the response is lost on the way back because the guest walked out of range or the AP dropped the connection. The client sees a network error with the blob still in hand and re-sends it, both when the guest taps "Erneut" and automatically when the queue requeues on reconnect. Every attempt minted a fresh `Uuid::new_v4()` server-side, so the same photo landed in the gallery two or three times and was charged against the guest's storage quota each time. The client already has a stable per-queue-item UUID, so it costs nothing to send. Migration 022 adds `client_upload_id` with a partial unique index — partial so the NULLs of every pre-022 upload, and of any caller that doesn't send one, keep working untouched. Two paths, because there are two races: - Sequential retry: a lookup before the transaction finds the stored row, deletes the re-sent bytes and replays the original response as 200. The body has necessarily already been streamed, since the key arrives as a multipart field — re-sending is the client's cost and is already paid by the time we see it. What must be prevented is a second ROW. - Concurrent retry: two attempts in flight at once. `ON CONFLICT DO NOTHING` returns no row to the loser, which abandons its transaction (quota increment included) and replays the winner. Letting the unique index raise instead would only surface after the transaction had aborted, as an opaque error the caller would have to string-match. The replay reads live state rather than assuming a fresh row: a reconnect can be minutes later, by which time the derivatives may exist and the photo may have been liked. Every read there fails soft — the upload is already safely stored, so a sparser response is fine and failing the request is not. Verified live: the same photo sent three times returns 201, 200, 200 with one id, one row, and the quota charged exactly once. Also in this file: the two image-header probes at admission now run on `spawn_blocking`. Both open the file and run the codec's header parse synchronously, and `#[tokio::main]` gives two worker threads on a 2-vCPU box — so every upload stalled half the runtime's request-serving capacity. Everything else that blocks here (image encode, bcrypt) was already offloaded; this was the one that wasn't. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
faea555967 |
fix(ops): make /health a readiness probe, and bound how long the pool waits
`/health` returned the literal string "ok" and touched nothing. Every request in this app needs the database, so that answered a question nobody asked: the container reported healthy while every request 500'd, and with no operator watching during the event there was no signal at all. It now runs `SELECT 1` with a 2s timeout. Verified live against the production stack: 200, stop Postgres, 503 "database timeout", start Postgres, 200 again — with no app restart, because sqlx revalidates on acquire. Deliberately NOT wired to automatic recovery. Compose's `restart: unless-stopped` does not react to healthcheck state anyway, and an autoheal sidecar would be actively wrong here: it would truncate every in-flight upload to "fix" an outage that, as the test above shows, clears on its own. This is a diagnostic — including for the runbook's event-day `curl`. The pool had only `max_connections` set. Three additions: - `acquire_timeout(5s)`. sqlx defaults to 30, so a DB blip parked every request AND all ~100 SSE session revalidations for half a minute before erroring — the app looked hung rather than degraded, and the backlog outlived the blip. - `min_connections(2)`, so the first request after the setup-to-guests-arriving gap doesn't pay TCP + auth. - `statement_timeout=15s` / `lock_timeout=5s` per connection. Without them a single pathological query holds a pool slot indefinitely and no client-side timeout can take it back, because the slot is only released when Postgres finishes. Those two SETs are sent as two statements. `sqlx::query` uses the extended query protocol, which permits exactly one per call — as `SET a; SET b` every new connection failed, which surfaced as the pool never opening one and `create_pool` reporting a connect timeout. Caught by booting against an empty database rather than a warm one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
157499d493 |
fix(ops): reclaim abandoned upload temp files, and supervise the task that does
`stream_field_to_file` removes its `.tmp` on every error return, which covers everything the handler can see. It cannot cover what actually happens at a party: the client goes away — a phone sleeps, a guest walks out of range, the PWA is evicted mid-video — and axum DROPS the handler future rather than returning an error, so no cleanup runs at all. The shutdown backstop force-exits in-flight handlers for the same net effect. Nothing else reclaimed them. `cleanup_deleted_media` only visits rows with `deleted_at`, and an abandoned upload never got a row; `export::sweep_orphan_temps` is only ever pointed at the exports volume. `grep -rn read_dir src/` had three hits, all in export.rs — the media tree was never read by anything. So every abandonment stranded up to `max_video_size_mb` of unowned bytes permanently, on the same 40 GB filesystem as `postgres_data`. Worse than a leak: the per-user quota is computed from live free disk, so those bytes were also subtracted from what everyone else was allowed to upload. An evening of flaky venue wifi could take the event down. The threshold is on modification time, not creation time, which is what makes an hour safe: a live upload is written to continuously so its mtime keeps advancing and it can never age into the sweep no matter how slow the connection. The clock only starts once the writer stops. The periodic task is now supervised. It carries every piece of recurring hygiene in the app — session pruning, media reclamation, this sweep, and the rate-limiter and SSE-ticket maps — as a bare `tokio::spawn` with no retained handle, so a single panic anywhere inside it stopped all five permanently and silently. No log line, no symptom until the disk or a HashMap grew into one. Tested: an hour-old temp is reclaimed, a temp still being written to is not (deleting that one destroys a live upload), a committed `.jpg` is never touched, and a media tree that does not exist yet is a silent no-op rather than an error logged 24 times a day. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f952494c2 |
fix(media): stop a poster-frame failure from deleting the guest's video
Reproduced live, by accident, while smoke-testing on a machine with no ffmpeg: the clip uploaded fine, returned 201, and roughly six seconds later had `deleted_at` set and was gone from the feed. The `Ok(None)` "this clip yields no frame" case was already handled — that fix landed when sub-second clips were being destroyed. But the `?` on the call itself still routed every OTHER failure into the same give-up path, which soft-deletes: ffmpeg missing from the image, ffmpeg hanging on a truncated `.mov` and tripping the timeout, an ENOSPC on `thumbnails/`, or a DB blip in `set_thumbnail_path`. None of those says anything about the video, and `get_original` serves the file byte-for-byte, so a post that merely lacks a poster is fully watchable. No failure in the video branch may fail the upload. iPhone `.mov` is exactly the input most likely to trip it, and a wedding clip is not retakeable. ENOSPC gets its own classifier. It was the one failure the retry loop actively made worse: a disk does not drain during six seconds of backoff, so all three attempts failed identically while holding a compression permit that photos were queued behind — and the give-up path then refunded the quota and soft-deleted the row while deliberately KEEPING the original. That freed nothing, removed the photo seconds after a 201, and handed the guest the allowance to upload it again into the same full disk. Now: no retry, no refund, no delete. The row stays live and the photo is served from its original, and `backfill_stale_derivatives` regenerates the derivatives on the next start once there is room. `is_storage_full_error` has to look inside `ImageError::IoError` as well as at bare io errors, because `image` wraps rather than sources it and a plain chain walk would miss every derivative-write failure. FFMPEG_TIMEOUT drops 120s -> 45s. It was never a budget for honest work — a poster from a phone clip takes well under a second, and `-ss` before `-i` means even a 500 MB file seeks rather than scans. It is the ceiling on how long a pathological input holds a permit that guests' photos are waiting behind, so it should be as tight as it can be without cutting off real work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
43d37269b6 |
deploy: pull prebuilt images instead of building on the event server
The production compose still carried `build:` keys and no `image:` keys, so a
`git clone` onto the CX22 followed by `docker compose up -d` would have started a
fat-LTO release build of 427 crates on a 2-vCPU/4 GB box — the outcome the whole
build-on-the-Mac decision exists to avoid, reached silently because `pull` skips a
service it is told to build rather than failing.
Both services now pull `registry.mc02.dev/eventsnap/*:${EVENTSNAP_VERSION}` with the
`:?` form, so a missing tag fails the command instead of resolving to an empty one.
`docker-compose.build.yml` restores the `build:` keys for the workstation that
produces the images, from the same context paths.
Also here:
- `DOMAIN` gets the same `:?` guard. Blank did not fail — it produced `https://` for
the frontend's ORIGIN and collapsed the Caddyfile's site block into a malformed
global block, so the stack came up with no TLS and no site.
- `stop_grace_period: 20s` on the app. Docker's default stop timeout is 10s, exactly
the app's own drain budget, so a redeploy could SIGKILL the process at the moment it
was finishing — truncating the in-flight upload the graceful shutdown protects.
- `COMMENTS_ENABLED` is pinned "false" alongside MEDIA_PATH. It is a product decision
for this event, and `.env.example` ships the generic `true`; pinning it means an
operator who copies the example and edits only the secrets cannot ship comments on.
- The frontend runtime stage now copies the lockfile and uses `npm ci`. Without it the
three `^`-ranged deps re-resolved at build time, so an image rebuilt days later could
differ from the one that was tested. Image also drops 120 MB -> 65 MB.
- `docker-compose.dev.yml` told the operator that production had the same `$`-eating
bug and to escape the hash as `$$` in `.env`. That is wrong and it breaks a working
deployment: Compose uses single-quoted env_file values literally, and doubling
produces a 74-character string `looks_bcrypt` rejects. Verified with `printenv`.
The runbook's rollback pointed at `v0.12.0`, which has 6 migrations against HEAD's 22
and was never built or pushed — running the emergency card's rollback line would have
crash-looped the app with `VersionMissing` during the event. §9 now has you tag one
build twice so the rollback target is bit-identical, and says plainly what that can and
cannot fix. Every `$DOMAIN` command gained the `set -a; . ./.env` it needs, the
down-migration psql commands are wrapped in `sh -c` so the container expands the
credentials rather than sending `-U ""`, and the advice to lower `max_video_size_mb`
is withdrawn: the client guard it was premised on does exist, but is pinned to a
compile-time constant, so lowering the DB value only moves failures later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9b90929269 |
test(e2e): re-point the PIN lockout specs at the property that matters
Three specs asserted the OLD policy — that three wrong PINs lock an account — which is exactly the behaviour the previous commit removed, because that threshold sat below the per-(IP, name) throttle ceiling and so let any single IP lock any guest whose display name is readable off the feed. Rewritten to assert the distinction the fix introduces, which a status code alone cannot show: both tiers answer 429, but only the account lock costs the VICTIM. The new specs read the row via db.isPinLocked rather than the response, so: - one IP hammering /recover is throttled and the account stays UNLOCKED; - a distributed guesser (counter preloaded via db.setFailedPinAttempts, since no single source can reach the threshold any more) still trips the lock, and it holds even against the correct PIN; - concurrent wrong PINs are all counted — the atomicity property the old parallel test was really about, now asserted on the counter instead of inferred from a 429 that the throttle could equally have produced. The UI spec asserts the user-visible half: after four wrong PINs Dave can still get into his own account. It also now types the PIN digit by digit rather than filling and clicking, because the 4th digit auto-submits (pin-auto-submit.spec.ts) and doing both raced the button's disabled state. The adversarial spec enables rate_limits_enabled for its own run — it is off by default in this environment, so without that the throttle tier would silently not be exercised — and restores it in afterEach so it cannot leak into other specs sharing the stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
117e67fa80 |
fix(auth,upload): close the admin lockout and four unbounded-input paths
ADMIN LOCKOUT. admin_login looked its user up BY NAME. Migration 007 makes
display_name unique per event case-insensitively and join had no reserved-name
guard, so any guest joining as "admin"/"Admin"/"ADMIN" before the operator's first
login made find(role == Admin) miss, the fallback create("Admin") violate that
index, and `?` return a 500 — permanently, with no in-app recovery. Moderation,
config and gallery release all gone; the fix was hand-editing the database.
The root cause is the lookup key, not the creation. The name was never the
identity. User::find_admin_for_event resolves by role, which makes the whole class
of name collisions irrelevant — including the homoglyph bypasses of the new
reserved-name list, which is now defence in depth rather than the control.
Promoting the squatting row would be the obvious fix and is a serious mistake: it
carries a recovery_pin_hash the guest knows, so it would hand them the admin
dashboard via /recover, permanently, through a path needing no password. A
separate row under a fallback name is worse UX and much better security. Verified
against the real schema — the guest keeps their uploads, PIN and session under a
freed name, and the role lookup then finds exactly one admin.
Second, independent bug in that block: create() followed by a SEPARATE UPDATE ...
SET role = 'admin' manufactures the same poisoned state if anything fails between
them. Collapsed into create_with_role.
UNBOUNDED INPUTS — one root cause, four places: validation ran after the
allocation.
- upload caption/hashtags used Field::text(), which buffers the whole field, on
the one route whose DefaultBodyLimit is 576 MiB — so 576 MiB of heap per
concurrent request in a 1 GiB container, with the length check running
afterwards on a string already built. Now refused mid-read.
- the hashtag CSV was never length-checked at all and was upserted tag by tag
INSIDE the commit transaction, which holds FOR SHARE on the event row — one
request could stall every other upload behind tens of thousands of round trips.
Capped at 30 tags of <=50 chars.
- /recover and /recover/request built rate-limiter keys by format!() from an
unvalidated, unbounded display name, retained up to 24h in a map pruned hourly:
the limiter itself became the memory-exhaustion primitive it exists to prevent.
join validated first; that check is now shared by all three. /recover/request
also had no per-IP ceiling at all — /join got one in 017, /recover in 019, and
019's own comment describes exactly this attack. It returns 204 rather than 400
on a bad name, because a 400 would be a new signal on an endpoint whose contract
is that it cannot enumerate guests.
- the SSE ticket store had no size cap, no per-session cap and no rate limit on
its endpoint, while prune ran hourly against a 30s TTL. Now pruned on issue,
capped, and rate-limited. At capacity it REFUSES rather than evicting a
stranger's ticket — evicting would let one client deny SSE to the venue. Not
one-ticket-per-session either: two tabs open their EventSources concurrently.
PATCH /upload/{id} had no rate limit, no validation, and called
invalidate_and_arm unconditionally — outside both `if let Some` guards. So
PATCH {} bumped export_epoch and armed a fresh pair of full-gallery export workers
every call; REGEN_DEBOUNCE bounds the rate of that, not the total work, so a guest
could keep the keepsake permanently un-downloadable. All three fixed. The
validation also resolves a divergence: upload normalised tags while edit stored
them raw, so #Party via edit and party via upload became two hashtag rows.
PIN LOCKOUT was an ordering bug before a policy one: the account-lock threshold
(3) sat BELOW the per-(IP, name) ceiling (5), so three requests from one IP locked
any guest whose name is on the feed, every 15 minutes, forever. The tier meant to
protect a guest was the cheapest way to attack them. Ceiling drops to 4, threshold
rises to 12, so locking a victim now needs at least three distinct sources.
Brute-force cost is unchanged — 48 attempts/hour means 10k PINs still take ~208h
regardless of IP count — and increment_failed_pin now decays the streak after 15
minutes, since the counter previously only cleared on success and honest typos
accumulated across days. Both invariants are pinned by tests rather than comments.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f275de5c8f |
perf(feed): stop every feed page from aggregating the whole event
v_feed computed like_count/comment_count with LEFT JOINs and a GROUP BY. Postgres CAN push `event_id = $1` and the keyset predicate through the view — verified with EXPLAIN, it uses idx_upload_event_created_id — but it CANNOT push ORDER BY ... LIMIT across a GroupAggregate. So every request aggregated every upload in the event, times its likes and comments, and only then sorted and took 21 rows. Cost grew with the event, not with the page. Measured on a throwaway database seeded to a real reception (1000 uploads, 100 guests, 27k likes, 10k comments), same query, same data: before GroupAggregate (actual rows=1001) -> Sort -> Limit 449 ms after Index Scan (actual rows=21) -> Limit -> SubPlans 0.58 ms migration 022 replaces the joins with correlated scalar subqueries, which puts the counts ABOVE the Limit so they run 21 times instead of 1001. Exactly equivalent, not merely close: "like" is keyed (upload_id, user_id) so COUNT(DISTINCT user_id) == count(*), comment.id is the PK so COUNT(DISTINCT c.id) == count(*), and the GROUP BY was on u.id so it was already one row per upload. Column names, order and types are unchanged, so no Rust changes. No new index needed — idx_like_upload and idx_comment_upload already match the subqueries. Note the existing load harness cannot see any of this: e2e/loadtest/driver.mjs creates no likes and no comments, so the expensive path had never been exercised. The amplifier, feed/+page.svelte: every open feed subscribed to `upload-processed` and refetched page 1 — the most expensive page — so ~100 open feeds each fired one per completed upload. Now gated on whether this client actually shows the card that changed, and the debounce is jittered, because a fixed delay just moves a simultaneous herd 800 ms later. Nothing is lost by skipping: a client without the card also missed its `new-upload`, and the reconnect `feed-delta` already schedules a refresh. Load shedding, because the above reduces the risk rather than removing it: db.rs set no acquire_timeout, so sqlx's 30 s default applied — longer than the frontend's own 20 s fetch timeout, meaning the browser gave up while the server kept holding the slot and the work was done for nobody. Now 5 s, and PoolTimedOut maps to 503 + Retry-After instead of a generic 500. That mattered because the upload queue classifies 5xx as transient and retries: a 500 sent the retries straight back into the saturated pool with nothing to pace them. PoolClosed stays Internal — it only occurs during shutdown, where a 503 would invite a retry against a server that is going away. The Retry-After extraction in into_response matches on variants, so unlike message() a missing arm is not a compile error — it would silently drop the header. Pinned by a test covering both retry-carrying variants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f0f9c098b |
fix(upload-queue): rehydrate the persisted queue app-wide, not only on /upload
loadQueue() had exactly one call site in the entire frontend: the /upload route's onMount. So after a reload, an iOS tab discard or a PWA relaunch, staged photos sat in IndexedDB while the badge read 0 and nothing sent them — unless the guest happened to navigate back to /upload, which they have no reason to do, having already been shown a success. The photo never leaves the phone and the guest is never told. The root cause was narrower than "loadQueue isn't called enough". requeueRetriable() read IndexedDB but only .map()'d over whatever the in-memory store already held, so it could reset statuses and never ADD an entry — and processQueue reads only that store. That is why the `online` listener and the SSE resume hooks, which both call it, could not recover a cold start either. It now REBUILDS the store from IndexedDB, which makes all three resume paths work. Rebuilding needs one guard: entryToQueueItem downgrades `uploading` to `pending` with progress 0, and this runs on every `online` event and every SSE reconnect, so a blind rebuild would visibly reset the progress bar of a request still on the wire. In-flight items are carried over by id. Hydration is module-level, SSR-guarded and idempotent, re-armed via onSetAuth/onClearAuth because login is a client-side goto() — no module re-import, no onMount re-run — so a hydration that no-oped for lack of a token gets a second chance. Module level rather than a layout onMount because +layout.svelte already imports this module on every entry point, it matches the file's own bindOnline()/bindSse() pattern, and a store owning its own persistence keeps the layout free of a concern it cannot test. auth.ts does not import this module, so no cycle. The burst-queue e2e test no longer navigates to /upload after its reload — it now asserts the resume happens wherever the reload lands, which is the actual regression guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e52b2f1cd1 |
fix(upload): reclaim the bytes of uploads that never finish
Reclaim was a dozen explicit remove_file calls on the handler's return paths. That covers every way the handler can FINISH and none of the ways it can simply STOP. When a client disconnects mid-body — a phone leaving wifi, iOS killing a backgrounded PWA, the user hitting back — axum drops the handler future at an .await inside field.chunk() and no return path runs at all. The partial file then survives forever: it has no upload row, so cleanup_deleted_media (row-driven) can never see it, and no sweeper covered the originals directory. The triggers are routine rather than adversarial, and the client keeps the blob and auto- retries on every `online` event, so one large video over bad wifi leaves several copies. Those bytes are also invisible to the quota while still consuming the free disk that compute_storage_quota divides among guests — so orphans silently shrink every guest's ceiling while the admin widget under-reports. All three volumes share one filesystem; the end state is Postgres unable to write WAL. TempFileGuard is an RAII guard, because dropping the future is exactly what runs Drop — it is the only construct that survives cancellation. Armed before the file can exist, disarmed only after tx.commit() succeeds. The twelve explicit cleanups are deleted so one owner holds the rule. The subtler half is the rename. It happens BEFORE the commit, so between them the file exists under its final name with no row pointing at it — an orphan that looks legitimate. The guard is RETARGETED there rather than disarmed, and the retarget sits on the same poll as the rename with no .await between, which is what makes that window uncancellable. sweep_orphan_originals is the backstop for the process that was killed, where no Drop can run at all. Hourly, alongside the existing sweeps: .tmp files past the window go unconditionally (a .tmp never has a row by construction), other files are batched 500 at a time through a single NOT EXISTS query. Two things that look like oversights and are not, both commented in place: - the 6h window is what makes the sweep safe against the rename-before-commit ordering, since a committing upload is briefly indistinguishable from an orphan. It must not be shortened to speed up a test. - the NOT EXISTS deliberately does NOT filter deleted_at IS NULL. A soft-deleted row still points at its file during its retention window, and reclaiming that is cleanup_deleted_media's job; filtering here would race the two sweeps and destroy the files the recovery window exists to preserve. Verified against the real schema: given a live original, a soft-deleted one and a true orphan, the query returns only the orphan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5969ec74ea |
fix(compression): bound derivative retries so one bad upload can't loop forever
The OOM in the previous commit was survivable; what made it an outage was that it repeated. The upload row is committed before compression starts, derivatives_rev defaults to 0, and set_derivatives_rev only runs on success — so a row whose processing killed the container survived at rev 0, and backfill_stale_derivatives (called unconditionally at every boot) re-selected it and re-ran the identical workload. With restart: unless-stopped that is an infinite kill loop, and every cycle also drops every SSE stream and truncates every in-flight upload. Verified end to end against the real schema in a scratch database: with the new guard the backfill selects the row on boots 1-3 and zero rows from boot 4 on, and a later success resets the counter. migration 021 adds derivative_attempts and derivative_last_error. The counter is incremented WRITE-AHEAD, before the work is attempted. This is the whole design: the failure being bounded is a cgroup SIGKILL, so no Err is returned, no error handler runs and no Drop fires. A counter bumped in a failure path increments zero times per crash and the loop would be unchanged. set_derivatives_rev clears it, so success is the only reset and both the live path and the backfill get it without a new call site to forget. Also in the backfill: - one task walking the rows sequentially instead of one task per row. A large backlog used to spawn thousands of tasks, each holding a pool handle and queueing on the same two permits, competing with live uploads for a whole boot. - LIMIT 200 per boot, and original_path <> '' replacing an IS NOT NULL that was dead (the column is NOT NULL; cleanup_deleted_media blanks it instead). - a once-per-boot error log naming how many uploads have given up. Without it the give-up is invisible — the loop stops, which is the point, but the photos keep a stale derivative forever with nothing to notice. Adds backfill_video_posters for the mirror-image gap: a video interrupted by a restart has its compression_status flipped processing -> failed by startup_recovery and is never re-enqueued, so thumbnail_path stays NULL for the rest of the event while the clip itself plays fine. It shares the same attempt budget, which means a genuinely posterless sub-second clip (Live Photo, mis-tap) stops being re-ffmpeg'd after three boots. That is intended, not a bug to fix later — Ok(false) is a normal permanent outcome there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |