06e0bea0e984f70b61c88b628979aa1a0e7363e3
231 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
06e0bea0e9 |
fix(export): a refused download mint no longer strands the running transfer
`/export/ticket` mints before charging the daily limit, deliberately: charging first meant a store-capacity 503 — a server-side condition the guest cannot see or cause — still cost one of their three daily downloads, with no refund path. But a mint refused with 429 left its ticket in the store. Download tickets live six hours (they must, so a 1.4 GB transfer can resume with `Range`), and the per-session cap is four tickets OF THE SAME KIND. So: the transfer starts on ticket A -> the bar looks stuck on venue wifi -> the guest taps "Herunterladen" again -> mints 2 and 3 succeed, 4 and 5 return 429 but still mint -> the fifth evicts the oldest download ticket for the session, which is A -> the transfer drops, resumes, and 401s -> re-minting is impossible, they are at the daily limit The keepsake is unreachable until the next day, for tapping a button that appeared to do nothing. This is the failure `sse_churn_cannot_evict_a_running_ download` was written to prevent, reintroduced through the one channel that test does not cover: download tickets evicting each other. Discarding the ticket on the refusal path keeps both properties that put the mint first — a capacity 503 still costs no download, and a refused download now costs no slot. Also corrects three doc comments in this path still describing download tickets as "single-use, 30s TTL", false since they were made resumable. Stale comments here have already sent one review down the wrong path. The new spec asserts the two 429s explicitly, so it cannot pass vacuously if the daily limit stops being enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e6aeaa0a8b |
docs(compose): the app CPU cap cannot separate compression from the request path
The comment read "CPU ceiling for the two image workers + ffmpeg poster extraction", which describes a separation Docker cannot make: `compression.rs` runs that work in `tokio::task::spawn_blocking` — same process, same cgroup as every Axum handler — and `cpus`/`cpu_shares` are per-container. What actually happens is worth knowing when sizing this box: `cpu.max` is `120000 100000`, so two CPU-pegged blocking workers exhaust the 120 ms quota after ~60 ms of each 100 ms period and the kernel freezes the WHOLE cgroup — uploads, feed and SSE included — for the rest of it. Over a 100-photo burst that is ~210 s during which every request can eat up to 40 ms of throttle. The value stays at 1.2: an app that can take both cores starves Postgres, and every request path goes through Postgres. A slightly stalled request beats a starved database. The comment now says which knob actually shortens the backlog (COMPRESSION_WORKER_CONCURRENCY) rather than implying this one does. |
||
|
|
ac04e27e34 |
fix(upload): a retry after release returns the stored photo instead of refusing it
The idempotency key was only readable as a multipart FIELD, and a field cannot be read until the body is being parsed — which happens after the lock/release pre-flight. So the replay was unreachable in exactly the case it exists for: the photo commits → the response is lost on the way back (the flaky-wifi failure the key was added for) → the host releases the gallery at the end of the night → the phone's retry answers `gallery_released`. The guest is told a photo that is sitting in the gallery was never sent. And the remedy the client offers is destructive: `open_event` clears `export_released_at` AND bumps `export_epoch`, retiring the whole keepsake generation and forcing a multi-GB rebuild on a 2-vCPU box at midnight — to re-send a photo that was never missing. Several guests on one flaky evening make this likely to happen at least once. The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line, so the answer is knowable before anything is decided about locks. The multipart field stays for the concurrent case and as a fallback. Placed ahead of the hourly rate limiter too, which was the same mistake one layer up: a 40-photo burst with two retries apiece exhausted the guest's hour on uploads that had all committed the first time. The body is still drained rather than abandoned — replying before reading it makes the proxy see a broken pipe and turn a clean 200 into a 502. The spec carries its own control: a DIFFERENT photo is asserted to still be refused with `gallery_released` after the release, so the replay cannot be green merely because the gate was open. |
||
|
|
137b892480 |
docs(runbook): the .env template no longer implies edits that compose overrides
The template said `EXPORT_PATH=/exports # NOT pinned by compose`, which has been false for as long as the pin has existed — and it sits three lines under MEDIA_PATH, which is correctly described as pinned, so the contrast reads as deliberate. An operator moving exports to a separate volume (the remedy §13 now recommends for a full disk) would edit `.env`, see nothing change, and have no reason to suspect the compose file. All four pinned vars are now listed together with what the pin means: change it there, not here. DATABASE_MAX_CONNECTIONS carries the extra note that its value is boot-fatal when unparseable, which is why it is pinned at all. |
||
|
|
b5a1580368 |
test(e2e): restore the download-side 404 coverage the mint pre-check displaced
Both tests in export.spec.ts are still named "ZIP download 404s…" but now assert only that `/export/ticket` refuses. That move was right — the mint pre-validates, and refusing there spends none of the guest's three daily downloads — but it left `resolve_export_file` unasserted on the download path itself, so deleting that check would not have turned anything red. It cannot be covered by minting against an already-dead archive, because the mint refuses first. The order has to be: real release → mint while healthy → retire the generation → download. Which is also precisely what happens to a ticket already in flight when the host takes a photo down mid-download. Carries its own positive control: the ticket is asserted to serve a 200 before the epoch moves, so the 404 afterwards cannot be green for the wrong reason — an expired or never-valid ticket would 404 too. |
||
|
|
6475199670 |
fix(feed): stop raising the stale pill on a list view that is not filtered
The merge gate tested the raw chip state (`selectedHashtag || activeFilters
.length`), but `filterParams()` — which decides what the server actually
returns — ignores `activeFilters` entirely in list view.
`switchView('list')` deliberately keeps a chip on `activeFilters` (an uploader
chip, or a second tag) while setting `selectedHashtag` to the first TAG
filter, which is null when the only chip was an uploader. So a guest who
filtered the grid by an uploader and then switched to list view had a
genuinely unfiltered view whose every delta raised "Neue Beiträge" instead of
merging the rows in — the same pill-for-rows-that-should-have-merged this
block was written to stop, one branch further in.
Nothing was lost (the pill always clears) but it trains guests to ignore the
one control that means something. Now gated on the effective filter, which is
exact by construction: it asks the same function the fetch does.
|
||
|
|
55b57fc037 |
fix(me): two hosts deleting at once can no longer leave the event with no operator
The last-operator guard ran on the pool, before the transaction opened. Two hosts deleting themselves at the same moment each saw the other, both passed, and the event was left with nobody who can moderate, nobody who can release the gallery, and no way to appoint anyone — because appointing requires a host. Not recoverable from inside the app. The fix is a transaction-scoped ADVISORY lock, and the two obvious alternatives are both worse: * `FOR UPDATE` on the other operators' rows DEADLOCKS. Each deleter locks the other's row and then tries to delete its own, so Postgres resolves it by killing one. The invariant survives; the loser gets a 500 instead of the sentence explaining what to do. My first attempt did exactly this, and the test caught it. * Locking the `event` row serialises cleanly but inverts the lock order every moderation path takes (upload/user rows first, event last). That is an ABBA against a path that runs constantly during the event, traded for one that runs approximately never. An advisory lock is a separate lock space, so it cannot interact with the row-lock graph at all, and it is released when the transaction ends. The loser waits, counts zero once the winner's row is gone, and is refused with the sentence it should have got. The test is a genuine concurrency test — it spawns the second deleter and asserts it unblocks to see no remaining operator. It fails against the pre-check-outside-the-transaction version and against the FOR UPDATE version. |
||
|
|
19b59d6fee |
docs(upload): stop claiming a proxy bandwidth control that does not exist
`get_original`'s comment said bandwidth abuse "belongs at the proxy, where per-connection limits still work", which reads as though the removed per-IP limiter had been replaced by something. It was not: the Caddyfile sets timeouts and no rate or concurrency directive, and the tower stack is TraceLayer alone. Removing the limiter was right — the venue is one NAT address, so that bucket throttled the whole party's feed — but the route is now unbounded, and the comment should say so rather than imply cover. Records the actual cost (no-store plus the derivative fallback plus the nonce'd retry, against a 15-slot pool that upload commits compete for) and the shape a real fix would take: a concurrency semaphore over media streaming, not a request-rate bucket. |
||
|
|
010bcc0e3c |
fix(build): stop a stale or missing keepsake viewer from shipping silently
Three ways the compiled-in viewer could be wrong, none of which anything would have reported. Found by mutation-testing the guard added below — it failed when it should have passed, and the reason was the second bullet. * `include_dir!` registers NO rebuild dependency. Run `npm run build` in frontend/export-viewer, then `cargo build`, and cargo sees no source change and reuses the cached binary — carrying the PREVIOUS index.html. The file on disk and the file in the binary disagree, git is clean, every check passes, and Memories.zip ships a stale viewer. Confirmed empirically: after replacing the artifact the compiled-in copy did not change until a source file was touched. A build.rs now declares `rerun-if-changed` for `static/export-viewer` AND `migrations` — sqlx::migrate!() embeds its directory the same way, and there the stale snapshot is worse still: the binary boots against a database that already ran a newer migration and crash-loops with VersionMissing. * `emptyOutDir: true` deleted the committed artifact BEFORE generating. That was safe while the build could not fail; it no longer is, because `inlineThemeFonts` now calls `this.error` on a keepsake that is not self-contained. A failed build left the directory empty — and include_dir! over an empty directory compiles fine, while `write_viewer_with_data` iterates zero files and returns Ok. The result is a valid archive with every photo and no viewer. The output is one overwritten file, so nothing accumulates without the wipe. * Nothing asserted the viewer was there at all. Now asserted at the point of use (bail rather than write a viewer-less keepsake) and in a test that checks presence, plausible size, and that no `url(/...)` survived inlining — the three ways it can be present but useless. The Dockerfile copies build.rs with the sources rather than with Cargo.toml, so the dependency-cache layer stays byte-identical and the dummy build does not run it. |
||
|
|
8af8c4fab7 |
docs(runbook): validate the Caddyfile before the freeze, and pair down-migrations with a rollback
Nothing anywhere executes the production `Caddyfile` before the real deploy — the e2e stack mounts `e2e/Caddyfile.test` — and a syntax error there is total: Caddy exits, `restart: unless-stopped` loops, 443 is dead for the whole event, and `docker compose up -d --force-recreate caddy` still exits 0 while it crash-loops. Step zero now validates it. I ran it against the current file (which I changed last commit, unexercised): "Valid configuration", and the new `read_body 30m` adapts to `read_timeout: 1800000000000`ns as intended. And a warning §9 needed: a down migration is not a standalone repair. Roll the IMAGE back first. `Upload::create` sends an `ON CONFLICT ... WHERE` predicate that must match the live partial index exactly and is not compile-checked, so running 026's or 031's down against the current binary turns every upload carrying a client_upload_id — i.e. every upload from the shipped client — into a runtime 500. 026's down can also fail outright on any database where a guest deleted and re-uploaded a photo; it rolls back cleanly, but you cannot go below it. Both verified against a live Postgres. |
||
|
|
301e6636a5 |
fix(audit): give the audit trail the names that make it readable
Migration 029 made `actor_id`/`target_id` non-FK on the stated grounds that
"the record must survive the actor's account being removed, which is exactly
when it is most likely to be wanted". All eleven call sites then passed None
for both name columns — so what survived a deletion was a bare uuid resolving
to nothing: the guarantee, minus the only thing that made it useful.
`record` now resolves whatever the caller omitted, in one query, so no call
site can forget. `me::delete_account` passes its names explicitly because it
has already hard-deleted the row by then — that is the one record a host is
most likely to be reading the next morning ("whose photos disappeared?").
Also: `actor_role` is written with `as_str()` rather than
`format!("{actor_role:?}")`. The Debug spelling is not a stable wire format,
so a derive change or a renamed variant would have silently started writing a
different string into a column nothing validates.
Migration 029's header lists three action slugs (`promote_user`,
`demote_user`, `delete_user`) that no call site has ever emitted, and it
cannot be corrected — editing an applied migration changes its checksum and
crash-loops every database that ran it. The real list, verified against the
call sites, is documented in this module instead, along with the fact that
there is no read endpoint and the query to use by hand.
A NULL name fails silently, so it is now asserted: names resolved from ids,
names surviving the row's deletion, and a row still written when neither can
be resolved (an audit write must never fail the action it records).
|
||
|
|
4916eed436 |
fix(deploy): ship the swap ceilings, pin the last boot-fatal env var, and correct docs that misdirect
* memswap_limit is now IN docker-compose.yml on all four services. Compose sets Memory but leaves MemorySwap unset, and Docker then permits swap equal to the memory limit — so following §5's "add 2 GB of swap" silently DOUBLED every ceiling, to ~5 GiB on a 3.82 GiB box. Nothing OOMs; instead Postgres's working set becomes swap-eligible on a shared-tenancy SSD, turning a bounded OOM-kill that restarts in seconds into unbounded latency with no signal but "everything is slow". The runbook told the operator to hand-add it, which also broke §0's own gate that docker-compose.yml must be unmodified. Verified rather than assumed: service-level memswap_limit does compose with deploy.resources.limits.memory (docker inspect → Memory=1073741824 MemorySwap=1207959552). * DATABASE_MAX_CONNECTIONS pinned in compose. It is the one env var that is now boot-FATAL when unparseable — the right call, but it means a stray quote or a trailing inline comment in .env crash-loops the app behind a live Caddy. MEDIA_PATH, EXPORT_PATH and APP_PORT are pinned for weaker reasons. * .env.example's quota narrative was sized for a CX33: "~30 GB of a fresh 70 GB" on a box with 40 GB. And on THIS box the fixed point never binds at all — ~210 MB/guest is below the 500 MiB floor, so everyone gets the floor and the per-user quota stops bounding aggregate growth. What actually stops uploads is the keepsake preflight at ~8 GB of media. That paragraph is what an operator reads when a guest is blocked, and it pointed at the wrong knob. * The emergency card gains the one disk symptom that can appear mid-event, where `df -h` — its only disk instruction — actively misleads: the gate fires ~10 GB + 2.2x media BEFORE the disk is full, so df shows ~20 GB free at the moment uploads are being refused. * Two code comments that now assert the opposite of the code: claim_job promised that "the update_progress liveness check bails such a worker out early" — it cannot, its predicate is on the job row, which a reopen does not touch, so a mid-export reopen grinds the whole gallery to completion on a 2-vCPU box during the live event. And prune_superseded_archives still argued "deleted bytes cannot be rolled back" as an invariant, after the reclaim path was changed to prune even when that will not close the shortfall. Both now describe what the code does. * Smaller corrections: runbook §3's "two 48 MP photos ≈ 800 MB" scenario is unreachable (compression.rs takes an exclusive heavy permit, so they serialise) and contradicted .env.example; "all four healthy" is wrong since caddy has no healthcheck; a README line reference pointed at a comment added by the same commit that broke it. |
||
|
|
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> |