Compare commits

...

36 Commits

Author SHA1 Message Date
fabi
9759c7c669 fix(ops): the hourly backup never ran, and an unset POSTGRES_USER crash-loops silently
**The backup that did not exist.**
`.env.example` ships `EVENT_NAME=Max & Maria's Wedding` and the runbook tells you
to `cp .env.example .env`. Compose's env_file parser reads that fine. POSIX `sh`
does not: `. ./.env` aborts with "Unterminated quoted string" (verified, rc=2),
and every variable defined after that line is left unset.

The §10.2 cron script is `#!/bin/sh` + `set -eu` + `. ./.env`, so it exited
before `pg_dump` — every hour, into a log nobody reads. The only automated backup
of the one thing the runbook calls irreconstructible produced nothing, and §10.2's
own "prove it works NOW" only catches it if `.env` is already final at that
moment.

The script reads NOTHING from `.env` — `POSTGRES_USER`/`POSTGRES_DB` are expanded
inside the db container by the single-quoted `sh -c`. The source line was pure
liability and is gone. `EVENT_NAME` is now double-quoted in `.env.example`, which
both parsers read identically (verified), and the three interactive sourcing
sites now read just `$DOMAIN` instead of sourcing the whole file. The verify step
also proves the dump is a non-empty valid gzip containing tables, rather than
that a file exists.

Also fixes the script's `cd /root/eventsnap`, which contradicts §5's non-root
deploy and §13's `~/eventsnap` — under a non-root deploy it failed the same way,
silently.

**The crash loop with no message.**
`docker-compose.yml` interpolated `POSTGRES_USER`/`POSTGRES_DB` with no default
and no `:?` guard, into `environment:`, which OVERRIDES `env_file`. Unset does
not fall back — it resolves to the empty string, initdb creates a role and
database named "", `DATABASE_URL` still says `eventsnap`, and the app hits
`FATAL: role "eventsnap" does not exist` forever. `pg_isready -U "" -d ""` never
passes, so `app` never turns healthy and Caddy — gated on `service_healthy` —
never starts: port 443 dead for the whole event, exit only via `down -v`.

Both now carry `:?` guards (verified they fire), and §3's ".env template — ALL of
them" list, which omitted both, now includes them.

Other runbook corrections: the backup/restore pointer named a line range that had
drifted into an unrelated section and stopped mid-restore, before the media
restore and the mandatory `chown` — now referenced by heading, which cannot go
stale. §7.3 told you to verify that `EXPORT_PATH` is not pinned when §3 correctly
says it is. Stale counts: rev-list 196 -> 217, "versions 007–022" -> 007–031,
`frontend/Dockerfile:9` -> :8, and the low-disk description now matches the code
(the 10 GB absolute floor was removed as unreachable).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:46:21 +02:00
fabi
cfc8bd0016 test: replace coverage that could not fail with coverage that can
`backend/tests/` follows a house rule of copying production SQL character-for-
character rather than calling `src/`, because the crate is a binary and nothing
in it is importable from an integration test. For pinning behaviour that already
existed that is a defensible trade. Applied to a NEW fix whose only coverage is
the copy, it proves nothing: the fix and its test become two independent
implementations, and deleting the fix leaves the test green.

`audit_names.rs` did exactly that. It never called `audit::record` — it
reimplemented `resolve_names` and the INSERT inside the test file, down to a
hardcoded `.bind("host")`, and then asserted `actor_role == "host"` against its
own literal. That assertion could not fail for any change to the code it named,
and grep confirmed there was no other coverage of the audit-name work anywhere.

Moved into `#[cfg(test)]` inside `services/audit.rs`, where the real function IS
callable. CI already runs `cargo test --all-features` with a live DATABASE_URL,
so `#[sqlx::test]` works there; verified all four run and pass. The role
assertion now compares against `UserRole::as_str()` itself rather than a literal,
so it tracks a rename instead of pretending to, plus an explicit `assert_ne!`
against the Debug spelling.

Also:

- `retry-after-release.spec.ts` filtered the feed on `u.id === original.id` to
  prove "no second row was created". A duplicate gets a fresh uuid and could
  never match, so the filter yielded exactly 1 whether the gallery held one copy
  or five. Counts by uploader now, with the original's identity asserted
  separately. (The rest of that spec is sound — its 403 control and replay-id
  check both fail if the header fast-path is reverted.)

- `upload_after_release_commits_sees_the_lock_and_is_rejected` claimed the
  handler answers `UploadsLocked`. It answers `GalleryReleased` since the check
  order was inverted on this branch, and the test asserts no variant at all.
  Documented what it actually covers (the locked READ) and where the ordering IS
  covered (two e2e specs).

- Two `// SRC:` pointers had drifted ~130 lines into unrelated code, which is how
  a hand-copied fixture silently stops matching its original. Now named, not
  numbered.

- `emptyOutDir: false` claimed a failed viewer build "leaves the last good
  artifact in place". True for the `generateBundle` error, false for the newer
  `writeBundle` assertion, which fires after Vite has already written the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:46:04 +02:00
fabi
ee70eec094 fix(frontend): three dead ends a guest cannot get out of
**1. The cached PIN could never be cleared after a host reset.**
`/recover` clears a rejected cached PIN only when the submitted name is the one
this device belongs to — narrowed on this branch so a guest who mistypes their
own name does not lose the only copy of their PIN (the server keeps just the
bcrypt). But it compared against `DISPLAY_NAME_KEY`, which `clearAuth` deletes
for shared-device privacy — one step BEFORE the guest ever reaches that screen:

  host taps "PIN zurücksetzen" -> the backend also revokes every session for that
  user -> the guest's next request 401s -> clearAuth -> redirect to /join -> they
  go to /recover, where the field is pre-filled with the dead PIN and the guard
  can never fire again

Since a 4-digit value auto-submits, every correction burns another of the four
wrong-PIN attempts the shared venue IP allows per 15 minutes. The PIN's owner is
now stored WITH the PIN and survives alongside it, with a fallback to the auth
display name for devices that cached a PIN before this key existed.

**2. Every layout-level SSE handler waited on the `/me/context` retry.**
The retry was awaited inside the same `onMount` that registers `pin-reset`,
`user-hidden`/`user-shown`, `event-closed`/`event-opened` and `event-updated`.
Worst case is a 20s timeout + 2s backoff + a second 20s timeout: ~42s with an
empty handler list, on exactly the wifi the retry exists for. Five of the six
self-heal; `pin-reset` does not, and a missed one leaves a dead PIN displayed in
"Mein Konto" and pre-filling /recover — the same state as (1), reached from the
other end. Detached, since nothing below reads its result.

**3. `crypto.randomUUID` was on the join critical path.**
It needs Safari >= 15.4 / Chrome >= 92 AND a secure context. The queue already
depended on it, so an old phone previously joined and browsed and only failed at
upload — degraded but survivable. Minting an idempotency key at join turned that
into a `TypeError` caught by the generic handler and rendered as "Ein Fehler ist
aufgetreten." on every retry: cannot join, cannot browse, and /recover is no help
because there is no account yet. The one screen where a hard failure has no way
out at all. Falls back to `crypto.getRandomValues` with the RFC 4122 version and
variant bits set; `Math.random` is deliberately NOT a further fallback, since a
collision between two guests would replay one guest's join or upload onto
another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:45:46 +02:00
fabi
9bae5d77ed fix(join): concurrent first joins no longer 500 on the QR-scan burst
`Event::find_or_create` was check-then-insert against a UNIQUE slug, and its only
callers are `/join` and `/admin/login` — both of which run before the row exists,
at the single most concurrent moment the app ever sees: the QR code goes up and
every phone in the room posts `/join` within the same second. All of them miss
the SELECT, all of them INSERT, one wins, and the rest get a bare unique
violation surfaced as a 500 on the very first screen of the event. There is no
retry on that path and nothing in the UI explains it.

In the documented timeline the host's T-5 admin login creates the row first, so
the blast radius is small — but it is one `down -v` or one `EVENT_SLUG` edit away
from being live on the night.

`ON CONFLICT (slug) DO UPDATE SET slug = EXCLUDED.slug` — a deliberate no-op
write, because `DO NOTHING` returns no row on conflict and would put the loser
back at square one. It touches only `slug`, so `name`, `export_epoch` and the
lock/release timestamps are never disturbed by a late arrival; a test pins that.
The read fast-path stays, so every join after the first is still a plain SELECT
and takes no row lock.

Tests live in `src/` rather than `tests/` because the crate is a binary and the
function is not importable from an integration test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:45:32 +02:00
fabi
20c15c3500 fix(backend): three ways the end of the night could go wrong
**1. Releasing the gallery could arm the keepsake with no worker.**
`release_gallery` ran `tx.commit()` -> SSE `event-closed` -> `audit::record().await`
-> `spawn_export_jobs`. The audit write is two pool round-trips, each able to wait
the full 5s acquire timeout, and it runs in the same instant `event-closed` fans
out to ~100 phones whose upload queues all hit the API at once. Axum drops the
handler future when the client disconnects — the host taps "Freigeben" and
pockets the phone. The release has COMMITTED: event closed, uploads locked, epoch
bumped, both `export_job` rows pending, and no worker. `/export/*` 404s, the page
sits on "Wird vorbereitet…", `recover_exports` only runs at boot, and a second
release is refused. Every other regen call site spawns first; `me.rs` says so in
a comment. This was the sole violator, and the only path that arms the FIRST
build of the keepsake. Spawn moved immediately after the commit.

**2. The event could be left with no operator.**
`remaining_operators` was an unlocked pool COUNT followed by a separate UPDATE,
so `ban_user` and `set_role` raced each other and `DELETE /me`: an admin demotes
host B while host A deletes themselves, each check sees the other still present,
both commit, and nobody can moderate, release the gallery, or appoint anyone —
appointing requires being an operator. The count now runs inside the writing
transaction behind the same advisory lock `delete_account` uses, via one shared
helper so the key cannot drift between copies.

The lock is taken FIRST in all three, and the order is load-bearing:
`delete_account` previously took it last, after row locks on `upload` and
`event`, while the two new call sites take it before locking those same rows —
an ABBA that Postgres would resolve by killing one transaction with a 500. The
ordering rule is documented on the helper.

**3. The keepsake could become unbuildable the moment uploads stopped.**
The upload gate and the export preflight computed the IDENTICAL threshold
(`required_free_bytes(media, 2) + DISK_RESERVE_BYTES`), leaving zero margin
between them. Once the gate refused its first upload the preflight was already at
its own limit, so anything written afterwards decided the keepsake's fate: WAL up
to `max_wal_size`, 30 MB x 4 of container logs, and the compression backlog
draining at exactly that hour. The release commits before the workers bail, so
the failure lands at 01:00 with no second release possible. The gate now demands
`UPLOAD_GATE_HEADROOM_BYTES` more than the preflight, costing ~0.5 GB of media
ceiling — the trade README already argues for. The dashboard banner mirrors the
new threshold so its lead is unchanged, and a new test pins gate-before-preflight
at six gallery sizes.

Also: the global disk gate fails OPEN when the mount cannot be read, which is
deliberate, but did it SILENTLY — no log line at all, while the export preflight
warns on the identical condition. Inside a container `/` is an overlay rather
than a `/dev` device, so this is reachable, and when it happens the only global
disk bound is gone and the box fills until Postgres cannot write WAL.

README's sizing table was also arithmetically self-contradictory (it showed
~27 GB free against a ~27.6 GB requirement); recomputed for the new gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:45:24 +02:00
fabi
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>
2026-08-13 19:45:08 +02:00
fabi
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.
2026-08-12 23:11:57 +02:00
fabi
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.
2026-08-12 23:11:57 +02:00
fabi
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.
2026-08-12 22:23:33 +02:00
fabi
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.
2026-08-12 22:23:33 +02:00
fabi
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.
2026-08-12 21:40:29 +02:00
fabi
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.
2026-08-12 21:40:29 +02:00
fabi
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.
2026-08-12 20:51:51 +02:00
fabi
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.
2026-08-12 20:51:51 +02:00
fabi
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.
2026-08-12 20:00:52 +02:00
fabi
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).
2026-08-12 20:00:52 +02:00
fabi
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.
2026-08-12 19:10:45 +02:00
fabi
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.
2026-08-12 19:10:45 +02:00
fabi
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.
2026-08-12 09:15:55 +02:00
fabi
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.
2026-08-12 09:15:40 +02:00
fabi
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.
2026-08-12 09:15:40 +02:00
fabi
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.
2026-08-12 09:15:24 +02:00
fabi
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.
2026-08-12 09:15:24 +02:00
fabi
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.
2026-08-12 09:15:07 +02:00
fabi
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.
2026-08-12 09:15:07 +02:00
fabi
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.
2026-08-12 09:14:51 +02:00
fabi
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.
2026-08-12 09:14:39 +02:00
fabi
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>
2026-08-11 22:44:48 +02:00
fabi
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>
2026-08-11 22:44:48 +02:00
fabi
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>
2026-08-11 22:44:26 +02:00
fabi
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>
2026-08-11 22:44:26 +02:00
fabi
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>
2026-08-11 22:44:10 +02:00
fabi
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>
2026-08-11 22:44:10 +02:00
fabi
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>
2026-08-11 22:43:50 +02:00
fabi
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>
2026-08-11 22:43:29 +02:00
fabi
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>
2026-08-11 22:43:11 +02:00
94 changed files with 6261 additions and 601 deletions

View File

@@ -12,6 +12,15 @@ DOMAIN=my-event.example.com
# prebuilt images from the registry and never compiles — see DEPLOYMENT_RUNBOOK.md.
# Always an immutable tag, never `latest`: rollback is `EVENTSNAP_VERSION=<previous>`
# + `docker compose up -d`, which works offline if that image is still resident locally.
#
# ⚠ THIS TAG DOES NOT EXIST YET. The newest git tag is v0.12.0; v0.13.0 is the release you
# cut for the event. Build and push it (plus its identical rollback twin v0.13.0-a) BEFORE
# the first `docker compose up -d` — see DEPLOYMENT_RUNBOOK.md §6 (build) and §9 (rollback).
# Copying this file and starting the stack without that step fails with `manifest unknown`.
#
# Do NOT "fix" this by dropping back to v0.12.0: no image was ever built for it, and a
# 6-migration tree booting against a 31-migration database returns VersionMissing and
# crash-loops forever behind a live Caddy. §9 covers this in full.
EVENTSNAP_VERSION=v0.13.0
# ── App server ────────────────────────────────────────────────────────────────
@@ -36,8 +45,12 @@ DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/events
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
POSTGRES_DB=eventsnap
# Connection pool size. The code default is 10 (backend/src/db.rs) — set it explicitly,
# because a `.env` written by hand from this file's secrets is otherwise silently on 10.
# Connection pool size. The code default is 15 (DEFAULT_MAX_CONNECTIONS in backend/src/db.rs),
# and docker-compose.yml pins this value in `app.environment` so an edit here cannot reach the
# container. That pin is deliberate: since the value became boot-FATAL when unparseable — so an
# operator tuning a knob that never took effect gets told, instead of silently staying on the
# default — a stray quote or a trailing inline comment in `.env` would crash-loop the app behind
# a live Caddy. Change the pin in compose, not this line.
#
# SIZE IT TO THE CORES, NOT TO THE GUESTS. The earlier advice here was ~30, reasoned from
# "~100 guests polling the feed at once" back when a feed page cost ~449 ms and connections
@@ -74,7 +87,15 @@ SESSION_EXPIRY_DAYS=30
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
# ── Event ─────────────────────────────────────────────────────────────────────
EVENT_NAME=Max & Maria's Wedding
# DOUBLE-QUOTED, and it matters. Compose's env_file parser reads `Max & Maria's Wedding`
# unquoted just fine — but the runbook also tells you to `set -a; . ./.env; set +a` in a plain
# shell, and POSIX `sh` aborts on the apostrophe with "Unterminated quoted string" (rc=2).
# Everything defined BELOW this line is then left unset, silently: the hourly pg_dump cron in
# §10.2 does exactly this, so it would exit before ever writing a backup, every hour, into a log
# nobody reads. Double quotes are read identically by both parsers (verified) — keep them, and
# keep them double, since single quotes would make a literal `$` in a name survive but are what
# `ADMIN_PASSWORD_HASH` above needs for the opposite reason.
EVENT_NAME="Max & Maria's Wedding"
EVENT_SLUG=max-maria-2026
# ── Storage ───────────────────────────────────────────────────────────────────
@@ -100,17 +121,33 @@ EXPORT_PATH=/exports
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
# which anything warns you:
#
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
# divisor = max(active_uploaders, estimated_guest_count, 1)
# per_user_limit = max(floor(free_disk * quota_tolerance / divisor), 500 MiB)
#
# It is recomputed against LIVE free space on every upload, so it self-throttles: guests
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
# estimated_guest_count is a FLOOR ON THE DIVISOR, not decoration — it is a live knob
# (upload::quota_limit_bytes). Earlier drafts of this file and the runbook both omitted
# it and told operators it was inert; it is not.
#
# Raising it therefore AUTHORISES GUESTS TO FILL MORE OF THE DISK. Setting 0.95 in the
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
# Memories.zip are each roughly a second copy of every original (both store media
# uncompressed). Budget for media + 2x media, or move exports to their own volume.
# It is recomputed against LIVE free space on every upload, so in principle it self-
# throttles: guests converge on a fixed point at tolerance/(1+tolerance) of the free space
# you started with — 43% at 0.75.
#
# ON THIS BOX THAT FIXED POINT NEVER BINDS, and it is worth knowing which knob actually
# stops the disk filling. The arithmetic above used to be quoted as "~30 GB of a fresh
# 70 GB", which is an 80 GB CX33; this deploys to a CX22 with 40 GB. At ~28 GB free and
# estimated_guest_count = 100 flooring the divisor, the formula yields ~210 MB per guest —
# BELOW the 500 MiB floor — so every guest is granted the floor and the per-user quota
# stops bounding aggregate growth at all.
#
# What actually bounds it is the keepsake preflight in upload.rs: uploads are refused once
# free < media x 1.1 x 2 + 10 GB, which on 40 GB lands at ~8 GB of media (README, "Sizing
# the disk"). So if a guest reports being blocked, the number to look at is total media,
# not this one.
#
# Raising this still AUTHORISES GUESTS TO FILL MORE OF THE DISK on a larger box, and it
# still eats the headroom the keepsake needs — Gallery.zip and Memories.zip are each
# roughly a second copy of every original (both store media uncompressed). Budget for
# media + 2x media, or move exports to their own volume.
#
# 0.75 is the tested default. Lower it if the box is tight; raise it only if you have
# provisioned export headroom separately.

View File

@@ -102,6 +102,44 @@ jobs:
working-directory: ./frontend
run: npm run format:check
export-viewer:
name: Keepsake viewer — builds, self-contained, committed artifact in sync
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/export-viewer/package-lock.json'
- name: Install deps
working-directory: ./frontend/export-viewer
run: npm ci || npm install
# Two things nothing else in CI covered, both of which ship a broken keepsake silently.
#
# 1. The build's own self-contained guard (`inlineThemeFonts`) is the only thing standing
# between an added theme asset and a viewer that reaches for files on the guest's disk.
# It is a build-time `this.error`, so it only fires when somebody runs this build — and
# no workflow, Dockerfile or script did. It could sit disarmed indefinitely.
#
# 2. `backend/static/export-viewer/index.html` is COMMITTED and compiled into the binary with
# `include_dir!`. A viewer source change merged without a manual rebuild ships the stale
# artifact, and nothing anywhere would say so. `git diff --exit-code` is the check.
- name: Build the standalone viewer
working-directory: ./frontend/export-viewer
run: npm run build
- name: Committed artifact matches a clean rebuild
run: |
if ! git diff --exit-code -- backend/static/export-viewer/; then
echo "::error::backend/static/export-viewer/ is out of date with frontend/export-viewer/."
echo "Run 'npm run build' in frontend/export-viewer and commit the result."
exit 1
fi
e2e-typecheck:
name: E2E — typecheck + lint
runs-on: ubuntu-latest

View File

@@ -1,3 +1,33 @@
{
servers {
timeouts {
# Slowloris defence, at the layer that can actually apply it.
#
# There was no read or write timeout anywhere, so a client could open a POST, send one
# byte a minute, and hold a connection, a tokio task and a `.tmp` file indefinitely —
# and the upload sweeper is keyed on mtime precisely so a live upload never ages out,
# so ten such connections consumed disk the upload gate could not see.
#
# read_header is tight: a legitimate client sends its headers in one go.
read_header 10s
# read_body is GENEROUS but present. It was omitted on the reasoning that "a slow body
# still has to actually send bytes" — which is an argument about disk, and disk is not
# the scarce resource here. `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 entire budget, every other guest waits 20s and gets a
# 503, and it never recovers on its own because the permit is held until the handler
# returns. That needs no attacker — eight guests starting real videos and then walking
# out of AP range does it, and TCP will not reap those sockets for hours.
#
# 30m carries a 500 MB video at ~2.2 Mbit/s sustained, which is well under venue wifi
# and under most cellular, so it does not fail the uploads this product exists to
# collect. It does bound the leak to something that drains.
read_body 30m
idle 5m
}
}
}
{$DOMAIN} {
# Compress everything EXCEPT the SSE stream — gzip buffering delays
# "real-time" likes/comments until the ~30s keep-alive tick.

View File

@@ -15,42 +15,57 @@ Everything here is written for that last constraint. Where a choice trades throu
## 0. Timeline — the single most important control
### Step zero: commit everything, before you build anything
### Step zero: verify the deployment files are committed, before you build anything
**The production deployment does not exist in git yet.** At the time of writing, `docker-compose.yml`
and `.env.example` are modified but uncommitted, and `DEPLOYMENT_RUNBOOK.md`,
`docker-compose.build.yml`, both `.dockerignore` files and migrations `021`/`022` are untracked.
None of them is gitignored — they are simply not committed.
Everything the server clones must be in git — §7 tells you to `git clone` onto the box, so
anything living only in your working tree is not part of the deployment. Two failure modes if it
is not:
That is not a tidiness problem, it is the deployment failing in two ways at once:
- If the committed `docker-compose.yml` still carried `build:` keys and no `image:` keys, then on
that clone `docker compose pull` would skip both services and `docker compose up -d` would start
**a fat-LTO release build of 427 crates on the CX22** — the exact scenario §1 rules out as an
expected OOM.
- `sqlx::migrate!()` embeds `./migrations` **at compile time**. An image built from a working tree
with uncommitted migrations bakes them in and applies them on first boot; any later rebuild from
a clean clone produces an image that lacks them and crash-loops with `VersionMissing` against its
own database.
- §7 tells you to `git clone` onto the server. `git show HEAD:docker-compose.yml` still has
`build:` keys and **no `image:` keys**, so on that clone `docker compose pull` skips both services
and `docker compose up -d` starts **a fat-LTO release build of 427 crates on the CX22** — the
exact scenario §1 rules out as an expected OOM. The committed file also has no log rotation.
- `sqlx::migrate!()` embeds `./migrations` **at compile time**. A build from the working tree bakes
in 021 and 022 and applies them on first boot; any later rebuild from a clean clone produces an
image that lacks them and crash-loops with `VersionMissing` against its own database.
**As of this writing all of these are committed and the check below passes.** Run it anyway — it
costs a second and it is the difference between finding this now and finding it at T5.
```bash
git add docker-compose.yml docker-compose.dev.yml docker-compose.build.yml .env.example \
backend/.dockerignore frontend/.dockerignore frontend/Dockerfile \
backend/migrations/021_*.sql backend/migrations/022_*.sql \
DEPLOYMENT_RUNBOOK.md
git commit -m "chore: production compose, ignore files and runbook"
git push
# Every deployment file must be tracked. Prints nothing and exits 0 when correct;
# names the offender and exits non-zero otherwise.
git ls-files --error-unmatch \
docker-compose.yml docker-compose.dev.yml docker-compose.build.yml .env.example \
backend/.dockerignore frontend/.dockerignore frontend/Dockerfile \
DEPLOYMENT_RUNBOOK.md Caddyfile >/dev/null
# Prove it landed — this must print two `image:` lines and nothing about `build:`
git show HEAD:docker-compose.yml | grep -E 'image:|build:'
git show HEAD --stat | grep -c migrations/02 # must be 4
# No uncommitted edits to them.
git status --porcelain -- docker-compose.yml .env.example Caddyfile DEPLOYMENT_RUNBOOK.md
# The COMMITTED compose must pull, not build: 4 `image:` lines, zero `build:` lines.
git show HEAD:docker-compose.yml | grep -cE '^[[:space:]]*image:' # must be 4
git show HEAD:docker-compose.yml | grep -cE '^[[:space:]]*build:' # must be 0
# Every migration in the tree is committed — a build from a dirty tree bakes in extras.
git status --porcelain -- backend/migrations/ # must print nothing
# The Caddyfile PARSES. Nothing else checks it: the e2e stack mounts `e2e/Caddyfile.test`,
# so the production file is never executed until the real deploy — 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.
docker run --rm -v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" -e DOMAIN=example.com \
caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile # must end "Valid configuration"
```
| When | What |
|---|---|
| **T7 days** | Commit and push everything above. Registry + DNS pre-flight (§5). Build and push images (§6). |
| **T5 days** | First deploy to the server (§7). Verify admin login. Leave it running. |
| **T5 days** | ⚠ **Enable Hetzner automated snapshots** (§10.1) and **point an uptime monitor at `/health`** (§10.4). Two console checkboxes, ~10 minutes total. Without them a failure during the event is both total and unnoticed. |
| **T3 days** | **Freeze migrations.** No further code deploys unless something is broken. |
| **T2 days** | Pre-pull current *and* previous image tags (§9). Run the backup rehearsal (§10). |
| **T2 days** | Pre-pull current *and* previous image tags (§9). Install the hourly DB dump and prove it runs (§10.2). Run the backup rehearsal (§10.3). |
| **Event day** | Change nothing. Configuration tweaks via the admin dashboard only (§4). |
**Why the freeze matters more than anything else here.** Migrations run automatically at boot
@@ -103,7 +118,7 @@ build/
```
Without these, the first time you run `cargo build` or `npm install` locally, every image build
ships a multi-GB context to an *emulated* builder. Worse: `frontend/Dockerfile:9` does `COPY . .`
ships a multi-GB context to an *emulated* builder. Worse: `frontend/Dockerfile:8` does `COPY . .`
**after** `npm ci`, so a macOS `node_modules/` would be merged over the container's Linux one.
### 2.2 Switch compose from `build:` to `image:`
@@ -169,16 +184,35 @@ EVENTSNAP_VERSION=v0.13.0
# ── Secrets — ALL of them, before the first `up -d` ───────────────────────
JWT_SECRET=<openssl rand -hex 64>
# These two are NOT optional and have no defaults. docker-compose.yml interpolates them into
# `environment:`, which overrides `env_file`, so leaving them out does not fall back — it creates
# a Postgres role and database named "" while DATABASE_URL still says `eventsnap`. The result is
# a permanent crash loop whose only clean exit is `down -v`. Compose now refuses to start without
# them, but write them here anyway: the three values below must agree with each other.
POSTGRES_USER=eventsnap
POSTGRES_DB=eventsnap
POSTGRES_PASSWORD=<openssl rand -hex 24>
DATABASE_URL=postgres://eventsnap:<SAME PASSWORD>@db:5432/eventsnap
ADMIN_PASSWORD_HASH='<docker run --rm caddy:2-alpine caddy hash-password --plaintext "pw">'
# ── Paths — must match the volume mounts ──────────────────────────────────
MEDIA_PATH=/media # pinned by compose anyway, but keep consistent
EXPORT_PATH=/exports # NOT pinned by compose — see the trap in §7.3
# All four of these are PINNED in docker-compose.yml under `app.environment`, which overrides
# `env_file`. Keep them consistent here for readability, but understand that editing them in
# `.env` changes nothing — the pin is what the container gets. Change the pin.
MEDIA_PATH=/media
EXPORT_PATH=/exports
APP_PORT=3000
# ── Sizing (see the two corrections below) ────────────────────────────────
DATABASE_MAX_CONNECTIONS=30
# 15, matching .env.example, the `db` sizing comment in docker-compose.yml and the code
# default. An earlier draft of this runbook said 30: that does not fit the 1G memory limit
# compose allots `db`, and 30 simultaneous queries cannot run on 2 vCPU anyway — they queue
# on the CPU instead of on the pool. Raise it only alongside more cores AND a bigger limit.
#
# ALSO PINNED IN COMPOSE (see above), and pinned for a sharper reason than the paths: an
# unparseable value here is boot-FATAL rather than falling back to the default, so a stray
# quote or a trailing inline comment in `.env` would crash-loop the app behind a live Caddy.
DATABASE_MAX_CONNECTIONS=15
COMPRESSION_WORKER_CONCURRENCY=2
# ── Comments off, likes + captions on ─────────────────────────────────────
@@ -188,10 +222,14 @@ COMMENTS_ENABLED=false
RUST_LOG=eventsnap_backend=info,tower_http=warn
```
### Two corrections to the repo's own advice — do not follow `.env.example` here
### Two sizing decisions worth understanding before you touch them
`.env.example` now agrees with this section on both — it carries the same reasoning inline and
self-corrects the old advice. Kept here because these are the two knobs an operator is most
tempted to raise under pressure.
**`COMPRESSION_WORKER_CONCURRENCY`: keep `2`. Do NOT raise to 4, and do NOT raise the app memory
limit to 2G.** `.env.example:92-98` justifies both on the premise that "each worker can run an
limit to 2G.** An earlier draft justified both on the premise that "each worker can run an
ffmpeg transcode". **There is no transcode anywhere in this codebase.** `services/video.rs::run_ffmpeg`
runs `ffmpeg -ss <t> -i <src> -vframes 1 -vf scale=…` — a single poster frame. Video originals are
stored and served byte-for-byte.
@@ -206,9 +244,15 @@ guard in `imaging::decode_limits` does not cover. Estimated peak per photo:
| 24 MP (iPhone Pro default) | ~223 MB |
| 48 MP ("Max" mode) | ~354 MB |
At concurrency 2, two 48 MP photos ≈ 800 MB against the 1 GiB cap — ~25% margin. At concurrency 4
the same pair is ~1.5 GB → **OOM**. And app=2G + db=1G + 256M + 256M + ~370 MB OS/Docker ≈ 3954 MiB
against ~3910 MiB MemTotal — the box is oversubscribed before a single photo arrives.
Those are per-photo peaks, and the "two 48 MP photos at once" pair this limit used to be sized
against **is no longer reachable**: `compression.rs` takes an EXCLUSIVE `heavy` permit for a large
decode, so two giants serialise no matter what `COMPRESSION_WORKER_CONCURRENCY` is set to (see
`.env.example`, which makes the same point). The binding case is now one giant (~354 MB) plus the
ordinary working set against the 1 GiB cap, which is comfortable.
What has not changed is the reason to keep concurrency at 2 and `app` at 1G: at concurrency 4 the
memory arithmetic stops working (app=2G + db=1G + 256M + 256M + ~370 MB OS/Docker ≈ 3954 MiB
against ~3910 MiB MemTotal — oversubscribed before a single photo arrives).
**`quota_tolerance`: keep `0.75`. Raising it does not make anything more generous for a real guest.**
See §4.
@@ -231,22 +275,33 @@ after the first deploy, before the event.
| `quota_tolerance` | 0.75 | **leave at 0.75** | See below. |
| `quota_enabled`, `storage_quota_enabled`, `rate_limits_enabled` | true | **leave on** | This is the only disk-full safety net. |
**Ignore `estimated_guest_count` and `upload_count_quota_enabled`.** Both are seeded, validated and
rendered in the admin UI — and **read by no code at all** (verified by grep across `backend/src`).
Changing them does nothing. `estimated_guest_count` in particular does *not* feed the quota formula.
> **Correction (was wrong in an earlier draft).** This section used to say "**Ignore
> `estimated_guest_count`** … read by no code at all". That is **false** — it is a live tuning
> knob and it is the dominant term in the quota divisor for a normal event. An operator who
> believed the old text and changed it would have moved every guest's ceiling. `upload_count_quota_enabled`
> genuinely is inert.
### Why quotas are already as generous as you want
```
per_user_limit = floor(free_disk × quota_tolerance / max(active_uploaders, 1))
divisor = max(active_uploaders, estimated_guest_count, 1)
per_user_limit = max(floor(free_disk × quota_tolerance / divisor), 500 MiB)
```
`active_uploaders` is `SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL`
(`upload::compute_storage_quota`) — **people who actually uploaded**, not guests who joined. The 70 guests who never
upload are not in the denominator; their share flows to the photographers automatically. **The
redistribution you asked for is already the design.**
(`upload::quota_limit_bytes`. The 500 MiB floor applies only when the whole budget can back it —
below that the divided value stands, so the quota cannot promise space the disk does not have.)
With ~28 GB free and a realistic 30 people actually uploading, each gets **~700 MB** — against an
expected ~1.25 GB for the *entire event*. Nobody will be blocked.
`active_uploaders` is `SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL`
**people who actually uploaded**, not guests who joined. But it is a `max`, not the sole divisor:
`estimated_guest_count` (default **100**) acts as a **floor on the divisor**, so the ceiling settles
at its final value early instead of sliding down all evening as guests arrive. It also blunts the
abuse case where the divisor was attacker-controlled — ~1000 throwaway accounts once drove every
real guest's ceiling to ~52 MB.
With ~28 GB free, `quota_tolerance` 0.75 and a realistic 30 people actually uploading, the divisor
is **100** (not 30, because `estimated_guest_count` floors it), giving 28 GB × 0.75 / 100 ≈ 210 MB —
which is below the floor, so **every guest is granted the 500 MiB minimum**. Against an expected
~1.25 GB for the *entire event*, nobody will be blocked. An earlier draft computed "~700 MB each"
by dividing by 30; that ignored the floor on the divisor and was wrong.
Raising `quota_tolerance` would only raise the **saturation ceiling** (media converges to
`t/(1+t)` of free space: 43% at 0.75, 50% at 1.0). It does nothing for a real guest at your volume,
@@ -302,11 +357,35 @@ rate-limit you out of getting a certificate at all.
### Host preparation
```bash
docker compose version # must be v2.x — the deploy.resources limits need it
docker compose version # must be v2.x — see below, this one is not optional
free -h && swapon --show # Hetzner images ship no swap
df -h /var/lib/docker # want ≥ 25 GB free
```
> **If `docker compose version` reports v1 (or `docker-compose` is a separate Python binary), STOP
> and install the v2 plugin before deploying.** This check previously had no failure action, which
> made it decorative — and it is the single check that the whole sizing argument rests on.
>
> On Compose v1, `deploy.resources.limits` is **silently ignored** outside Swarm: no warning, no
> error, `up -d` exits 0. Every memory and CPU limit in `docker-compose.yml` evaporates, and §1's
> arithmetic (`app` 1G + `db` 1G + 256M + 256M inside ~3910 MiB) becomes fiction — the first 48 MP
> photo takes the box out via the OOM killer instead of being bounded. On v2 the limits are real
> (verified empirically: `memory: 1G` produces `HostConfig.Memory=1073741824`).
>
> ```bash
> # Debian/Ubuntu, with Docker's official repo already configured:
> apt-get update && apt-get install -y docker-compose-plugin
> docker compose version # must now print v2.x
> ```
>
> Verify the limits actually landed, once the stack is up — this is the check that matters, not the
> version string:
>
> ```bash
> docker inspect eventsnap-app-1 --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'
> # Must print two NON-ZERO numbers. `0 0` means the limits were dropped.
> ```
**Add 2 GB of swap** as an OOM cushion — a compression spike that would otherwise kill the container
instead swaps out cold pages and merely runs slowly:
@@ -316,10 +395,19 @@ echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl -w vm.swappiness=10 && echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
```
> **Gotcha:** Compose sets each container's `Memory` limit but leaves `MemorySwap` unset, and Docker
> then allows swap equal to the memory limit so adding host swap silently **doubles** every
> container ceiling. If you add swap, also add `memswap_limit: 1152m` to `app` and `db`, and
> `memswap_limit: 320m` to `frontend` and `caddy` (service-level, not under `deploy:`).
> **Already handled — do not hand-edit compose.** Compose sets each container's `Memory` limit but
> leaves `MemorySwap` unset, and Docker then allows swap equal to the memory limit, so adding host
> swap would silently **double** every container ceiling (to ~5 GiB of ceilings on a 3.82 GiB box).
> `docker-compose.yml` now ships `memswap_limit` on all four services — 1152m on `app` and `db`,
> 320m on `frontend` and `caddy` — so this step is safe as written.
>
> This used to say "add it yourself", which also broke §0's own gate that
> `git status --porcelain -- docker-compose.yml` must print nothing. Confirm it is still there:
>
> ```bash
> docker inspect eventsnap-app-1 --format '{{.HostConfig.Memory}} {{.HostConfig.MemorySwap}}'
> # 1073741824 1207959552 — the second number MUST be larger than the first but not double it.
> ```
---
@@ -410,28 +498,33 @@ describes this trap at `backend/src/db.rs` (`explain_auth_failure`); this comman
### 7.2 Bring it up
```bash
# `.env` is consumed by docker compose, not by your shell — load it before using $DOMAIN.
set -a; . ./.env; set +a
# `.env` is consumed by docker compose, not by your shell — read $DOMAIN out of it first.
# Reads that ONE variable rather than sourcing the file: `.env` legitimately holds values with
# apostrophes (EVENT_NAME), and `. ./.env` aborts on one with "Unterminated quoted string".
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'")
docker compose up -d
docker compose logs -f app # wait for "database connected and migrations applied"
curl -fsS https://$DOMAIN/health # → ok (503 means the app is up but the DB is not)
```
### 7.3 Verify the three things compose does *not* pin
### 7.3 Verify what the container actually received
`MEDIA_PATH` is pinned to `/media` on the `app` service in `docker-compose.yml`. Its siblings
are not:
`MEDIA_PATH`, `EXPORT_PATH` and `APP_PORT` are all **pinned** on the `app` service in
`docker-compose.yml`, exactly as §3 says — editing them in `.env` changes nothing. This step is
not about whether they are pinned; it is about confirming the container got the values you think
it did, including the two that genuinely do come from `.env`:
```bash
docker compose exec app printenv DATABASE_URL EXPORT_PATH ADMIN_PASSWORD_HASH
```
1. **`DATABASE_URL`** must contain `@db:5432`. A dev `.env` points it at `@localhost`, which inside
the container is the app itself.
2. **`EXPORT_PATH`** must be `/exports`. Anywhere else and the keepsake archives are written to the
container's writable layer and **vanish on the next `up -d`** — including on a rollback.
3. **`ADMIN_PASSWORD_HASH`** must match `.env` **byte for byte.**
1. **`DATABASE_URL`** (from `.env`) must contain `@db:5432`. A dev `.env` points it at
`@localhost`, which inside the container is the app itself.
2. **`EXPORT_PATH`** (pinned) must read `/exports`. If it does not, the pin has been edited —
anywhere else and the keepsake archives are written to the container's writable layer and
**vanish on the next `up -d`**, including on a rollback.
3. **`ADMIN_PASSWORD_HASH`** (from `.env`) must match `.env` **byte for byte.**
**Then actually log in to `/admin` with the real password.** This is not optional politeness:
@@ -455,9 +548,10 @@ the cost of checking is 10 seconds; the cost of being wrong is the whole event.
## 8. Post-deploy verification
```bash
set -a; . ./.env; set +a # $DOMAIN comes from .env, not your shell
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'") # $DOMAIN comes from .env, not your shell
docker compose ps # all four healthy
docker compose ps # db, app, frontend healthy; caddy has
# no healthcheck and shows only "running"
docker inspect -f '{{.HostConfig.Memory}}' eventsnap-app-1 # must be 1073741824, not 0
curl -fsS https://$DOMAIN/health # ok — now a real DB check, not a constant
docker compose exec app printenv COMMENTS_ENABLED RUST_LOG
@@ -486,13 +580,13 @@ explanation:
```
$ git ls-tree --name-only v0.12.0 backend/migrations/ | wc -l
12 # 6 migrations. HEAD has 22.
12 # 6 migrations. HEAD has 31.
$ git rev-list --count v0.12.0..HEAD
154
217
```
`db.rs` runs `sqlx::migrate!()` with no `set_ignore_missing`, so an image built from a 6-migration
tree, booting against a database that already carries versions 007022, returns `VersionMissing`.
tree, booting against a database that already carries versions 007031, returns `VersionMissing`.
`create_pool` errors, `main` exits 1, and `restart: unless-stopped` restarts it forever — with Caddy
still routing traffic to it. (`014_export_epoch.up.sql` documents this failure mode; §0 restates it.)
No `v0.12.0` image was ever built or pushed either, so the pre-pull would fail with
@@ -540,7 +634,7 @@ covers both. It exists so that the rollback line in the emergency card is safe t
catastrophic. **If you genuinely need to undo a code change during the event, you cannot; freeze
early enough that you never have to.**
**Across a migration boundary — avoid by freezing.** If you must: all 22 migrations have paired
**Across a migration boundary — avoid by freezing.** If you must: all 31 migrations have paired
`.down.sql` files, but **none of them removes its own `_sqlx_migrations` row**, so that second step
is mandatory and undocumented:
@@ -556,6 +650,18 @@ docker compose exec -T db sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM _sqlx_migrations WHERE version = NN;"'
```
> **A down migration is only valid PAIRED WITH A CODE ROLLBACK — it is not a standalone repair.**
> `Upload::create` sends an `ON CONFLICT ... WHERE` predicate that must match the live partial
> index exactly, and these queries are not compile-checked. Run **026**'s or **031**'s down against
> the current binary and every upload carrying a `client_upload_id` — i.e. every upload from the
> shipped client — becomes a runtime 500. Roll the image back first, then the migration.
>
> **026's down can also fail outright, and that is expected.** It restores a wider unique index, so
> it aborts with `could not create unique index ... is duplicated` on any database where a guest
> ever deleted a photo and re-uploaded it. The transaction rolls back cleanly and the narrow index
> survives intact — no half-state — but you cannot go below 026 on a database that has seen real
> use. Verified against a live Postgres.
Migration **014** is the only destructive one on the way up, and the only one with a rehearsal
harness — `backend/scripts/rehearse-014.sh`. Run it once against a real dump before the event.
@@ -570,18 +676,120 @@ docker save registry.mc02.dev/eventsnap/app:v0.13.0 \
## 10. Backup
Full commands are in `README.md:315-435` and are correct — `pg_dump --clean --if-exists`, plus
Full commands are in README's **`## Backup`** and **`## Restore`** sections — read `## Restore` to
its END (through the media *and* exports restore, and the `chown` that follows), not just the
database step. Referenced by heading, not by line number: the previous pointer named a line range
that had drifted to the middle of an unrelated section and stopped mid-way through restore step 2,
which would have restored the database and no media. They are correct — `pg_dump --clean --if-exists`, plus
`alpine tar` out of `eventsnap_media_data` and `eventsnap_exports_data`, mounted at `/src` (not
`/media`), with `chown -R 100:101` on restore because the app runs non-root and BusyBox tar has no
`--same-owner`. There is deliberately no script.
Two gaps the README does not cover:
Three gaps the README does not cover:
1. **Nothing backs up `.env`**, which holds the only copy of `POSTGRES_PASSWORD`. A dump you cannot
authenticate against is not a backup. Copy `.env` off the box, encrypted, once it is final.
2. **Timing.** Do not use nightly cron — every irreplaceable byte is created inside one evening.
Take the dump and the media tarball back-to-back **the night of the event, after locking uploads
from the host dashboard**, so the pair is consistent.
2. **The final dump is not a backup — it is an archive.** Taking it *after* locking uploads gives
you a consistent pair, and that is the right way to archive the finished event. But it means
that until the host locks uploads there is **no copy of anything anywhere**. All four volumes
sit on the same 40 GB filesystem, on one VPS, with no redundancy. A disk or host failure at
23:00 — the fullest the gallery will ever be — loses **100% of the event**, permanently, with
the guests still in the room. Both of the mitigations below are required.
3. **Nothing is watching.** See §10.2.
### 10.1 Snapshots — do this once, before the event
> ⚠ **ACTION REQUIRED — Hetzner Cloud console, ~5 minutes, one checkbox.**
> Server → **Backups** → enable. Costs ~20% of the server price and needs no operator action
> ever again.
This is the single highest-value item in this runbook. It converts "total, permanent loss" into
"lose at most the hours since the last snapshot", automatically, with nobody awake. It covers the
whole volume set at once — database, media, exports and `.env` — which the `pg_dump` path does not.
It does **not** replace §10's archive: snapshots are whole-disk and crash-consistent, so restoring
one gives you the box back, not a portable copy of the photos. Do both.
### 10.2 A mid-event database dump — cheap, and the only thing cron should do
The database is small (a few MB — it holds rows, not pixels) and it is the part that cannot be
reconstructed: media files on disk without their `upload` rows are anonymous UUIDs with no
uploader, caption, hashtag or timestamp. Dumping it hourly costs essentially nothing and is safe
while uploads are live, because a `pg_dump` is transactionally consistent on its own.
Media is the bulk and *is* recoverable from guests' phones in the worst case, so it stays on the
event-night schedule below.
Install this as **the same user you deployed as** (§5) — not root. `docker compose` needs that
user's docker group membership and its compose project, and a root crontab has neither.
```bash
# On the server, before the event. Hourly DB-only dump, keeping the last 48.
mkdir -p ~/eventsnap-dumps
cat >~/eventsnap-dump.sh <<'SH'
#!/bin/sh
set -eu
# Must match your deploy directory from §5. cron starts in $HOME, so this cannot be relative.
cd "$HOME/eventsnap"
# NOTE: deliberately does NOT source .env. Nothing here reads it — POSTGRES_USER and POSTGRES_DB
# are expanded INSIDE the db container by the single-quoted sh -c below, using the values compose
# already injected. Sourcing it was actively harmful: `.env` legitimately contains values with
# apostrophes (EVENT_NAME="Max & Maria's Wedding"), and POSIX sh aborts on one with
# "Unterminated quoted string". Under `set -eu` this script would exit before pg_dump — every
# hour, silently, leaving the only automated backup of the irreplaceable table permanently empty.
OUT="$HOME/eventsnap-dumps/db-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
docker compose exec -T db sh -c \
'pg_dump --clean --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip >"$OUT.tmp"
mv "$OUT.tmp" "$OUT" # atomic: never leave a truncated dump looking complete
ls -1t "$HOME"/eventsnap-dumps/db-*.sql.gz | tail -n +49 | xargs -r rm
SH
chmod +x ~/eventsnap-dump.sh
( crontab -l 2>/dev/null; echo "17 * * * * $HOME/eventsnap-dump.sh >>$HOME/eventsnap-dumps/dump.log 2>&1" ) | crontab -
# Prove it works NOW, not at 23:00 — and prove it produced a NON-EMPTY dump, since the failure
# this replaces produced a zero-byte file and a clean exit code.
~/eventsnap-dump.sh && ls -lh ~/eventsnap-dumps/
gzip -t ~/eventsnap-dumps/db-*.sql.gz && echo "dump is a valid gzip"
zcat ~/eventsnap-dumps/db-*.sql.gz | grep -c 'CREATE TABLE' # must be > 0, not just "a file exists"
```
These land on the same filesystem, so they do **not** survive a disk loss — that is what §10.1 is
for. They protect against the far more likely failure: a bad migration, an accidental host action,
or a corrupted table.
### 10.3 The event-night archive — unchanged
Take the dump and the media tarball back-to-back **the night of the event, after locking uploads
from the host dashboard**, so the pair is consistent. Copy both off the box before you sleep.
### 10.4 Monitoring — something has to be able to wake you
> ⚠ **ACTION REQUIRED — external uptime monitor, ~5 minutes.**
> Point any free monitor (UptimeRobot, Better Stack, Healthchecks.io — all have free tiers with
> SMS or push) at `https://$DOMAIN/health`, 15 minute interval, **alerting to a phone that will
> be on you during the event.**
There is otherwise **no** metrics collection, no alerting, no log shipping and no external check
anywhere in this deployment. Without this step, none of the following reaches a human: a crash
loop, a full disk, a dead database, an expired certificate, or the box being off. The host is at
a party and is not watching a dashboard.
`/health` is already built for exactly this and nothing currently consumes it:
| Response | Meaning | Action |
|---|---|---|
| `200 ok` | App **and** database are answering | — |
| `503 database timeout` / `database unavailable` | App is up, Postgres is not | §13 emergency card |
| Connection refused / TLS error | App container or Caddy is down | `docker compose ps`, then §13 |
| Timeout | Box is gone, or the disk is full enough to wedge it | §10.1 snapshot restore |
It runs a real `SELECT 1` against the pool with a 2 s timeout — a green check means the request
path guests use is genuinely working, not merely that a process is listening.
**The one signal this does not give you is disk.** The low-disk banner on `/host` requires the
host to open a dashboard during their own party and does not refresh without a manual reload, so
treat it as a pre-event check, not an alert. Before the event, confirm headroom with §11's
numbers; the export preflight and the upload quota are the automated backstops.
---
@@ -602,10 +810,13 @@ total ~1113 GB of ~36 GB usable
35 GB of cache that permanently shrinks the guest quota, because the quota is recomputed against
*live* free space on every upload).
The `README.md:295-299` "ENOSPC" projection models guests **saturating the quota** (~12 GB of
media), not 100 photos. That scenario needs ~10× your expected volume — and it degrades gracefully:
the export preflight refuses up front rather than hitting ENOSPC mid-write, and the host dashboard
warns when free space drops below 10 GB or below the keepsake requirement (`handlers::host`'s low-disk thresholds).
The "ENOSPC" projection in README's **`### Sizing the disk`** discussion models guests
**saturating the quota** (~12 GB of media), not 100 photos. That scenario needs ~10× your expected
volume — and it degrades gracefully: the export preflight refuses up front rather than hitting
ENOSPC mid-write, and the host dashboard warns while free space is still **1.25× above the level at
which uploads stop** (`handlers::host::disk_is_low`). Note that is the only trigger: the separate
10 GB absolute floor this used to describe was removed as unreachable, because the derived
threshold is always higher.
---
@@ -654,6 +865,56 @@ These change what you will observe on the night, so they are listed separately f
| 3 | **SSE keep-alives are sent as SSE comments** (`:ping`), which the browser's EventSource parser discards without dispatching. A client therefore cannot implement a pure silence timer to detect a half-open socket. | Worked around client-side: the feed runs a jittered 60120 s `/feed/delta` backstop and reconnects when a poll returns content the stream never delivered. A cleaner fix is to emit keep-alives as a *named* event; that is a coordinated backend+frontend change, not worth making during a freeze. |
| 4 | **The lightbox stops at the end of the loaded page** — stepping past the last loaded photo does not fetch the next one. | The guest scrolls the feed (which does page) and re-opens. |
### Migration checksum mismatch — `VersionMissing` / "previously applied but has been modified"
`sqlx` compares **checksums**, so renaming or renumbering a migration file is indistinguishable
from editing one. If a box ever booted an image built from a branch that numbered migrations
differently, the next boot aborts with *"migration 21 was previously applied but has been
modified"*, `main` exits non-zero, and `restart: unless-stopped` makes it **permanent** — with
Caddy still routing traffic to the dead container.
Every main-line migration is byte-identical to what shipped, so a box that only ever ran tagged
releases is unaffected. **Verify rather than assume** — run this against the server before any
deploy.
Note the `sh -c` wrapping, for the same reason as §9: `POSTGRES_USER` and `POSTGRES_DB` live in
`.env`, which Compose reads and **your shell does not**. Unwrapped, `-U "$POSTGRES_USER"` sends
`-U ""` and psql answers `FATAL: role "" does not exist` — at 11pm, with the app crash-looping.
```bash
docker compose exec -T db sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version, description, success FROM _sqlx_migrations ORDER BY version;"'
```
If the app is already crash-looping on a renumbered migration, and **only** if you have confirmed
the SQL in the new file is equivalent to what was actually applied. Take the version numbers from
the crash message and the query above — do **not** copy the ones below, which are an example:
```bash
docker compose stop app
# Replace 21,22,23 with the versions the boot error actually named.
docker compose exec -T db sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM _sqlx_migrations WHERE version IN (21,22,23);"'
docker compose start app # re-applies exactly those, then continues
```
This re-runs those migrations. They must be idempotent (`IF NOT EXISTS` / `IF EXISTS`) or this
fails differently. Take a `pg_dump` first — see §10.
### Schema changes are **not** compile-time checked
All ~120 queries use the runtime `sqlx::query()` API. There are no `query!` macros and no `.sqlx`
cache, and the backend **compiles with no `DATABASE_URL` at all**. Consequences:
- A migration that renames or drops a column **compiles clean** and fails in production as a
runtime 500 on whichever request path touches it first.
- `cargo build` succeeding tells you nothing about schema/query agreement. Only `cargo test`
(which runs against a real Postgres) and manual exercise of the affected route do.
So: after any migration that touches an existing column, exercise the routes that read it before
you consider the deploy done. README and PROJECT previously claimed compile-time checking; they
have been corrected.
---
## 13. Event-day emergency card
@@ -665,8 +926,10 @@ cd ~/eventsnap
# FIRST LINE, ALWAYS. `.env` is read by docker compose, NOT by your shell — without this,
# every `$DOMAIN` below expands to nothing and `curl https:///health` reads like an outage
# when the site is fine.
set -a; . ./.env; set +a
# when the site is fine. Reads the one variable instead of sourcing the file, because `.env`
# legitimately contains an apostrophe (EVENT_NAME) and `. ./.env` dies on it — which at 11pm
# looks exactly like the outage you came here to diagnose.
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'")
# Is it alive? (200 = app AND database are answering; 503 = the app is up, the DB is not)
curl -fsS https://$DOMAIN/health
@@ -686,6 +949,24 @@ sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env && docker com
df -h /var/lib/docker
```
### "Der Speicher des Events ist fast voll" — guests cannot upload
**`df -h` will look fine, and that is not a contradiction.** The upload gate refuses long before the
disk fills: it reserves room for the keepsake, which is roughly a second copy of every original, plus
a 10 GB floor. Uploads stop at **~8 GB of media** on a 40 GB box, when `df` still shows ~20 GB free.
Check the number that actually binds, not free space:
```bash
docker compose exec -T db sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_size_pretty(sum(original_size_bytes)) FROM upload WHERE deleted_at IS NULL;"'
```
Mid-event, in order of preference: delete the largest videos from the host dashboard (each frees its
own bytes immediately), or move `exports_data` to a separate volume. Raising `quota_tolerance` will
**not** help — on this box every guest is already on the 500 MiB floor, so that knob is not what is
refusing them (see §4 and `.env.example`).
**NEVER** run `docker compose down -v`. It deletes the database, all media, all exports and the TLS
certificate. There is no undo.

View File

@@ -344,7 +344,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
| Styling | Tailwind CSS | Utility-first, mobile-first; zero runtime CSS overhead |
| Backend | Rust + Axum | Developer preference; memory safety, single-binary deploy |
| Async Runtime | Tokio | De-facto Rust async runtime; Axum is built on it |
| Database Driver | SQLx | Async PostgreSQL with compile-time query checking; automatic prepared statements |
| Database Driver | SQLx | Async PostgreSQL; automatic prepared statements. **Queries use the runtime `sqlx::query()` API, not the checked macros** — see the note under "Schema changes" |
| Database | PostgreSQL 16 | Robust, relational; straightforward to back up |
| Auth | Custom JWT (`jsonwebtoken` crate) | No external service needed; name + PIN is the full auth model |
| Image Compression | `image` crate + `oxipng` | Lossless PNG compression; JPEG preview generation |
@@ -1206,7 +1206,7 @@ of the media volume alone silently loses every generated keepsake.
|-------|---------|
| `axum` | Web framework |
| `tokio` | Async runtime |
| `sqlx` | Async PostgreSQL driver; compile-time query checking; prepared statements; migrations |
| `sqlx` | Async PostgreSQL driver; prepared statements; migrations embedded at compile time. Queries are runtime-checked (`sqlx::query()`), **not** macro-checked |
| `jsonwebtoken` | JWT sign / verify |
| `bcrypt` | PIN + admin password hashing |
| `uuid` | UUID v7 (time-sortable) |

View File

@@ -49,7 +49,7 @@ A guest scans the QR code on their way in, types their name, and is immediately
| Styling | Tailwind CSS v4 |
| Backend | Rust + Axum |
| Async | Tokio |
| Database | PostgreSQL 16 via SQLx (compile-time query checking) |
| Database | PostgreSQL 16 via SQLx (runtime query API; migrations embedded at compile time) |
| Auth | Custom JWT (`jsonwebtoken`) + bcrypt PINs |
| Image processing | `image` crate + `oxipng` (lossless compression) |
| Video processing | ffmpeg via `tokio::process::Command` |
@@ -150,45 +150,63 @@ Caddy automatically obtains a Let's Encrypt certificate on first start. The app
### Updating an existing deployment
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
> `build:` services with no published image tag, and Compose has no source-change detection:
> if an image with that name already exists it is reused. After a `git pull` the command
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
> nothing looks exactly like a successful one. `--build` is what makes it real.
> **The event server never compiles.** `app` and `frontend` have **no `build:` key** — they
> pull an immutable tag from the registry (the `app` service in `docker-compose.yml` says so explicitly, so that
> a wrong tag fails instantly with `manifest unknown` instead of silently starting a 45-minute
> compile on the box guests are using). A `git pull` therefore deploys **nothing** on its own,
> and `docker compose up -d --build` **errors** — there is nothing to build. Deploying means
> pushing a new tag from a workstation and pointing `EVENTSNAP_VERSION` at it.
```bash
# ── On your workstation: build and push the new tag ───────────────────────────
# Push the rollback twin at the same time, from the same source — see
# DEPLOYMENT_RUNBOOK.md §9 for why an identical second tag is the rollback target.
VERSION=v0.13.1
docker buildx build --platform linux/amd64 \
-t registry.mc02.dev/eventsnap/app:$VERSION \
-t registry.mc02.dev/eventsnap/app:$VERSION-a --push ./backend
docker buildx build --platform linux/amd64 \
-t registry.mc02.dev/eventsnap/frontend:$VERSION \
-t registry.mc02.dev/eventsnap/frontend:$VERSION-a --push ./frontend
# ── On the server ─────────────────────────────────────────────────────────────
cd /path/to/eventsnap
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
# (See "Backup" below; the database dump is the one that matters here.)
# 2. Fetch the new code.
# 2. Fetch the new compose/Caddyfile. This does NOT change which image runs.
git pull
# 3. Rebuild and restart the application services. --build is NOT optional.
docker compose up -d --build
# 3. Point the stack at the new tag.
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.1/' .env
# 4. Apply any Caddyfile change. Step 3 does NOT do this — see the warning below.
# 4. Pull explicitly, BEFORE restarting. A failure here (bad tag, registry down) leaves the
# running stack untouched; letting `up -d` discover it takes the app down first.
docker compose pull app frontend
# 5. Restart onto the new images.
docker compose up -d app frontend
# 6. Apply any Caddyfile change. Step 5 does NOT do this — see the warning below.
docker compose up -d --force-recreate caddy
# 5. Confirm the app came back up. Anything other than "ok" means check the logs.
# 7. Confirm the app came back up. Anything other than "ok" means check the logs.
curl -fsS https://DOMAIN/health && echo
# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
# compare — it must have changed. (Ignore the CREATED column; it reports the base
# layer's age, not this build's.) An unchanged ID means step 3 ran without --build
# and you are still serving the old code.
# 8. Confirm the running containers are actually on the new tag.
docker compose images app frontend
```
Migrations are applied by the backend on startup, so step 3 covers them. If `app` stays
Migrations are applied by the backend on startup, so step 5 covers them. If `app` stays
unhealthy afterwards, `docker compose logs app` will name the failing migration — and note
that a migration applied by a *newer* build is not removed by checking out an older commit,
so rolling back code without restoring the database snapshot from step 1 leaves the schema
ahead of the binary and the app refusing to boot.
that a migration applied by a *newer* build is not removed by rolling the tag back, so
reverting `EVENTSNAP_VERSION` without restoring the database snapshot from step 1 leaves the
schema ahead of the binary and the app refusing to boot. **This is why the rollback target is
an identical twin tag rather than an older release** — see `DEPLOYMENT_RUNBOOK.md` §9.
> **Why step 4 exists.** `--build` only rebuilds services that have a `build:` section, and
> `caddy` is a pinned upstream image. Compose decides whether to recreate a container from its
> **Why step 6 exists.** Steps 45 only touch `app` and `frontend`; `caddy` is a separate
> pinned upstream image. Compose decides whether to recreate a container from its
> *config hash*, which covers the mount **specification** (`./Caddyfile:/etc/caddy/Caddyfile:ro`)
> but **not the file's contents** — so a `git pull` that changes `./Caddyfile` produces no
> delta, Compose reports `Running`, and Caddy keeps serving its old config indefinitely. Exit
@@ -196,7 +214,7 @@ ahead of the binary and the app refusing to boot.
>
> That is not hypothetical: the fix that made the keepsake download work on iOS
> (`137c4ee`) touched the Caddyfile and four e2e files and nothing else, so **all** of its
> production effect lives in that one file. Without step 4 you deploy it, watch both image IDs
> production effect lives in that one file. Without step 6 you deploy it, watch both image IDs
> change, and iOS downloads stay broken.
>
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
@@ -293,19 +311,29 @@ to imply is gone. What bounds the disk is the **global gate in the upload handle
which refuses any upload that would leave too little room to build the keepsake:
```
free_after_upload < media_after × 1.1 × 2 + DISK_RESERVE_BYTES → refused
free_after_upload < media_after × 1.1 × 2 + DISK_RESERVE_BYTES
+ UPLOAD_GATE_HEADROOM_BYTES → refused
```
Solving that for the gallery size gives the real ceiling. On the **40 GB box this runs
on**, with ~5 GB for the OS, Docker images (the runbook pre-pulls the rollback tag too)
and Postgres:
That last term is what separates this gate from the export preflight, which bails at
`media × 1.1 × 2 + DISK_RESERVE_BYTES` — the same expression **minus** the headroom. The
two used to be identical, which meant the preflight was already sitting on its limit at
the exact moment uploads stopped: every byte written between the last refused upload and
the host tapping *Galerie freigeben* (Postgres WAL, container logs, the compression
backlog draining at precisely that hour) pushed it under, and the release commits before
the workers fail. The headroom buys 1.5 GB of slack so that cannot happen.
| Volume | Usable after baseline | Media ceiling | Free at release |
|---|---|---|---|
| 40 GB | ~35 GB | **~8 GB** | ~27 GB → both archives fit |
| 80 GB | ~70 GB | ~19 GB | ~51 GB → both archives fit |
Solving the gate for the gallery size gives the real ceiling — the gate's equilibrium is
`3.2 × media`, so each GB of reserve or headroom costs ~0.31 GB of gallery. On the
**40 GB box this runs on**, with ~5 GB for the OS, Docker images (the runbook pre-pulls
the rollback tag too) and Postgres:
**Uploads therefore stop at roughly 8 GB of media on a 40 GB box, not when the disk is
| Volume | Usable after baseline | Media ceiling | Free at release | Preflight needs |
|---|---|---|---|---|
| 40 GB | ~35 GB | **~7.3 GB** | ~27.7 GB | ~26.2 GB → fits, 1.5 GB spare |
| 80 GB | ~70 GB | ~18.3 GB | ~51.7 GB | ~50.2 GB → fits, 1.5 GB spare |
**Uploads therefore stop at roughly 7 GB of media on a 40 GB box, not when the disk is
full.** That is deliberate. 1000 photos at ~3.5 MB is ~3.5 GB and fits comfortably;
video is what consumes the budget, so lower `max_video_size_mb` (seeded at 500) if you
expect a lot of it. Refusing the 1001st upload is a far better outcome than accepting it

4
backend/Cargo.lock generated
View File

@@ -605,9 +605,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]

View File

@@ -13,6 +13,13 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
COPY src ./src
COPY static ./static
COPY migrations ./migrations
# Copied WITH the sources, not with Cargo.toml above: cargo auto-detects `build.rs` by presence, so
# putting it in the dependency-cache layer would make the dummy build run it too and invalidate a
# layer that is otherwise stable. Copied at all because without it the image builds a subtly
# DIFFERENT package from the one developers build — no build script, hence none of the
# rerun-if-changed tracking for `static/export-viewer` and `migrations`. Harmless here (every image
# build is clean, so there is no stale cache to reuse) and confusing everywhere else.
COPY build.rs ./
RUN touch src/main.rs && cargo build --release
# --- Runtime stage ---

25
backend/build.rs Normal file
View File

@@ -0,0 +1,25 @@
//! Tell cargo which non-Rust inputs are baked into the binary.
//!
//! `include_dir!` and `sqlx::migrate!()` both embed directory contents at COMPILE time, and neither
//! registers a rebuild dependency on its own. Cargo therefore reuses a cached binary when only
//! those directories changed — the source files are untouched, so as far as cargo is concerned
//! nothing happened.
//!
//! For the keepsake viewer that is a silent, shippable defect: run `npm run build` in
//! `frontend/export-viewer`, then `cargo build`, and the resulting binary still carries the
//! PREVIOUS `static/export-viewer/index.html`. The artifact on disk and the artifact in the binary
//! disagree, `git status` is clean, and every check passes — while `Memories.zip` ships a stale
//! viewer. Confirmed empirically: after replacing the file, the compiled-in copy did not change
//! until a source file was touched.
//!
//! Production is mostly insulated because images are built from a clean context (no cache to
//! reuse), but every incremental build — i.e. all local development and any test run that follows
//! a viewer rebuild — hits it, and that includes the test that asserts the viewer is present.
fn main() {
// The compiled-in keepsake viewer (services/export.rs: `include_dir!`).
println!("cargo:rerun-if-changed=static/export-viewer");
// The embedded migration set (db.rs: `sqlx::migrate!()`). Same mechanism, and the failure is
// worse: a binary built from a stale snapshot boots against a database that has already run a
// newer migration and crash-loops with VersionMissing.
println!("cargo:rerun-if-changed=migrations");
}

View File

@@ -0,0 +1,13 @@
-- Restore migration 022's wider index (which also covered soft-deleted rows).
--
-- Note this can FAIL where the up-migration succeeded: once retries-after-delete have been
-- allowed, two rows may legitimately share a `client_upload_id` (one deleted, one live), and
-- the wider unique index cannot be rebuilt over them. That is inherent to reverting this
-- direction, not a defect in the down-migration. If it fails, the live-only index is still
-- correct and should simply be kept.
DROP INDEX IF EXISTS upload_client_upload_id_key;
CREATE UNIQUE INDEX upload_client_upload_id_key
ON upload (client_upload_id)
WHERE client_upload_id IS NOT NULL;

View File

@@ -0,0 +1,35 @@
-- Narrow the client-upload idempotency index so it stops covering soft-deleted rows.
--
-- The bug (H9). Migration 022 created the index partial on `client_upload_id IS NOT NULL`
-- only, so a soft-deleted row kept occupying its key. But `find_by_client_upload_id` filters
-- `deleted_at IS NULL` — deliberately, and its doc comment says so: "if the guest deleted the
-- photo and their queue later retries, they should get a fresh upload rather than a
-- resurrection of a deleted one." The index and the lookup therefore disagreed, and the
-- disagreement is reachable by an ordinary guest:
--
-- 1. guest uploads a photo, then deletes it (soft delete — the row stays, `deleted_at` set)
-- 2. their queue retries the same item (reconnect requeue, or they tap "Erneut")
-- 3. the whole body is re-streamed and re-validated, then `ON CONFLICT DO NOTHING` matches
-- the DEAD row and inserts nothing
-- 4. the replay lookup filters that row out and finds nothing, so the handler returns 409
-- 5. the client classifies 409 as terminal and DELETES the blob from IndexedDB
--
-- The photo is now gone from the device with no row in the gallery, and there is no path back.
-- Re-selecting the same file from the camera roll mints a new `client_upload_id`, so that does
-- work — but the guest has no way to know that is what is required.
--
-- Adding `deleted_at IS NULL` makes the index agree with the lookup: a key is claimed only
-- while a LIVE row holds it, so step 3 inserts a fresh row and the retry succeeds.
--
-- Uniqueness among live rows is what the feature actually needs. The property migration 022
-- was protecting — "the same photo must not land in the gallery twice" — is about rows the
-- guest can see, and a soft-deleted row is not one of those.
DROP INDEX IF EXISTS upload_client_upload_id_key;
-- CONCURRENTLY is deliberately NOT used: sqlx runs each migration inside a transaction, and
-- CREATE INDEX CONCURRENTLY cannot run in one. The table is small (one event's uploads) and
-- this runs at boot before the server accepts requests, so the brief lock costs nothing.
CREATE UNIQUE INDEX upload_client_upload_id_key
ON upload (client_upload_id)
WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL;

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS user_client_join_id_key;
ALTER TABLE "user" DROP COLUMN IF EXISTS client_join_id;

View File

@@ -0,0 +1,34 @@
-- Idempotency key for /join, supplied by the client.
--
-- The failure this closes (H16) is the single most likely failure of the evening, on step one
-- of the product. `/join` commits the user row AND the bcrypt hash of the PIN, but the PLAINTEXT
-- PIN exists nowhere except the HTTP response body. So:
--
-- 1. guest scans the QR in the venue car park, taps "Beitreten"
-- 2. the server creates the account and hashes the PIN
-- 3. the response is lost on the way back — the 5G-to-nothing transition every wedding venue
-- has, or the AP handing off
-- 4. the client retries; the name is now taken, so it 409s
-- 5. the client shows a PIN entry form for a PIN THAT WAS NEVER DISPLAYED
--
-- The guest is locked out of their own brand-new account, and the only recovery is finding a
-- host with a dashboard open. `/upload` already solved exactly this with `client_upload_id`;
-- join never got the same treatment.
--
-- With a key, a retry is recognised as the same join and answered with a usable PIN. We do NOT
-- store the plaintext to replay it — see the handler: a retry ROTATES the PIN. That is sound
-- precisely because the original was never shown to anybody, so there is nothing to preserve,
-- and it keeps this table free of recoverable credentials.
ALTER TABLE "user" ADD COLUMN client_join_id UUID;
-- Partial, for the same reasons as `upload_client_upload_id_key`: index only the rows that
-- carry a key, and state the rule exactly. NULL is allowed and unconstrained, so any client
-- that does not send one (and every row that predates this column) behaves exactly as before.
--
-- Scoped per event as well as per key. The key is a client-generated v4 UUID so a cross-event
-- collision is not realistic, but a reused install genuinely has two events in one table and
-- "this join belongs to that event" is the property we actually mean.
CREATE UNIQUE INDEX user_client_join_id_key
ON "user" (event_id, client_join_id)
WHERE client_join_id IS NOT NULL;

View File

@@ -0,0 +1,22 @@
-- Restore migration 024's counts (which included banned users' likes and comments).
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.display_path,
u.mime_type,
u.caption,
u.created_at,
(SELECT count(*) FROM "like" l WHERE l.upload_id = u.id) AS like_count,
(SELECT count(*) FROM comment c WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE;

View File

@@ -0,0 +1,44 @@
-- Exclude banned users' likes and comments from the feed's scalar counts (H11).
--
-- `v_feed` already excludes banned UPLOADERS (`usr.is_banned = FALSE` on the join), but the two
-- correlated subqueries added by migration 024 counted every like and every non-deleted comment
-- regardless of who wrote it. So after a ban:
--
-- * the banned guest's own photos disappear from the feed (correct), but
-- * their likes still inflate the counter on everyone else's photos, and
-- * their comments still contribute to `comment_count` — and, until the change to
-- `Comment::list_for_upload` that ships with this migration, were still RENDERED in the
-- lightbox on the most-viewed photo of the evening.
--
-- The host's mental model of "ban" is "this person's contributions are gone". Photos honoured it;
-- likes and comments did not. Migration 021 already applied exactly this reasoning to hashtag
-- counts, and the export query filters `is_banned` too — this brings the last read path in line.
--
-- Derived at read time, so `unban_user` restores the counts with no extra work, exactly as it
-- already restores the photos.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.display_path,
u.mime_type,
u.caption,
u.created_at,
(SELECT count(*) FROM "like" l
JOIN "user" lu ON lu.id = l.user_id
WHERE l.upload_id = u.id AND NOT lu.is_banned) AS like_count,
(SELECT count(*) FROM comment c
JOIN "user" cu ON cu.id = c.user_id
WHERE c.upload_id = u.id AND c.deleted_at IS NULL AND NOT cu.is_banned) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE;

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS host_action_audit_event_created_idx;
DROP TABLE IF EXISTS host_action_audit;

View File

@@ -0,0 +1,41 @@
-- An audit trail for privileged actions (H17).
--
-- What existed before: nothing. `grep -i audit` across `handlers/host.rs` and `handlers/admin.rs`
-- returned no hits. Individual actions logged a `tracing::info!` line, but config changes, gallery
-- release and event lock/unlock logged nothing at all — and the "audit trail" as a whole was a
-- 30 MB rotating Docker log that the runbook's own retention settings will discard.
--
-- Why it matters here specifically: a host is a promoted GUEST, and `reset_pin` overwrites another
-- guest's credential and returns the new PIN in the clear. So a host can take over any guest's
-- account and post as them, and nothing in the record showed it happened (only /recover FAILURES
-- were logged). At a wedding the people involved know each other; the point is not catching a
-- villain, it is being able to answer "what happened to my photo?" the next morning without
-- guessing.
--
-- Deliberately append-only in practice: no UPDATE or DELETE path is written for it anywhere. Small
-- (a few hundred rows for a real event), so no partitioning or retention job.
CREATE TABLE host_action_audit (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
-- The privileged caller. NOT a FK with ON DELETE CASCADE: the record must survive the actor's
-- account being removed, which is exactly when it is most likely to be wanted.
actor_id UUID,
actor_name TEXT,
actor_role TEXT NOT NULL,
-- Short stable slug: 'ban_user', 'unban_user', 'reset_pin', 'delete_upload',
-- 'delete_comment', 'release_gallery', 'lock_uploads', 'unlock_uploads', 'patch_config',
-- 'promote_user', 'demote_user', 'delete_user'.
action TEXT NOT NULL,
-- The guest or object acted upon, when there is one.
target_id UUID,
target_name TEXT,
-- Free-form context: the config key and its old/new value, the caption that was removed, etc.
-- Never credentials — a reset PIN must not be recoverable from this table.
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- The only query shape this needs: "what happened at this event, newest first".
CREATE INDEX host_action_audit_event_created_idx
ON host_action_audit (event_id, created_at DESC);

View File

@@ -0,0 +1,3 @@
-- Revert the join ceiling to 60/min for installs still on the raised default
-- (preserves any explicit admin override at another value).
UPDATE config SET value = '60' WHERE key = 'join_ip_rate_per_min' AND value = '300';

View File

@@ -0,0 +1,15 @@
-- Raise the per-IP join ceiling from 60/min to 300/min.
--
-- Rationale: every guest at the venue arrives through one NAT'd public address,
-- so `join_ip:{ip}` is not a per-guest limit at all — it is a ceiling on the
-- whole party. The QR code goes up once and is scanned in a burst: at 60/min,
-- guest 61 onwards is refused on the join screen, which is the one screen with
-- no auto-retry, and every manual retry spends another slot.
--
-- The code default was already raised to 300 (auth/handlers.rs), but a default
-- only applies when the key is ABSENT, and migration 017 seeds it. Without this
-- UPDATE the raise is dead code on every existing install.
--
-- Only bump installs still on the seeded default; an admin who deliberately set
-- a different value keeps it (migration 017 seeded 60; this UPDATE is scoped to '60').
UPDATE config SET value = '300' WHERE key = 'join_ip_rate_per_min' AND value = '60';

View File

@@ -0,0 +1,10 @@
-- Restore migration 026's predicate, then drop the column it depended on.
--
-- Note the same pairing caveat 026's own down carries: this is only valid alongside a code
-- rollback. `Upload::create` sends an ON CONFLICT predicate that must match the live index, so
-- running this down against the current binary makes every keyed upload a runtime 500.
DROP INDEX IF EXISTS upload_client_upload_id_key;
CREATE UNIQUE INDEX upload_client_upload_id_key ON upload (client_upload_id)
WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL;
ALTER TABLE upload DROP COLUMN IF EXISTS taken_down_by_host;

View File

@@ -0,0 +1,28 @@
-- Keep a client upload key CLAIMED when the deletion was a host takedown.
--
-- Migration 026 narrowed `upload_client_upload_id_key` to live rows so that a guest who deletes
-- their own photo and whose queue later retries gets a fresh upload instead of a permanent 409.
-- That rationale reasoned only about the GUEST deleting. `deleted_at` is also set by
-- `host_delete_upload`, and for that case the same rule undoes a moderation decision:
--
-- 1. Guest uploads. The row commits and the photo appears in the feed, but the response is lost
-- on the way back (the flaky-wifi case this whole feature exists for), so the phone keeps the
-- queue item.
-- 2. The host sees the photo and takes it down. `deleted_at` is stamped, the keepsake epoch is
-- bumped, and the archive is rebuilt without it.
-- 3. Ten minutes later the phone reconnects and retries. The key is no longer claimed, the
-- INSERT succeeds, and the photo is BACK — in the feed, 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 undone.
--
-- So the key stays claimed for a host takedown and is released only for a guest's own delete. The
-- retry then resolves to the duplicate path and is refused, which is the correct answer: the photo
-- was deliberately removed, and re-sending the bytes must not bring it back.
ALTER TABLE upload ADD COLUMN taken_down_by_host BOOLEAN NOT NULL DEFAULT FALSE;
-- KEEP THE PREDICATE IN LOCKSTEP WITH `Upload::create`'s ON CONFLICT clause (models/upload.rs).
-- A drift between the two is not a compile error here — queries are checked at runtime — it is a
-- 500 on every upload that carries a key, i.e. on exactly the retries this index exists to serve.
DROP INDEX IF EXISTS upload_client_upload_id_key;
CREATE UNIQUE INDEX upload_client_upload_id_key ON upload (client_upload_id)
WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host);

View File

@@ -28,6 +28,37 @@ use crate::state::AppState;
/// feed's byline, and that "Admin" stays available for the admin row.
const RESERVED_DISPLAY_NAMES: &[&str] = &["admin", "administrator", "host", "eventsnap"];
/// How long a `client_join_id` stays replayable after the account it created.
///
/// The key exists to survive a lost response, which a client retries within seconds — a guest
/// walking back into signal and reopening the app is the slow end of it. Beyond that the key is
/// only a liability: it is accepted pre-auth and answers with a session, so an unbounded lifetime
/// makes every abandoned join attempt a permanent credential sitting in `localStorage`.
const JOIN_REPLAY_WINDOW_MINUTES: i64 = 30;
/// May a presented `client_join_id` replay the account it created?
///
/// Extracted so the two guards are unit-testable without a database. Both must hold — see the
/// call site in `join` for why either alone is insufficient.
///
/// Case-insensitive on the name to match the `LOWER(display_name)` uniqueness index: a guest
/// retyping "anna" for "Anna" is the same person resuming, not a different one.
fn join_key_replayable(
stored_name: &str,
submitted_name: &str,
created_at: chrono::DateTime<Utc>,
now: chrono::DateTime<Utc>,
) -> bool {
if stored_name.to_lowercase() != submitted_name.to_lowercase() {
return false;
}
let age = now.signed_duration_since(created_at);
// A negative age means the row is stamped in the future — clock skew between the app and
// Postgres. Treat it as in-window rather than replayable-forever: the comparison below is
// `<=`, so a negative duration passes, which is the same answer as "just created".
age <= chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES)
}
fn is_reserved_display_name(name: &str) -> bool {
let name = name.trim().to_lowercase();
RESERVED_DISPLAY_NAMES.contains(&name.as_str())
@@ -74,6 +105,11 @@ fn validate_display_name(raw: &str) -> Result<&str, AppError> {
#[derive(Deserialize)]
pub struct JoinRequest {
pub display_name: String,
/// Stable per-attempt key so a retry after a lost response resumes the same join instead of
/// 409ing on a name the caller itself owns. Optional: older clients simply behave as before.
/// See migration 027 and the retry branch in `join`.
#[serde(default)]
pub client_join_id: Option<Uuid>,
}
#[derive(Serialize)]
@@ -101,7 +137,18 @@ pub async fn join(
// Cheap enough to run before validation, which keeps a flood of malformed bodies from
// being free.
if rate_limits_on && join_rate_on {
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 60).await;
// 300/min, not 60. The comment above has the right principle and the old default did not
// follow it: a 100-guest wedding does not trickle in, it arrives at the door together when
// the QR code goes up, and every one of those joins is the SAME public IP. At 60/min guests
// 61-100 got a 429 on the one screen that has no auto-retry — the join page — so the
// remedy was "ask a stranger why the app says no and tap again", at exactly the moment the
// host is busiest. Each shed request also costs another slot when they do retry.
//
// This is not the anti-spam control (that is the per-name bucket below, which a flood
// cannot evade) nor the CPU bound (that is BCRYPT_PERMITS, which caps concurrent hashing
// at 2 regardless of how many requests arrive). It only bounds raw volume, so it can
// afford to sit well above the real arrival peak.
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 300).await;
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("join_ip:{ip}"),
ip_ceiling,
@@ -147,6 +194,58 @@ pub async fn join(
)
.await?;
// IDEMPOTENT RETRY — must come before the name-taken check, because on a retry the name is
// taken by our OWN row and the 409 below is precisely the bug (H16).
//
// The account exists and its PIN hash is committed, but the plaintext went out in a response
// that never arrived, so nobody alive knows it. We cannot replay it (only the bcrypt is
// stored, deliberately), so instead we ROTATE it: mint a new PIN, overwrite the hash, issue a
// fresh session, and answer as if this were the original reply.
//
// Rotating is safe here in a way it would not be elsewhere: the previous PIN was never
// displayed to anyone, so there is no device holding it and nothing to invalidate. And it
// beats the alternative of persisting plaintext PINs so they can be replayed — that would put
// a recoverable credential in the database for every guest, to fix a lost packet.
// Look the key up once, then decide whether this caller is entitled to replay it.
let prior = match body.client_join_id {
Some(k) => User::find_by_client_join_id(&state.pool, event.id, k).await?,
None => None,
};
// A key replays ONLY for the name that minted it, and only briefly. Without both guards the
// key is a bearer credential: it is accepted pre-auth, the submitted name was ignored, and the
// reply carries a session with the stored row's ROLE. So anyone holding one guest's key could
// send any name at all and receive that guest's account — a host's, if they had been promoted
// — while the rotation below locked the rightful owner out of their own PIN.
//
// * Name binding also fixes the ordinary, non-malicious version, which a party guarantees:
// a guest's join fails, they hand the venue tablet to the next person, and that person
// joins under their own name — landing in the first guest's account, posting as them.
// * The window bounds the credential's life. A lost response is retried in seconds, not
// hours; the client also clears the key on success, so the only keys that survive at all
// are genuinely-failed attempts. After the window the key is spent and a normal join runs.
let replay = prior.as_ref().filter(|existing| {
join_key_replayable(
&existing.display_name,
display_name,
existing.created_at,
Utc::now(),
)
});
// A key whose row exists but is not ours to replay is spent — it must not be carried into the
// INSERT below, or it would collide with that row on `user_client_join_id_key` and report a
// name clash the guest cannot act on.
let effective_join_key = if prior.is_some() {
None
} else {
body.client_join_id
};
if let Some(existing) = replay {
return replay_join(&state, event.id, existing).await;
}
// Reject if a user with this name (case-insensitive) already exists
if User::name_taken(&state.pool, event.id, display_name).await? {
return Err(AppError::Conflict(format!(
@@ -162,9 +261,42 @@ pub async fn join(
// The pre-check above is racy: two simultaneous joins with the same name can both
// pass it, and the DB's unique index then rejects the loser. Map that unique
// violation to the same clean 409 the pre-check returns, not a generic 500.
let user = match User::create(&state.pool, event.id, display_name, &pin_hash).await {
let user = match User::create(
&state.pool,
event.id,
display_name,
&pin_hash,
effective_join_key,
)
.await
{
Ok(u) => u,
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
// Either the display name or `client_join_id` collided. Both mean "somebody already
// holds this", and for the join key that somebody is a concurrent retry of this very
// request — so re-check the key and replay it rather than reporting a name clash the
// guest cannot act on.
// REPLAY it, don't 409. The loser of this race is holding proof that it is the same
// attempt as the winner — the same `client_join_id`, minted by the same phone — and
// the winner's row is a few milliseconds old under the same name, so the ordinary
// name+window replay guard passes. Returning a Conflict instead sent the guest to the
// join page's `code === 'conflict'` branch, which renders the NAME-TAKEN screen with a
// PIN entry form — for a PIN that was never displayed to anybody. That is precisely
// the dead end migration 027 exists to close, re-entered through the racing path, and
// it is easy to hit: the guest's instinctive response to a hung request is to reload
// and tap Join again.
if let Some(join_key) = effective_join_key
&& let Some(winner) =
User::find_by_client_join_id(&state.pool, event.id, join_key).await?
&& join_key_replayable(
&winner.display_name,
display_name,
winner.created_at,
Utc::now(),
)
{
return replay_join(&state, event.id, &winner).await;
}
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
@@ -197,6 +329,61 @@ pub async fn join(
))
}
/// Answer a join retry with the row its `client_join_id` already minted.
///
/// Rotates the PIN and issues a fresh session. Rotating is safe precisely here: the previous PIN
/// was never displayed to anyone (that is what "the response was lost" means), so no device holds
/// it and there is nothing to invalidate. The alternative — persisting plaintext PINs so they can
/// be replayed — would put a recoverable credential in the database for every guest, to fix a lost
/// packet.
///
/// Callers must have established that this caller is ENTITLED to the replay (`join_key_replayable`:
/// same display name, inside the window). Both call sites do; the key is accepted pre-auth, so
/// without that check it would be a bearer credential for someone else's account.
async fn replay_join(
state: &AppState,
event_id: Uuid,
existing: &User,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = hash_password(pin.clone(), 12).await?;
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $2, failed_pin_attempts = 0, pin_locked_until = NULL
WHERE id = $1",
)
.bind(existing.id)
.bind(&pin_hash)
.execute(&state.pool)
.await?;
let token = jwt::create_token(
existing.id,
event_id,
existing.role.clone(),
&state.config.jwt_secret,
state.config.session_expiry_days,
)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, existing.id, &token_hash, expires_at).await?;
tracing::info!(
user_id = %existing.id,
"join retry matched client_join_id; rotated the PIN and re-issued a session"
);
Ok((
StatusCode::CREATED,
Json(JoinResponse {
jwt: token,
pin,
user_id: existing.id,
is_new: true,
}),
))
}
/// Default for `recover_name_rate_per_15min` — wrong PINs allowed per (IP, name) per 15 min.
/// Mirrors migration 023; kept here so the invariant below can be asserted in a test.
const RECOVER_NAME_CEILING_DEFAULT: usize = 4;
@@ -211,6 +398,53 @@ const RECOVER_NAME_CEILING_DEFAULT: usize = 4;
/// raising this key restored the exact DoS the tier ordering exists to prevent, silently.
pub const RECOVER_NAME_CEILING_MAX: usize = (PIN_LOCK_THRESHOLD as usize) / 3;
/// Wrong PINs one IP may produce across ALL names before it is shut out for 15 minutes.
///
/// This is the tier that was missing, and its absence is what made 4-digit PINs practically
/// brute-forceable into a HOST account (H2). Hosts are promoted guests, so a host's entire
/// credential is a `{:04}` PIN, and `/uploaders` hands any joined guest the authoritative list of
/// names to try. The existing tiers are per-`(IP, name)` (4 per 15 min) and a per-IP REQUEST ceiling
/// (30/min) — neither of which bounds guesses *spread across names*:
///
/// * 4 guesses × 100 names = ~400 wrong PINs per 15 minutes from one IP
/// * no single account ever reaches the 12-failure lock, so nobody is locked out to notice
/// * nothing was logged beyond one `warn` per attempt, which nothing aggregates or alerts on
///
/// That is ~38,000 guesses/day against 100 accounts at 1/10,000 each — a coin-flip inside a week,
/// and far better odds than that against any particular host over an evening.
///
/// Counting FAILURES rather than requests is what makes this safe to set low: a guest recovering
/// their own device types their PIN correctly and is never charged, so a whole venue behind one NAT
/// is unaffected. 30 wrong PINs from a single IP in 15 minutes is already far beyond fat-fingering.
const RECOVER_IP_FAILURE_CEILING: usize = 30;
const RECOVER_IP_FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60);
/// Window for the per-(IP, name) failure budget. Matches the per-account lockout duration, so a
/// guest who trips both waits the same fifteen minutes rather than two stacked penalties.
const RECOVER_NAME_WINDOW: Duration = Duration::from_secs(15 * 60);
/// Charge one failed PIN attempt against the per-IP budget, and report whether it is now spent.
///
/// Deliberately charged on the way OUT of a failure rather than checked on the way in, so that the
/// only thing that consumes budget is a genuinely wrong PIN.
fn charge_recover_failure(state: &AppState, ip: &str) {
if let Err(retry_after) = state.rate_limiter.check_with_retry(
format!("recover_fail:{ip}"),
RECOVER_IP_FAILURE_CEILING,
RECOVER_IP_FAILURE_WINDOW,
) {
// Loud, and with the numbers an alert can key on. The old code logged one line per
// attempt at the same level as an ordinary typo, so a horizontal sweep looked exactly
// like a hundred guests mistyping.
tracing::error!(
ip = %ip,
retry_after_secs = retry_after,
ceiling = RECOVER_IP_FAILURE_CEILING,
"possible PIN brute force: one IP exhausted its failed-PIN budget across names"
);
}
}
/// Wrong PINs, from ALL sources, before the ACCOUNT itself is locked for 15 minutes.
///
/// This was 3, which sat BELOW the per-(IP, name) ceiling of 5 — and that ordering, not the
@@ -280,23 +514,84 @@ static BCRYPT_PERMITS: std::sync::LazyLock<tokio::sync::Semaphore> =
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
tokio::sync::Semaphore::new(cores.saturating_sub(1).max(1))
// `cores - 1` resolves to exactly ONE permit on the 2-vCPU box this runs on, which turned
// every join/recover/admin-login into a strict queue at ~200-250 ms each. `/recover` runs
// an unconditional verify even for an unknown name at 30/min/IP, so roughly eight source
// IPs saturated it indefinitely — and with an untimed `acquire()` an arriving guest's
// `/join` HUNG rather than getting a 503 they could retry.
//
// Floor of 2: bcrypt at cost 12 is CPU-bound but runs on the blocking pool, so two in
// flight on two cores still leaves the async runtime responsive, and it doubles arrival
// throughput during the one burst that matters (everyone scanning the QR at once).
tokio::sync::Semaphore::new(cores.saturating_sub(1).max(2))
});
async fn verify_password(candidate: String, hash: String) -> bool {
// `acquire()` only fails if the semaphore is closed, which never happens here.
let _permit = BCRYPT_PERMITS.acquire().await;
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
/// How long to wait for a bcrypt permit before shedding the request.
///
/// Generous relative to one hash (~200-250 ms) and far below any client timeout, so it only fires
/// when the queue is genuinely deep. Shedding with a `Retry-After` beats hanging: the client
/// already honours 503 + Retry-After on the load-shedding path, and a guest who is told to try
/// again in a moment is in a much better position than one staring at a spinner.
/// How long an auth request waits for a bcrypt permit before shedding.
///
/// Sized against the arrival burst, not against a comfortable latency. Two permits at cost 12 is
/// ~8 hashes/second, and tokio's semaphore is FIFO, so the request at queue position N waits
/// roughly N/8 seconds. At 5s the cliff was position ~40: the "everyone scans the QR as they walk
/// in" moment — the one case this endpoint exists for — put more than half of a 100-guest arrival
/// past the deadline and answered them with a 503. The join page has no auto-retry, so each one
/// became a guest standing in the doorway asking the host why the link is broken.
///
/// 15s drains a 100-guest burst (~12.5s) with margin, and still sits under the client's own 20s
/// abort (`TIMEOUT_MS` in `api.ts`), so a genuinely saturated server is still reported as a
/// retryable 503 rather than a hang. The shed remains for real overload; it just stops firing on
/// the ordinary case.
const BCRYPT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
/// The 503 both bcrypt paths shed with.
fn bcrypt_busy() -> AppError {
tracing::warn!("bcrypt queue saturated; shedding an auth request");
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
Some(5),
)
}
async fn verify_password(candidate: String, hash: String) -> Result<bool, AppError> {
verify_password_within(candidate, hash, BCRYPT_ACQUIRE_TIMEOUT).await
}
/// [`verify_password`] with an explicit ceiling on how long it will queue for a hash permit.
///
/// The short-timeout variant is what lets an over-budget caller still be *served* rather than
/// refused outright: the CPU bound is the semaphore, so shedding on a brief acquire timeout caps
/// the work just as hard as a rate bucket does — but it sheds whoever happens to arrive while the
/// permits are busy, instead of categorically refusing an IP that a flooder shares with the victim.
async fn verify_password_within(
candidate: String,
hash: String,
acquire_timeout: Duration,
) -> Result<bool, AppError> {
// Bounded wait — see BCRYPT_ACQUIRE_TIMEOUT. `acquire()` itself only fails if the semaphore is
// closed, which never happens here; the timeout is the case we care about.
let _permit = tokio::time::timeout(acquire_timeout, BCRYPT_PERMITS.acquire())
.await
.unwrap_or(false)
.map_err(|_| bcrypt_busy())?;
Ok(
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
.await
.unwrap_or(false),
)
}
/// Hash a secret on the blocking pool. Same reasoning as [`verify_password`] — and this one
/// runs on the busiest auth path there is, since every guest who joins gets a PIN hashed.
pub async fn hash_password(secret: String, cost: u32) -> Result<String, AppError> {
// Same global ceiling as `verify_password` — `/join` hashes a PIN for every guest, and 100
// guests scanning the QR at once is the arrival burst this box has to survive.
let _permit = BCRYPT_PERMITS.acquire().await;
// guests scanning the QR at once is the arrival burst this box has to survive. Same bounded
// wait, too: hanging on the arrival path is the worst place to hang.
let _permit = tokio::time::timeout(BCRYPT_ACQUIRE_TIMEOUT, BCRYPT_PERMITS.acquire())
.await
.map_err(|_| bcrypt_busy())?;
tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
@@ -341,30 +636,103 @@ pub async fn recover(
Some(retry_after_secs),
));
}
let name_ceiling = config::get_usize(
&state.config_cache,
"recover_name_rate_per_15min",
RECOVER_NAME_CEILING_DEFAULT,
)
.await
// CLAMPED, not merely defaulted — see RECOVER_NAME_CEILING_MAX. The config value is
// operator-settable and the invariant it has to respect is not expressible in
// `patch_config`'s numeric range, so it is enforced at the point of use.
.min(RECOVER_NAME_CEILING_MAX);
let name_key = display_name.to_lowercase();
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("recover:{ip}:{name_key}"),
name_ceiling,
Duration::from_secs(15 * 60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
Some(retry_after_secs),
));
}
}
// The per-(IP, name) tier. Same treatment as the cross-name tier below, and for the same
// reason: this bucket is keyed on an IP that behind the venue's NAT is the ENTIRE PARTY, so a
// gate that refuses the request outright refuses it for everyone who shares that name key.
//
// It used to be a `check_with_retry` sitting here, before the account was looked up — which
// means it charged EVERY request, including successful recoveries, and refused before any PIN
// was verified. Ceiling is clamped to RECOVER_NAME_CEILING_MAX (4). So four POSTs naming
// "Braut Sophie" with PIN 0000 locked Sophie out of her own recovery for fifteen minutes, WITH
// THE CORRECT PIN, from any phone on the venue wifi — and four more every fifteen minutes
// sustained it indefinitely, at a request rate far under every volume ceiling above. The
// benign version needs no attacker: the host mistypes their own 4-digit PIN four times.
//
// Now it counts FAILURES only, and a spent budget changes what a failure answers rather than
// refusing outright. A correct PIN always authenticates. Guessing is bounded exactly as before
// — wrong PINs are what spend the budget — with the per-account 3-strike lockout underneath it.
let name_key = display_name.to_lowercase();
let name_bucket = format!("recover_name_fail:{ip}:{name_key}");
let name_ceiling = if rate_limits_on && recover_rate_on {
Some(
config::get_usize(
&state.config_cache,
"recover_name_rate_per_15min",
RECOVER_NAME_CEILING_DEFAULT,
)
.await
// CLAMPED, not merely defaulted — see RECOVER_NAME_CEILING_MAX. The config value is
// operator-settable and the invariant it has to respect is not expressible in
// `patch_config`'s numeric range, so it is enforced at the point of use.
.min(RECOVER_NAME_CEILING_MAX),
)
} else {
None
};
let name_budget_spent: Option<u64> = name_ceiling.and_then(|max| {
state
.rate_limiter
.peek(&name_bucket, max, RECOVER_NAME_WINDOW)
.err()
});
// Charged on the way OUT of a failure, exactly like `charge_recover_failure`.
let charge_name_failure = || {
if let Some(max) = name_ceiling {
let _ =
state
.rate_limiter
.check_with_retry(name_bucket.clone(), max, RECOVER_NAME_WINDOW);
}
};
let name_refusal = |retry_after_secs: u64| {
AppError::TooManyRequests(
"Zu viele fehlgeschlagene Versuche für diesen Namen. Bitte warte 15 Minuten.".into(),
Some(retry_after_secs),
)
};
// The cross-name tier (H2). Read-only here — budget is spent only by an actual wrong PIN
// below — so a venue full of guests recovering their own devices never trips it, while a
// horizontal sweep across the public name list runs out after RECOVER_IP_FAILURE_CEILING.
//
// Placed before any bcrypt work so an exhausted IP also stops consuming the hash permits.
// A SPENT BUDGET NO LONGER REFUSES THE REQUEST OUTRIGHT — it only changes what a FAILURE
// answers. This gate used to `return` here, before the account was even looked up, and that
// handed any guest a venue-wide denial of service.
//
// The bucket is keyed on IP, and behind the venue's NAT that is one address for the entire
// party. Thirty POSTs with invented names — each one landing in the `users.is_empty()` branch
// below, which charged unconditionally — spent the shared budget for fifteen minutes, and ~2
// requests/minute sustained it indefinitely. Everyone at the party was then refused PIN
// recovery WITH THE CORRECT PIN. The host is the one who cannot absorb that: hosts are
// promoted guests whose only credential is a 4-digit PIN, so `/recover` is their only way back
// in after losing a session, and the constants here have no config key to turn off.
//
// So an exhausted budget is carried as a flag: a correct PIN still authenticates, while every
// wrong one answers 429 instead of 401. The guessing itself stays bounded where it always
// really was — the per-(IP,name) ceiling, and the per-account 3-strike lockout that no
// attacker on any IP can evade.
let ip_budget_spent: Option<u64> = if rate_limits_on && recover_rate_on {
state
.rate_limiter
.peek(
&format!("recover_fail:{ip}"),
RECOVER_IP_FAILURE_CEILING,
RECOVER_IP_FAILURE_WINDOW,
)
.err()
} else {
None
};
let sweep_refusal = |retry_after_secs: u64| {
AppError::TooManyRequests(
"Zu viele fehlgeschlagene Versuche von diesem Netzwerk. Bitte warte 15 Minuten.".into(),
Some(retry_after_secs),
)
};
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
@@ -378,6 +746,16 @@ pub async fn recover(
// timing. Display names are already public on the feed, but this still closes
// the /recover enumeration + timing oracle.
let _ = verify_password(body.pin.clone(), dummy_pin_hash().to_string()).await;
// Charged here too, or the cheapest sweep (guessing names that don't exist) would be free
// — and the whole point of the tier is that guessing costs the guesser something.
charge_recover_failure(&state, &ip);
charge_name_failure();
if let Some(retry_after_secs) = name_budget_spent {
return Err(name_refusal(retry_after_secs));
}
if let Some(retry_after_secs) = ip_budget_spent {
return Err(sweep_refusal(retry_after_secs));
}
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
}
@@ -401,7 +779,7 @@ pub async fn recover(
User::reset_pin_attempts(&state.pool, user.id).await?;
}
let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await;
let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await?;
if pin_matches {
// Reset failed attempts on success
@@ -426,7 +804,10 @@ pub async fn recover(
}));
}
// Wrong PIN — increment failure count
// Wrong PIN — charge both the per-account counter and this IP's cross-name budget. The
// account counter alone never fires against a sweep that only spends 4 guesses per name.
charge_recover_failure(&state, &ip);
charge_name_failure();
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
tracing::warn!(
user_id = %user.id,
@@ -447,6 +828,12 @@ pub async fn recover(
}
}
if let Some(retry_after_secs) = name_budget_spent {
return Err(name_refusal(retry_after_secs));
}
if let Some(retry_after_secs) = ip_budget_spent {
return Err(sweep_refusal(retry_after_secs));
}
Err(AppError::Unauthorized("PIN ist falsch.".into()))
}
@@ -464,18 +851,32 @@ pub struct AdminLoginResponse {
pub display_name: String,
}
/// Requests per minute per IP that may reach `verify_password` at all.
/// Requests per minute per IP after which admin login is served in DEGRADED mode.
///
/// Not a security control — the failure bucket below is. It bounds how deep a queue can form on
/// `BCRYPT_PERMITS`, which is what actually caps the CPU cost.
///
/// Still far above anything a person typing a password produces, but note the honest limitation:
/// unlike the failure bucket, this ceiling CAN refuse a correct password, and on venue NAT every
/// guest shares the operator's IP. It is a smaller number than it first was for exactly that
/// reason — the earlier 120 was chosen when this was the only bound on bcrypt, which made it both
/// too weak to cap CPU and too coarse to be safe for the operator.
/// This used to REFUSE above the ceiling, and that made it a denial of service against the one
/// person who cannot route around it. `/admin/login` is a public linkable page, every guest at the
/// venue shares the operator's IP behind NAT, and the check ran before `verify_password` — so one
/// phone posting twice a minute kept the bucket full and the operator, on that same IP, was
/// refused WITH THE CORRECT PASSWORD. The escape hatch was circular: `admin_login_rate_enabled`
/// is only flippable through `PATCH /admin/config`, which needs the session being refused. That
/// locked out moderation, gallery release and every config key — including the ones that would
/// undo it.
///
/// So exceeding it no longer refuses; it shortens the hash-permit wait to
/// [`ADMIN_LOGIN_DEGRADED_ACQUIRE`]. The CPU bound is unchanged, because the CPU bound was always
/// the semaphore and never this bucket: a flood now sheds itself on a busy semaphore, while the
/// operator's single well-timed request still gets a permit and a truthful answer.
const ADMIN_LOGIN_CPU_CEILING: usize = 30;
/// How long an over-budget admin login will queue for a hash permit before shedding with 503.
///
/// Short enough that a flood cannot build a queue that starves `/join`, long enough that a
/// request arriving while two permits are mid-hash (~250 ms each) still waits its turn.
const ADMIN_LOGIN_DEGRADED_ACQUIRE: Duration = Duration::from_secs(1);
pub async fn admin_login(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
@@ -503,28 +904,37 @@ pub async fn admin_login(
let admin_rate_on =
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
// Part 1: a deliberately GENEROUS ceiling whose only job is to bound bcrypt CPU. Cost-12
// verification is ~250 ms of a core, so an unbounded endpoint is a CPU exhaustion vector
// regardless of whether anyone guesses right. No human typing a password reaches this.
if rate_limits_on
// Part 1: a ceiling whose only job is to bound how deep a queue can form on the hash permits.
// Cost-12 verification is ~250 ms of a core, so an endpoint with no bound at all lets a flood
// build a backlog that starves `/join`. Exceeding it DEGRADES rather than refuses — see
// ADMIN_LOGIN_CPU_CEILING for why refusing here was a DoS against the operator.
let degraded = rate_limits_on
&& admin_rate_on
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("admin_login_cpu:{ip}"),
ADMIN_LOGIN_CPU_CEILING,
Duration::from_secs(60),
)
{
return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
Some(retry_after_secs),
));
&& state
.rate_limiter
.check_with_retry(
format!("admin_login_cpu:{ip}"),
ADMIN_LOGIN_CPU_CEILING,
Duration::from_secs(60),
)
.is_err();
if degraded {
tracing::warn!(
ip = %ip,
"admin login over its per-IP volume ceiling; serving with a shortened hash-permit wait"
);
}
let valid = verify_password(
let valid = verify_password_within(
body.password.clone(),
state.config.admin_password_hash.clone(),
if degraded {
ADMIN_LOGIN_DEGRADED_ACQUIRE
} else {
BCRYPT_ACQUIRE_TIMEOUT
},
)
.await;
.await?;
if !valid {
// Part 2: the tight bucket, charged ONLY on a wrong password. A correct password is
@@ -753,11 +1163,23 @@ mod tests {
#[test]
fn a_display_name_may_not_carry_control_characters() {
for bad in ["Anna\nERROR forged", "Anna\rX", "Anna\u{0}X", "A\u{7}B"] {
assert!(validate_display_name(bad).is_err(), "{bad:?} must be rejected");
assert!(
validate_display_name(bad).is_err(),
"{bad:?} must be rejected"
);
}
// Real guests have accents, emoji and non-Latin names — never reject those.
for good in ["Anna", "Zo\u{eb}", "Jos\u{e9}", "\u{5c71}\u{7530}", "Anna \u{1f389}"] {
assert!(validate_display_name(good).is_ok(), "{good:?} must be allowed");
for good in [
"Anna",
"Zo\u{eb}",
"Jos\u{e9}",
"\u{5c71}\u{7530}",
"Anna \u{1f389}",
] {
assert!(
validate_display_name(good).is_ok(),
"{good:?} must be allowed"
);
}
}
@@ -815,4 +1237,62 @@ mod tests {
assert!(validate_display_name(" ").is_err());
assert!(validate_display_name("bad\0name").is_err());
}
/// The join key is presented pre-auth and the reply carries a SESSION for the stored row —
/// so a key that replays for a name it did not create is an account-takeover primitive, not
/// a convenience bug. These pin both guards.
#[test]
fn a_join_key_replays_only_for_the_name_that_created_it() {
let created = Utc::now();
// The legitimate case: same guest, same name, retrying a lost response.
assert!(join_key_replayable("Anna", "Anna", created, created));
// Case-insensitive, matching the LOWER(display_name) uniqueness index.
assert!(join_key_replayable("Anna", "anna", created, created));
assert!(join_key_replayable("Zoë", "zoë", created, created));
// The takeover: a held key presented with any other name must NOT resolve to the
// stored account, whatever role that account happens to carry.
assert!(!join_key_replayable(
"Braut Sophie",
"Zufaelliger Fremder",
created,
created
));
// The shared-device case a party guarantees: the next person types their own name.
assert!(!join_key_replayable("Anna", "Bernd", created, created));
// Not a prefix or substring match either.
assert!(!join_key_replayable("Anna", "Anna B.", created, created));
}
#[test]
fn a_join_key_stops_replaying_once_its_window_closes() {
let created = Utc::now();
let inside = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES - 1);
let edge = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES);
let outside = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES + 1);
assert!(join_key_replayable("Anna", "Anna", created, inside));
// Inclusive at the boundary — an exactly-on-time retry is still the guest's own.
assert!(join_key_replayable("Anna", "Anna", created, edge));
// Past it the key is spent, so an abandoned attempt in localStorage stops being a
// permanent unauthenticated credential.
assert!(!join_key_replayable("Anna", "Anna", created, outside));
assert!(!join_key_replayable(
"Anna",
"Anna",
created,
created + chrono::Duration::days(3)
));
}
#[test]
fn a_clock_skewed_row_is_treated_as_fresh_not_immortal() {
// Postgres stamps `created_at`; if its clock is ahead of ours the age is negative. That
// must read as "just created" (replayable), never as an unbounded window.
let now = Utc::now();
let future = now + chrono::Duration::minutes(5);
assert!(join_key_replayable("Anna", "Anna", future, now));
// And the name guard still applies regardless of skew.
assert!(!join_key_replayable("Anna", "Bernd", future, now));
}
}

View File

@@ -150,6 +150,49 @@ pub struct AppConfig {
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
/// Upper bound on `SESSION_EXPIRY_DAYS`. ~10 years — absurdly generous for a one-evening event,
/// and low enough that `chrono::Duration::days` cannot overflow downstream.
const MAX_SESSION_EXPIRY_DAYS: i64 = 3650;
/// Parse and RANGE-CHECK `SESSION_EXPIRY_DAYS`. Refusing to boot is the whole point.
///
/// This was `.parse().context(...)` with no bounds, and both ends of the range were live faults
/// that a green health check hid completely (H7):
///
/// * A huge value made `chrono::Duration::days` PANIC on every `/join`, `/recover` and
/// `/admin/login`. There is no `CatchPanicLayer`, so the client got a connection reset with no
/// HTTP response at all — the app was up, healthy, and unable to authenticate anybody.
/// * Zero or negative created every session already-expired: `/join` returns 201 with a token,
/// and then every authenticated request 401s. A guest joins successfully and the app
/// immediately behaves as though they never did.
///
/// Both booted green because `/health` only probes the database. A bad value must stop the
/// container instead, where the operator sees it.
fn parse_session_expiry_days(raw: Option<&str>) -> Result<i64> {
let Some(raw) = raw else { return Ok(30) };
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(30);
}
let days: i64 = trimmed
.parse()
.with_context(|| format!("SESSION_EXPIRY_DAYS must be a whole number (got {trimmed:?})"))?;
if days < 1 {
return Err(anyhow!(
"SESSION_EXPIRY_DAYS must be at least 1 (got {days}). Zero or negative makes every \
session expire the moment it is created: /join succeeds and every request after it \
returns 401."
));
}
if days > MAX_SESSION_EXPIRY_DAYS {
return Err(anyhow!(
"SESSION_EXPIRY_DAYS must be at most {MAX_SESSION_EXPIRY_DAYS} (got {days}). Larger \
values overflow the token-expiry arithmetic and panic on every auth request."
));
}
Ok(days)
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
@@ -164,10 +207,9 @@ impl AppConfig {
Ok(Self {
database_url,
jwt_secret,
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("SESSION_EXPIRY_DAYS must be a number")?,
session_expiry_days: parse_session_expiry_days(
std::env::var("SESSION_EXPIRY_DAYS").ok().as_deref(),
)?,
admin_password_hash,
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,

View File

@@ -2,7 +2,11 @@ use anyhow::{Context, Result};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
/// Keep in step with `.env.example` and the `db` sizing comment in `docker-compose.yml`.
/// These three drifted apart once (code 10 / `.env.example` 15 / runbook 30) and the runbook
/// presented its number as authoritative, so the contradiction was invisible at deploy time.
/// 15 is sized to 2 vCPU and the 1G `db` memory limit — raise it only alongside both.
const DEFAULT_MAX_CONNECTIONS: u32 = 15;
/// SQLSTATE for `invalid_password`.
const PG_INVALID_PASSWORD: &str = "28P01";
@@ -47,10 +51,23 @@ fn explain_auth_failure(err: &sqlx::Error) {
}
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
// A malformed value must not silently become the default: an operator who typed
// `DATABASE_MAX_CONNECTIONS=3O` (letter O) would otherwise get 15 with no indication,
// and would keep tuning a knob that never took effect.
let max_connections = match std::env::var("DATABASE_MAX_CONNECTIONS") {
Err(_) => DEFAULT_MAX_CONNECTIONS,
Ok(raw) => match raw.trim().parse::<u32>() {
Ok(0) => {
anyhow::bail!("DATABASE_MAX_CONNECTIONS must be at least 1 (got 0)");
}
Ok(n) => n,
Err(e) => {
anyhow::bail!(
"DATABASE_MAX_CONNECTIONS must be a positive integer (got {raw:?}): {e}"
);
}
},
};
let pool = match PgPoolOptions::new()
.max_connections(max_connections)
@@ -89,10 +106,27 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
}
};
// Migrations run on their OWN connection, deliberately NOT from the pool.
//
// `after_connect` above puts `lock_timeout = 5s` on every pooled connection, and the migrator
// would inherit it. Migrations that take ACCESS EXCLUSIVE (026's index swap, 027's ADD COLUMN)
// then turn a short WAIT into a hard FAILURE: anything holding ACCESS SHARE on `upload` or
// `"user"` for more than five seconds — the hourly `pg_dump` the runbook installs in §10.2, or
// an operator's open `psql` transaction — aborts the migration, `create_pool` returns an
// error, `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 exactly what makes it a confusing
// intermittent outage rather than an obvious one.
//
// `statement_timeout` is left off here too: a migration on a real table can legitimately run
// longer than the 15s a request is allowed.
let mut migrator_conn = <sqlx::PgConnection as sqlx::Connection>::connect(database_url)
.await
.context("failed to open a connection for migrations")?;
sqlx::migrate!()
.run(&pool)
.run(&mut migrator_conn)
.await
.context("failed to run database migrations")?;
let _ = sqlx::Connection::close(migrator_conn).await;
tracing::info!(max_connections, "database connected and migrations applied");
Ok(pool)

View File

@@ -11,6 +11,27 @@ pub enum AppError {
/// (banned user, quota): the queued blob is kept and retried if the host reopens,
/// instead of being purged like a genuinely-terminal rejection.
UploadsLocked(String),
/// The gallery has been RELEASED — the keepsake was snapshotted, so a late upload could
/// never appear in it. Mechanically this is still reversible (a host reopen clears
/// `export_released_at` and bumps the epoch), which is why the blob must still be kept.
///
/// Distinct from `UploadsLocked` because the two differ in *expectation*, and the client's
/// retry policy has to differ with them. A closed event is a pause the host means to undo;
/// a released gallery is the end of the event, and nobody reopens it. Under one shared code
/// the queue kept auto-retrying a released event forever — re-streaming a multi-megabyte
/// photo over cellular on every budget refill, for a request whose answer will not change,
/// while telling the guest to tap a camera button that 403s. `gallery_released` lets the
/// client park the item visibly and wait for an actual `event-opened` instead of guessing.
GalleryReleased(String),
/// The uploader is banned. A 403 like `Forbidden`, but tagged `user_banned` so the client
/// keeps the queued blob instead of purging it.
///
/// A ban is reversible — `unban_user` exists, and the host's own confirm copy promises the
/// photos come back — but the client classified the generic `forbidden` code as permanent,
/// deleted the blob from IndexedDB, and moved the row to `blocked`, which has no retry
/// button. So an unban could restore everything except the photos that were in flight when
/// the ban landed, and a ban issued by mistake destroyed them with no way back.
UserBanned(String),
NotFound(String),
Conflict(String),
/// Second field: optional retry-after seconds to include in the response.
@@ -35,6 +56,8 @@ impl AppError {
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
Self::GalleryReleased(_) => (StatusCode::FORBIDDEN, "gallery_released"),
Self::UserBanned(_) => (StatusCode::FORBIDDEN, "user_banned"),
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
@@ -52,6 +75,8 @@ impl AppError {
| Self::Unauthorized(msg)
| Self::Forbidden(msg)
| Self::UploadsLocked(msg)
| Self::GalleryReleased(msg)
| Self::UserBanned(msg)
| Self::NotFound(msg)
| Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
@@ -190,9 +215,8 @@ mod tests {
AppError::ServiceUnavailable("busy".into(), Some(3)),
] {
let expected = match &err {
AppError::TooManyRequests(_, Some(s)) | AppError::ServiceUnavailable(_, Some(s)) => {
s.to_string()
}
AppError::TooManyRequests(_, Some(s))
| AppError::ServiceUnavailable(_, Some(s)) => s.to_string(),
_ => unreachable!(),
};
let resp = err.into_response();

View File

@@ -105,7 +105,7 @@ pub struct PatchConfigRequest(pub HashMap<String, String>);
pub async fn patch_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
RequireAdmin(auth): RequireAdmin,
Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
@@ -289,6 +289,23 @@ pub async fn patch_config(
// the TTL is only a backstop and must not be relied on for correctness.
state.config_cache.invalidate();
// Config changes were logged NOWHERE. They are the actions most likely to be blamed the
// morning after ("why did uploads stop?") and the hardest to reconstruct, because the value
// that caused the problem has since been changed back. Record the keys and their new values;
// these are operational settings, not credentials, so the payload is safe to keep.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"patch_config",
None,
None,
serde_json::to_value(&body).ok(),
)
.await;
// Notify all clients that a publicly-readable config value changed so their stores
// (e.g. the privacy note in My Account) refresh without a manual reload.
if privacy_note_changed || theme_changed {
@@ -347,12 +364,18 @@ pub struct DownloadQuery {
/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
/// carry an `Authorization` header, so the client exchanges its Bearer token for
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
/// single-use, 30s-TTL store as the SSE stream.
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Uses the same store as the SSE
/// stream, but NOT the same lifetime: a download ticket lives `DOWNLOAD_TTL` (6 h) and is
/// redeemable up to `MAX_DOWNLOAD_REDEMPTIONS` times, because a multi-GB transfer over venue wifi
/// has to survive being resumed with `Range`.
#[derive(serde::Deserialize)]
pub struct ExportTicketQuery {
/// Which archive the ticket is for — `zip` or `html`. Optional so an older client that
/// doesn't send it keeps working; it simply skips the pre-check it doesn't know to ask for.
/// Which archive the ticket is for — `zip` or `html`.
///
/// REQUIRED. It used to be optional "so an older client keeps working", but the ticket is now
/// bound to the archive it was minted for (see `TicketKind::Download`), and a ticket with no
/// archive would either have to be valid for both — the abuse this closes — or be issued for a
/// guess that 401s at the other endpoint. Every shipped client sends it.
#[serde(default)]
pub kind: Option<String>,
}
@@ -374,8 +397,11 @@ pub async fn export_ticket(
// forever, with no explanation, on the one screen that is the emotional payoff of the app.
// Minting is a normal `fetch`, so a 429 here reaches the user as a German message.
//
// Moving it does not weaken the limit: tickets are single-use with a 30s TTL and can only be
// obtained from this authenticated endpoint, so one mint is at most one download.
// Moving it does not weaken the limit meaningfully: a ticket can only be obtained from this
// authenticated endpoint, is bound to one archive, and — since downloads must be resumable —
// is worth at most `MAX_DOWNLOAD_REDEMPTIONS` transfers rather than exactly one. The daily
// limit is therefore a bound on mints, not on bytes; see `MAX_DOWNLOAD_REDEMPTIONS` for why
// charging per redemption would re-break resumption.
// Confirm the archive actually EXISTS before spending anything on it.
//
// `export_status` — which is what enables the Download button — reports `done` from
@@ -391,15 +417,26 @@ pub async fn export_ticket(
// `fetch` that the existing `toastError` path already renders. This is 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.
if let Some(kind) = q.kind.as_deref() {
let export_type = match kind {
"zip" => "zip",
"html" => "html",
other => {
return Err(AppError::BadRequest(format!(
"Unbekannter Export-Typ: {other}"
)));
}
let export_kind = match q.kind.as_deref() {
Some("zip") => crate::services::sse_tickets::ExportKind::Zip,
Some("html") => crate::services::sse_tickets::ExportKind::Html,
Some(other) => {
return Err(AppError::BadRequest(format!(
"Unbekannter Export-Typ: {other}"
)));
}
None => {
return Err(AppError::BadRequest(
"Es fehlt die Angabe, welches Archiv geladen werden soll. Bitte lade die Seite \
neu und versuche es erneut."
.into(),
));
}
};
{
let export_type = match export_kind {
crate::services::sse_tickets::ExportKind::Zip => "zip",
crate::services::sse_tickets::ExportKind::Html => "html",
};
let msg = if export_type == "zip" {
"Der ZIP-Export ist noch nicht verfügbar."
@@ -409,33 +446,60 @@ pub async fn export_ticket(
resolve_export_file(&state, export_type, msg).await?;
}
enforce_export_rate(&state, auth.user_id).await?;
// `issue` returns None when the ticket store is at capacity. Unwrapping it into the JSON body
// serialized `{"ticket": null}` with a 200 — so `api.post` resolved happily, the page toasted
// success, the iframe navigated to `?ticket=null`, and one of the guest's three DAILY
// downloads had already been charged above. That is precisely the phantom-success failure
// this endpoint's pre-validation was added to eliminate, arriving through the other door.
// 503 + Retry-After, matching how `sse::issue_ticket` answers the identical condition.
// success, and the iframe navigated to `?ticket=null`. 503 + Retry-After, matching how
// `sse::issue_ticket` answers the identical condition.
//
// Minted BEFORE the rate limit is charged. Charging first meant a store-capacity 503 — a
// server-side condition the guest did nothing to cause and cannot see — still cost one of
// their three DAILY downloads. There is no refund path, so the only fix is not to charge until
// the thing being charged for actually exists.
let ticket = state
.sse_tickets
.issue(auth.token_hash, TicketKind::Download)
.issue(auth.token_hash, TicketKind::Download(export_kind))
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
Some(30),
)
})?;
// A refused mint must not leave its ticket behind. The per-session cap is FOUR tickets of the
// same kind, and a download ticket now lives six hours instead of being consumed on first use —
// so every abandoned one occupies a slot until it expires. A guest whose 1.4 GB transfer looks
// stuck and who taps "Herunterladen" a few more times spends mints 1-3 legitimately, then gets
// a 429 on taps 4 and 5 — but both still minted, and the fifth evicted the OLDEST download
// ticket for the session: the one the running transfer is holding. The next `Range` resume then
// 401s, and re-minting is impossible because they are at the daily limit. The keepsake is gone
// until tomorrow, having done nothing worse than tapping a button that appeared to do nothing.
//
// Discarding here keeps both properties that put the mint first: a store-capacity 503 still
// costs no download, and a refused download costs no slot.
if let Err(e) = enforce_export_rate(&state, auth.user_id).await {
let _ = state
.sse_tickets
.consume(&ticket, TicketKind::Download(export_kind));
return Err(e);
}
Ok(Json(serde_json::json!({ "ticket": ticket })))
}
/// Validate a download ticket (single-use) and confirm its session still exists.
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
/// id to key the export rate limit per-user (see `enforce_export_rate`).
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
/// Validate a download ticket and confirm its session still exists, resolving it to the user who
/// minted it. Deliberately NOT single-use — see the note on `redeem_download` below.
async fn authenticate_download_ticket(
state: &AppState,
ticket: &str,
want: crate::services::sse_tickets::ExportKind,
) -> Result<Uuid, AppError> {
// Non-consuming: a keepsake download must survive being resumed with `Range`, and a
// single-use ticket meant the resume 401'd and cost the guest another of their three daily
// downloads. `redeem_download` bounds it by DOWNLOAD_TTL instead, and the session check
// below still runs on every request.
let token_hash = state
.sse_tickets
.consume(ticket, TicketKind::Download)
.redeem_download(ticket, want)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
.await
@@ -446,15 +510,32 @@ async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<
pub async fn download_zip(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
// Ticket validation only — the rate limit was charged at mint time, where a 429 is visible
// to the page. Charging it again here would cost every download two slots.
authenticate_download_ticket(&state, &q.ticket).await?;
authenticate_download_ticket(
&state,
&q.ticket,
crate::services::sse_tickets::ExportKind::Zip,
)
.await?;
let path =
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
serve_file(path, "Gallery.zip", "application/zip").await
serve_file(
path,
"Gallery.zip",
"application/zip",
headers
.get(axum::http::header::RANGE)
.and_then(|v| v.to_str().ok()),
headers
.get(axum::http::header::IF_RANGE)
.and_then(|v| v.to_str().ok()),
)
.await
}
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
@@ -500,45 +581,130 @@ async fn resolve_export_file(
pub async fn download_html(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
// See `download_zip`: the limit is charged at ticket mint, where the client can see it.
authenticate_download_ticket(&state, &q.ticket).await?;
authenticate_download_ticket(
&state,
&q.ticket,
crate::services::sse_tickets::ExportKind::Html,
)
.await?;
let path =
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
serve_file(path, "Memories.zip", "application/zip").await
serve_file(
path,
"Memories.zip",
"application/zip",
headers
.get(axum::http::header::RANGE)
.and_then(|v| v.to_str().ok()),
headers
.get(axum::http::header::IF_RANGE)
.and_then(|v| v.to_str().ok()),
)
.await
}
/// Stream a keepsake archive, honouring `Range`.
///
/// Range support is not a nicety here. The keepsake is the emotional payoff of the product and can
/// be ~1.4 GB; without `Accept-Ranges` a download that dies at 90% over hotel wifi restarts at byte
/// zero. Worse, the 3/day limit is charged when the download TICKET is minted and ZIP+HTML already
/// costs 2 — so one dropped connection locked a guest out of their own wedding photos for ~24h.
///
/// Reuses `upload::parse_range`, which already implements exactly the forms a client sends and is
/// unit-tested there. The media routes have always done this correctly; this route was the outlier.
async fn serve_file(
path: std::path::PathBuf,
filename: &str,
content_type: &str,
range_header: Option<&str>,
if_range_header: Option<&str>,
) -> Result<axum::response::Response, AppError> {
use crate::handlers::upload::{RangeSpec, parse_range};
use axum::body::Body;
use axum::http::{Response, StatusCode, header};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path)
let mut file = tokio::fs::File::open(&path)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
let len = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
.map_err(|e| AppError::Internal(e.into()))?
.len();
let disposition = format!("attachment; filename=\"{filename}\"");
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))?;
// A validator that CHANGES when the archive does, so a resume cannot splice two generations.
//
// The on-disk name is `{prefix}.{event_id}.{epoch}.zip`, so it already identifies the exact
// generation; length distinguishes a rebuild at the same epoch. Together they are a strong
// validator.
//
// Why this matters: `resolve_export_file` re-reads `export_current` on EVERY request, and a
// download ticket outlives several redemptions. So a guest whose 500 MB download drops at
// 500 MB, while the host takes a photo down (epoch bumps, rebuild lands, the old generation is
// pruned), used to resume with `Range: bytes=500000000-` against a DIFFERENT FILE of a
// different length — and the server would happily seek 500 MB into it and stream. The client
// concatenated the two halves into a structurally corrupt ZIP, with nothing logged anywhere.
let etag = format!(
"\"{}-{len}\"",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename)
);
Ok(response)
let base = |status: StatusCode| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition.clone())
// Advertised on EVERY response, including the 200. A client only knows it may resume
// if the first (unranged) response says so.
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag.clone())
};
// Serve a partial ONLY when the client proves it is resuming the same bytes.
//
// `If-Range` matching our ETag is that proof. A client that sends `Range` with no `If-Range`
// at all (curl -C -, wget -c, most download managers) cannot be given a partial safely — it
// has no way to notice the archive changed underneath it — so it gets a 200 and starts over.
// Restarting a download is a cost; a silently corrupt keepsake is not recoverable. Browsers
// send `If-Range`, so the ordinary resume path is unaffected, and this is the first release
// where their resume works at all: without a validator they simply refused to try.
let resume_is_safe = if_range_header.is_some_and(|v| v.trim() == etag);
let effective_range = if resume_is_safe { range_header } else { None };
match parse_range(effective_range, len) {
RangeSpec::Full => base(StatusCode::OK)
.header(header::CONTENT_LENGTH, len)
.body(Body::from_stream(ReaderStream::new(file)))
.map_err(|e| AppError::Internal(e.into())),
RangeSpec::Partial { start, end } => {
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|e| AppError::Internal(e.into()))?;
let span = end - start + 1;
base(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_LENGTH, span)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
.body(Body::from_stream(ReaderStream::new(file.take(span))))
.map_err(|e| AppError::Internal(e.into()))
}
RangeSpec::Unsatisfiable => base(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
.body(Body::empty())
.map_err(|e| AppError::Internal(e.into())),
}
}
/// Also expose export status to all authenticated users (guests need it for the export page)

View File

@@ -44,7 +44,6 @@ pub struct EventStatus {
pub disk_low: bool,
}
/// Is free space low enough that the host needs to know?
///
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
@@ -58,7 +57,8 @@ pub struct EventStatus {
/// into a decision someone can still make.
///
/// IT MUST FIRE BEFORE THE UPLOAD GATE CLOSES, and that is why the reserve and the margin are
/// here. The gate in `handlers::upload` refuses at `free < keepsake_required + DISK_RESERVE_BYTES`;
/// here. The gate in `handlers::upload` refuses at
/// `free < keepsake_required + DISK_RESERVE_BYTES + UPLOAD_GATE_HEADROOM_BYTES`;
/// warning at `free < keepsake_required` alone meant the two differed by the whole reserve, so
/// the wall was always hit FIRST. Every guest would be blocked from uploading while this
/// dashboard showed a comfortable disk and no banner at all — on the shipped 40 GB box, uploads
@@ -67,7 +67,14 @@ pub struct EventStatus {
/// The 25% margin makes it a warning rather than an obituary: the host sees it while there is
/// still room to act (delete a few large videos, which refunds immediately and reopens the gate).
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
let gate_closes_at = keepsake_required.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
// Mirrors the gate EXACTLY, headroom included. The gate now demands
// `UPLOAD_GATE_HEADROOM_BYTES` more than the export preflight does, so that ordinary
// end-of-night writes cannot flip the preflight after uploads have already stopped. Leaving
// that term out here would shrink the warning's lead by 1.5 GB — and the whole point of this
// function is that the banner must appear while the host can still act.
let gate_closes_at = keepsake_required
.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64)
.saturating_add(crate::handlers::upload::UPLOAD_GATE_HEADROOM_BYTES as u64);
let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4);
// No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here,
// and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at
@@ -80,8 +87,15 @@ fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
/// always keeps at least one operator" floor.
///
/// Takes a CONNECTION, not the pool, and every caller passes the same transaction it is about to
/// write in — after taking [`lock_operator_floor`]. Read on the pool beforehand, this count was a
/// snapshot that any concurrent operator-removing action could invalidate before the UPDATE landed:
/// an admin demoting host B while host A calls `DELETE /me` saw two independent checks each observe
/// the other still present, both commit, and the event end up with zero operators — which is not
/// recoverable from inside the app, since appointing an operator requires being one.
async fn remaining_operators(
state: &AppState,
conn: &mut sqlx::PgConnection,
event_id: Uuid,
excluding: Uuid,
) -> Result<i64, AppError> {
@@ -92,11 +106,36 @@ async fn remaining_operators(
)
.bind(event_id)
.bind(excluding)
.fetch_one(&state.pool)
.fetch_one(conn)
.await?;
Ok(count)
}
/// Serialise every action that can remove an operator from an event.
///
/// The same key `me::delete_account` takes — namespace 4242, `hashtext(event_id)` — and it MUST
/// stay identical, or the two families of caller lock against nothing. An advisory lock is used
/// rather than a row lock because it is a separate lock space and so cannot join the
/// `event`/`user` row-lock graph that moderation traffic already traverses in both directions;
/// it is released automatically when the transaction ends.
///
/// **Call this FIRST in the transaction, before taking any row lock.** Being a separate lock space
/// means it cannot form a cycle *with itself*, not that ordering is free: all three callers go on
/// to lock `user` and `event` rows, so a caller that took those rows first and reached for this
/// lock afterwards would deadlock against one that did it the other way round. Postgres would
/// break the tie by killing one transaction with a 500. Every caller acquires it first; keep it
/// that way.
pub(crate) async fn lock_operator_floor(
conn: &mut sqlx::PgConnection,
event_id: Uuid,
) -> Result<(), AppError> {
sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
.bind(event_id)
.execute(conn)
.await?;
Ok(())
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub role: String,
@@ -193,14 +232,6 @@ pub async fn ban_user(
));
}
// Floor: never leave the event with zero operators. Banning removes the target from
// the active-operator pool, so refuse if they're the last non-banned host/admin.
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
return Err(AppError::BadRequest(
"Der letzte Host kann nicht gesperrt werden.".into(),
));
}
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
// views/queries now also filter on `is_banned` (defense in depth), and we set
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
@@ -215,6 +246,21 @@ pub async fn ban_user(
//
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
let mut tx = state.pool.begin().await?;
// Floor: never leave the event with zero operators. Banning removes the target from the
// active-operator pool, so refuse if they're the last non-banned host/admin.
//
// INSIDE the transaction and behind the operator lock — see `remaining_operators`. Checked on
// the pool beforehand, this raced `set_role` and `DELETE /me` into an event with no operator.
if target.0 == "host" {
lock_operator_floor(&mut tx, auth.event_id).await?;
if remaining_operators(&mut tx, auth.event_id, user_id).await? == 0 {
return Err(AppError::BadRequest(
"Der letzte Host kann nicht gesperrt werden.".into(),
));
}
}
sqlx::query(
"UPDATE \"user\"
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
@@ -256,6 +302,19 @@ pub async fn ban_user(
"host: ban_user"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"ban_user",
Some(user_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -314,12 +373,40 @@ pub async fn unban_user(
start_regen(&state, r);
}
// The exact mirror of `ban_user`'s `user-hidden`, and it was missing entirely: every open
// feed and the unattended projector kept the guest evicted until somebody reloaded the page
// by hand. Meanwhile the host's own confirm copy promises the photos "come back to the
// gallery, die Diashow und den Export" — so the one surface that would have shown the host
// their action had worked showed the opposite.
//
// Also the signal a banned guest's upload queue waits on: their queued photos parked with
// the blob intact rather than being purged (see `AppError::UserBanned`), and this is what
// releases them.
let _ = state.sse_tx.send(SseEvent::new(
"user-shown",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
"host: unban_user"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"unban_user",
Some(user_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -420,21 +507,26 @@ pub async fn set_role(
// Floor: demoting the last non-banned host/admin to guest would leave the event with
// no operator. Refuse.
if new_role == "guest"
&& target.0 == "host"
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
{
return Err(AppError::BadRequest(
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
));
//
// The check and the UPDATE are ONE transaction, behind the operator lock — see
// `remaining_operators`. Split apart on the pool, this raced `ban_user` and `DELETE /me`.
let mut tx = state.pool.begin().await?;
if new_role == "guest" && target.0 == "host" {
lock_operator_floor(&mut tx, auth.event_id).await?;
if remaining_operators(&mut tx, auth.event_id, user_id).await? == 0 {
return Err(AppError::BadRequest(
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
));
}
}
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
.bind(user_id)
.bind(new_role)
.bind(auth.event_id)
.execute(&state.pool)
.execute(&mut *tx)
.await?;
tx.commit().await?;
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
@@ -443,6 +535,19 @@ pub async fn set_role(
new_role,
"host: set_role"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"set_role",
Some(user_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -535,6 +640,19 @@ pub async fn reset_user_pin(
"host: reset_user_pin"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"reset_pin",
Some(user_id),
None,
None,
)
.await;
Ok(Json(PinResetResponse { pin }))
}
@@ -611,8 +729,13 @@ pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRege
state.config.comments_enabled,
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
// delay lets superseded workers fail their claim and do zero work instead of each building
// a full archive. See export::REGEN_DEBOUNCE.
crate::services::export::REGEN_DEBOUNCE,
// a full archive.
//
// Measured from the START of the burst, not from this request — a fixed per-request delay
// meant a steady stream of invalidations faster than one per 20s deferred the build
// forever, leaving the keepsake permanently 404 and the UI stuck on "Wird vorbereitet…".
// See export::regen_delay_for.
crate::services::export::regen_delay_for(regen.event_id),
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
@@ -633,7 +756,9 @@ pub async fn host_delete_upload(
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
let mut tx = state.pool.begin().await?;
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
// `by_host: true` — the takedown holds the uploader's idempotency key so a late retry from
// their queue cannot resurrect the photo. See migration 031.
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id, true).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}
@@ -660,6 +785,19 @@ pub async fn host_delete_upload(
"host: host_delete_upload"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"delete_upload",
Some(upload_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -697,12 +835,25 @@ pub async fn host_delete_comment(
comment_id = %comment_id,
"host: host_delete_comment"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"delete_comment",
Some(comment_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
@@ -717,12 +868,27 @@ pub async fn close_event(
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
}
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
// the record the morning after had no way to see when uploads were locked or the
// gallery released, which are the two actions that change what every guest can do.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"lock_uploads",
None,
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
@@ -748,12 +914,27 @@ pub async fn open_event(
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
// the record the morning after had no way to see when uploads were locked or the
// gallery released, which are the two actions that change what every guest can do.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"unlock_uploads",
None,
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
// transaction. Two reasons, both of which were live bugs:
@@ -809,6 +990,20 @@ pub async fn release_gallery(
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// Detached — survives this handler being cancelled.
//
// SPAWNED IMMEDIATELY AFTER THE COMMIT, BEFORE ANY OTHER `.await`. Every `invalidate_and_arm`
// call site does this; `me::delete_account` carries the same note. The audit write below used
// to sit here, and it is two pool round-trips that can each wait up to the 5 s acquire timeout
// — right at the moment `event-closed` has just fanned out to ~100 phones whose queues all hit
// the API at once, so the pool is as contended as it ever gets. Drop the handler future during
// that suspension (the host's phone sleeps, the tab closes, Caddy times the request out) and
// the task never spawns: the event is released, uploads are locked, both `export_job` rows sit
// `pending` at the live epoch, and no worker exists. `/export/*` 404s, the page sits on "Wird
// vorbereitet…", `recover_exports` only runs at boot, and `release_gallery` refuses a retry
// because the gallery is already released.
//
// This is the one path that arms the FIRST build of the keepsake, so it is the worst possible
// place to reintroduce that window.
crate::services::export::spawn_export_jobs(
event_id,
event_name,
@@ -821,13 +1016,32 @@ pub async fn release_gallery(
state.sse_tx.clone(),
);
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
// the record the morning after had no way to see when uploads were locked or the
// gallery released, which are the two actions that change what every guest can do.
//
// Last, deliberately: it is best-effort by design (it swallows its own errors), so nothing
// downstream may depend on it having completed.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"release_gallery",
None,
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::disk_is_low;
use crate::handlers::upload::DISK_RESERVE_BYTES;
use crate::handlers::upload::{DISK_RESERVE_BYTES, UPLOAD_GATE_HEADROOM_BYTES};
use crate::services::export::required_free_bytes;
const GB: u64 = 1_000_000_000;
@@ -870,7 +1084,8 @@ mod tests {
// require it to be strictly above the level at which the gate closes, by a usable amount.
for media_gb in [0u64, 1, 4, 8, 16, 32] {
let required = required_free_bytes(media_gb * GB, 2);
let gate_closes_at = required + DISK_RESERVE_BYTES as u64;
let gate_closes_at =
required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64;
// Just above the gate: guests can still upload, and the host must already be warned.
assert!(
@@ -891,6 +1106,43 @@ mod tests {
}
}
/// The invariant the headroom exists for: uploads must stop while the keepsake can STILL be
/// built, with room to spare — not at the exact instant the preflight reaches its own limit.
///
/// Both thresholds used to be `required_free_bytes(media, 2) + DISK_RESERVE_BYTES`, identically.
/// So the moment the gate refused its first upload, the export preflight was already sitting on
/// its limit, and every byte written afterwards (WAL, container logs, the compression backlog
/// draining at exactly that hour) pushed it under. The release would then COMMIT — event closed,
/// uploads locked, epoch bumped, `event-closed` fanned out to every phone — and only then would
/// both workers bail, with no second release possible.
#[test]
fn the_upload_gate_closes_before_the_export_preflight_would_refuse() {
for media_gb in [0u64, 1, 4, 8, 16, 32] {
let required = required_free_bytes(media_gb * GB, 2);
// `services::export::preflight` bails below this.
let preflight_refuses_below = required + DISK_RESERVE_BYTES as u64;
// `handlers::upload` refuses below this.
let gate_refuses_below = preflight_refuses_below + UPLOAD_GATE_HEADROOM_BYTES as u64;
assert!(
gate_refuses_below > preflight_refuses_below,
"at media={media_gb}GB the gate and the preflight share a threshold, so the \
keepsake's fate rests on whatever is written after uploads stop"
);
// At the instant the last upload is refused, the preflight must still pass with the
// whole headroom to spare — that is the slack the night's remaining writes consume.
let free_when_gate_closes = gate_refuses_below;
assert!(
free_when_gate_closes
>= preflight_refuses_below + UPLOAD_GATE_HEADROOM_BYTES as u64,
"at media={media_gb}GB there is no slack between the gate closing and the \
preflight failing"
);
}
}
#[test]
fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() {
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
@@ -905,7 +1157,7 @@ mod tests {
// size — see `disk_is_low`. Warning at the bare size fired only after the gate had
// already blocked every guest.
let required = 20 * GB;
let gate = required + DISK_RESERVE_BYTES as u64;
let gate = required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64;
let warn_at = gate + gate / 4;
assert!(!disk_is_low(warn_at, required), "exactly enough is enough");
assert!(disk_is_low(warn_at - 1, required));
@@ -914,8 +1166,9 @@ mod tests {
#[test]
fn an_empty_gallery_still_reserves_room_for_postgres() {
// With no gallery the keepsake term is 0, so the warn threshold collapses to
// 1.25 x DISK_RESERVE_BYTES (12.5 GB), which dominates the 10 GB absolute floor.
assert!(!disk_is_low(13 * GB, 0));
// 1.25 x (DISK_RESERVE_BYTES + UPLOAD_GATE_HEADROOM_BYTES) = 1.25 x 11.5 GB = 14.375 GB,
// which dominates the 10 GB absolute floor.
assert!(!disk_is_low(15 * GB, 0));
assert!(disk_is_low(9 * GB, 0));
}
}

View File

@@ -121,3 +121,200 @@ pub async fn get_context(
is_banned: user.is_banned,
}))
}
/// `(original_path, preview_path, thumbnail_path, display_path)` for one upload.
type UploadFilePaths = (String, Option<String>, Option<String>, Option<String>);
/// Delete the caller's own account and everything attached to it.
///
/// The erasure path (H18). There was no user-deletion route at ANY role, so honouring a "please
/// remove my photos and my name" request meant hand-written SQL against production — during or
/// after a wedding, by whoever happened to have psql access. Deletion also never removed text:
/// captions, comment bodies and hashtag links survived indefinitely by design, so even the
/// existing per-photo delete left the guest's words in the database and in the keepsake.
///
/// Self-service on purpose. The alternative (host-initiated only) puts a guest's erasure request
/// through a third party who is at a party, and the join page's data notice now promises this.
///
/// ORDER MATTERS. `upload.user_id` and `comment.user_id` are plain FKs with NO `ON DELETE CASCADE`
/// (migration 002), so deleting the user first fails on a constraint violation. Children first,
/// then the row itself — at which point `session`, `like` and `pin_reset_request` do cascade.
pub async fn delete_account(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<axum::http::StatusCode, AppError> {
// The last host/admin may not erase themselves: it would leave the event with no operator and
// no way to appoint one. Mirrors the floor `set_role` and `ban_user` already enforce.
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if matches!(user.role, UserRole::Host | UserRole::Admin) {
let others = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM \"user\"
WHERE event_id = $1 AND id != $2
AND role IN ('host', 'admin') AND is_banned = FALSE",
)
.bind(auth.event_id)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await?;
if others == 0 {
return Err(AppError::BadRequest(
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
dein Konto löschst."
.into(),
));
}
}
// Collect the file paths BEFORE the rows go, or they are unrecoverable. Every derivative, not
// just the original: a preview left behind is still the guest's photo.
let files: Vec<UploadFilePaths> = sqlx::query_as(
"SELECT original_path, preview_path, thumbnail_path, display_path
FROM upload WHERE user_id = $1",
)
.bind(auth.user_id)
.fetch_all(&state.pool)
.await?;
let mut tx = state.pool.begin().await?;
// The last-host guard, AUTHORITATIVELY — inside the transaction, holding a lock.
//
// The pre-check further up runs on the pool before this transaction opens, so two hosts
// deleting themselves at the same moment each saw the other and both proceeded, leaving the
// event with NO operator: nobody to moderate, nobody to release the gallery, and no way to
// appoint anyone because appointing requires a host. Not recoverable from inside the app.
//
// Serialised with a transaction-scoped ADVISORY lock, not a row lock. `FOR UPDATE` on the
// other operators\' rows looks like the obvious answer and is the wrong one: each deleter would
// lock the OTHER\'s row and then try to delete its own, so the two block on each other and
// Postgres resolves it by killing one with a deadlock error — the invariant holds, but the
// loser gets a 500 instead of the sentence below. Locking the `event` row instead would
// serialise cleanly, but it inverts the lock order every moderation path uses (upload/user
// rows first, event last). An advisory lock is a separate lock space, so it cannot join the
// row-lock graph at all, and it is released automatically when this transaction ends.
//
// FIRST STATEMENT IN THE TRANSACTION, before any row lock — the ORDER matters as much as the
// lock. `ban_user` and `set_role` take this same lock and then go on to lock `user` and
// `event` rows. If this path grabbed those rows first and reached for the advisory lock
// afterwards, the two would deadlock, each holding what the other needs, and Postgres would
// kill one with a 500: the invariant would survive, but a host deleting their account would
// get an error page instead of the sentence below.
//
// Taking it up front also means the refusal path does no work at all before answering.
if matches!(user.role, UserRole::Host | UserRole::Admin) {
// Shared with `host::ban_user` and `host::set_role` — the same key, by construction rather
// than by two copies agreeing. All three remove an operator, so all three must serialise
// against each other or the floor is enforceable only against its own kind of caller.
crate::handlers::host::lock_operator_floor(&mut tx, auth.event_id).await?;
let others: Vec<uuid::Uuid> = sqlx::query_scalar(
"SELECT id FROM \"user\"
WHERE event_id = $1 AND id != $2
AND role IN ('host', 'admin') AND is_banned = FALSE",
)
.bind(auth.event_id)
.bind(auth.user_id)
.fetch_all(&mut *tx)
.await?;
if others.is_empty() {
return Err(AppError::BadRequest(
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
dein Konto löschst."
.into(),
));
}
}
// Comments the guest wrote on OTHER people's photos. Hard delete, not `deleted_at`: this is
// erasure, and a soft delete leaves the body in the table and in the keepsake's data.json.
sqlx::query("DELETE FROM comment WHERE user_id = $1")
.bind(auth.user_id)
.execute(&mut *tx)
.await?;
// Their uploads. Cascades comments and likes ON those uploads, plus upload_hashtag links.
sqlx::query("DELETE FROM upload WHERE user_id = $1")
.bind(auth.user_id)
.execute(&mut *tx)
.await?;
// Invalidate the keepsake inside the same transaction — an already-released archive still
// contains this guest's photos and captions, and erasure that leaves them in the downloadable
// ZIP has not happened. Returns None when the event isn't released, in which case there is
// nothing to rebuild.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
crate::services::export::Affects::Both,
)
.await?;
// And the account. `session`, `like` and `pin_reset_request` cascade from here.
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
.bind(auth.user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// IMMEDIATELY after the commit, before any other `.await`. Every other `invalidate_and_arm`
// call site does this; this one used to spawn the workers *after* the file-removal loop below,
// and axum drops a handler future the moment the client disconnects. Drop it inside that loop
// and the keepsake is left with the epoch bumped, both `export_job` rows armed `pending` at
// that epoch, and NO WORKER: `/export/zip` and `/export/html` 404, the UI sits on
// "Wird vorbereitet…" forever, and `recover_exports` only runs at boot. Deleting your account
// from a phone that walks out of wifi range is enough to do it.
if let Some(r) = regen {
crate::handlers::host::start_regen(&state, r);
}
// Best effort, after the commit. Anything missed here is an orphan with no row pointing at it,
// which `sweep_orphan_originals` reclaims on its next pass — so a failure delays reclamation
// rather than leaving the file referenced.
for (original, preview, thumbnail, display) in &files {
for rel in [
Some(original),
preview.as_ref(),
thumbnail.as_ref(),
display.as_ref(),
]
.into_iter()
.flatten()
{
let abs = state.config.media_path.join(rel);
if let Err(e) = tokio::fs::remove_file(&abs).await
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(error = ?e, path = %abs.display(), "account deletion: could not remove media file");
}
}
}
// Evict their content from every open feed and the projector. `user-hidden` is exactly the
// right signal — it already means "this user's cards must go" — and reusing it means every
// client already handles this with no new event type.
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"user-hidden",
serde_json::json!({ "user_id": auth.user_id }).to_string(),
));
// Audited like the host actions it resembles, with the actor and target being the same person.
//
// The names are passed EXPLICITLY here, unlike every other call site. `audit::record` resolves
// a missing name by looking the id up in `"user"` — and this handler has just hard-deleted that
// row, so the lookup would find nothing and write the NULL that makes the record unreadable.
// This is the row most likely to be read later ("whose photos disappeared?"), and migration 029
// made these columns non-FK precisely so it would survive the deletion.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
Some(&user.display_name),
user.role.clone(),
"delete_account",
Some(auth.user_id),
Some(&user.display_name),
Some(serde_json::json!({ "uploads_removed": files.len() })),
)
.await;
tracing::info!(user_id = %auth.user_id, uploads = files.len(), "account deleted by its owner");
Ok(axum::http::StatusCode::NO_CONTENT)
}

View File

@@ -21,6 +21,15 @@ pub struct PublicEventDto {
pub theme_preset: String,
pub theme_primary: String,
pub theme_accent: String,
/// The operator's data notice, if they set one. Empty string when unset (migration 009
/// defaults it to `''`).
///
/// Exposed PUBLICLY — it was only on `/me/context`, which requires a token, so the one place a
/// notice actually has to appear (before a name is collected) could not read it. The join page
/// pairs this with a baseline notice of its own, precisely because this can be empty: relying
/// on an operator-supplied string meant a stock deploy collected ~100 EU guests' photos of
/// identifiable people, including children, with no notice at the point of collection at all.
pub privacy_note: String,
}
/// Public event identity + presentation config, used by the pre-auth join/recover
@@ -40,5 +49,6 @@ pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEvent
.await,
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
.await,
privacy_note: config::get_str(cache, "privacy_note", "").await,
})
}

View File

@@ -104,8 +104,17 @@ pub async fn toggle_like(
// itself is already committed, so a failed count must not fail the request — but we
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
// client until the next event). On error we skip the broadcast and return null.
// The `NOT u.is_banned` join is what makes "mirrors v_feed.like_count" true. Migration 028
// added it to the view and not here, so the two disagreed the moment anyone was banned: the
// host bans a guest, the feed correctly drops to the lower number, and then the very next like
// on that photo broadcasts 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. `like.user_id` is NOT NULL REFERENCES "user"(id), so the inner join can
// neither drop nor duplicate a row.
let like_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
"SELECT COUNT(DISTINCT l.user_id) FROM \"like\" l \
JOIN \"user\" u ON u.id = l.user_id \
WHERE l.upload_id = $1 AND NOT u.is_banned",
)
.bind(upload_id)
.fetch_one(&state.pool)
@@ -222,8 +231,13 @@ pub async fn add_comment(
// over the same deleted_at filter is identical since comment.id is the PK). The
// count + broadcast are a UI optimisation — the comment is already committed, so a
// failure here must not fail the request. Swallow the error and skip the broadcast.
// `NOT u.is_banned` for the same reason as `like_count` above — see that comment. Migration
// 028 put this filter in `v_feed.comment_count` and `Comment::list_for_upload`, but not here,
// so posting a comment pushed the pre-ban total back to every client.
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
"SELECT COUNT(*) FROM comment c \
JOIN \"user\" u ON u.id = c.user_id \
WHERE c.upload_id = $1 AND c.deleted_at IS NULL AND NOT u.is_banned",
)
.bind(upload_id)
.fetch_one(&state.pool)

View File

@@ -53,12 +53,15 @@ pub async fn issue_ticket(
));
}
let ticket = state.sse_tickets.issue(auth.token_hash, TicketKind::Sse).ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
Some(30),
)
})?;
let ticket = state
.sse_tickets
.issue(auth.token_hash, TicketKind::Sse)
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
Some(30),
)
})?;
let server_time = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
@@ -68,6 +71,57 @@ pub async fn issue_ticket(
}))
}
/// Live SSE streams one session may hold OPEN at once.
///
/// The ticket store's `MAX_TICKETS_PER_SESSION` bounds UNCONSUMED tickets, not open streams — so it
/// never bounded this at all: mint a ticket, redeem it (freeing the slot), repeat. At the 60/min
/// ticket ceiling one guest could accumulate 60 new live streams per minute indefinitely, each
/// holding a broadcast receiver, a tokio task and a 60-second DB revalidation ticker.
///
/// 6 rather than 2: a guest legitimately has the feed in one tab, the diashow on a laptop, and both
/// may briefly double during a reconnect before the old socket's `Drop` lands. Well above real use,
/// far below anything that hurts.
const MAX_OPEN_STREAMS_PER_SESSION: usize = 6;
/// Open stream count per session token hash.
type OpenStreams = std::collections::HashMap<String, usize>;
static OPEN_STREAMS: std::sync::LazyLock<std::sync::Mutex<OpenStreams>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(OpenStreams::new()));
/// Decrements the open-stream count for its session when the stream is dropped.
///
/// A `Drop` guard is the only thing that works here: a client vanishing off wifi never runs any
/// cleanup path we write, but dropping the response future is exactly what happens.
struct StreamSlot(String);
impl Drop for StreamSlot {
fn drop(&mut self) {
if let Ok(mut map) = OPEN_STREAMS.lock()
&& let Some(n) = map.get_mut(&self.0)
{
*n = n.saturating_sub(1);
if *n == 0 {
map.remove(&self.0);
}
}
}
}
/// Claim one of this session's stream slots, or `None` when it is already at the cap.
fn claim_stream_slot(token_hash: &str) -> Option<StreamSlot> {
let mut map = match OPEN_STREAMS.lock() {
Ok(m) => m,
// Never let a poisoned lock take live updates down for the whole venue.
Err(e) => e.into_inner(),
};
let n = map.entry(token_hash.to_string()).or_insert(0);
if *n >= MAX_OPEN_STREAMS_PER_SESSION {
return None;
}
*n += 1;
Some(StreamSlot(token_hash.to_string()))
}
/// SSE stream endpoint. Authenticates via a single-use ticket (see
/// [`issue_ticket`]) — never the raw JWT.
pub async fn stream(
@@ -88,6 +142,17 @@ pub async fn stream(
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
// Bound how many streams this session holds open — see MAX_OPEN_STREAMS_PER_SESSION. Refuse
// rather than evict: closing somebody's live feed to make room for their own reconnect loop
// reads exactly like the flakiness it would be trying to fix.
let slot = claim_stream_slot(&token_hash).ok_or_else(|| {
tracing::warn!("session at its open-SSE-stream cap; refusing another");
AppError::TooManyRequests(
"Zu viele offene Verbindungen. Bitte schließe andere Tabs.".into(),
Some(10),
)
})?;
let rx = state.sse_tx.subscribe();
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
Ok(sse_event) => Some(Ok(Event::default()
@@ -113,6 +178,10 @@ pub async fn stream(
let pool = state.pool.clone();
let session_hash = token_hash.clone();
let session_gone = async move {
// Owns the slot guard, and this future is owned by the returned stream — so the slot is
// released exactly when the stream is dropped, including when the client simply walks out
// of range and no cleanup code of ours ever runs.
let _slot = slot;
let mut ticker = tokio::time::interval(Duration::from_secs(60));
ticker.tick().await; // consume the immediate first tick
loop {

View File

@@ -61,7 +61,7 @@ pub async fn truncate_all(
('upload_rate_per_hour', '100'),
('feed_rate_per_min', '60'),
('export_rate_per_day', '3'),
('join_ip_rate_per_min', '60'),
('join_ip_rate_per_min', '300'),
('recover_ip_rate_per_min', '30'),
('social_rate_per_min', '120'),
('quota_tolerance', '0.75'),

View File

@@ -54,9 +54,7 @@ async fn read_text_field_bounded(
.map_err(|e| AppError::BadRequest(e.to_string()))?
{
if buf.len() + chunk.len() > max_bytes {
return Err(AppError::BadRequest(
"Eingabe ist zu lang.".to_string(),
));
return Err(AppError::BadRequest("Eingabe ist zu lang.".to_string()));
}
buf.extend_from_slice(&chunk);
}
@@ -170,8 +168,51 @@ const ALLOWED_MEDIA: &[(&str, &str)] = &[
pub async fn upload(
State(state): State<AppState>,
auth: AuthUser,
headers: axum::http::HeaderMap,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// REPLAY FIRST — before the rate limit, before the ban check, before the lock/release gate.
//
// The idempotency key also arrives as a multipart FIELD, and there is a replay for it further
// down; but a field cannot be read until the body is being parsed, which is after every gate
// below. So the field's replay was unreachable in exactly the situation it matters most:
//
// the photo committed, the response was lost on the way back (the flaky-wifi failure this
// whole mechanism exists for), the host released the gallery at the end of the night, and
// the phone's retry then answered `gallery_released` — telling the guest a photo that is
// ALREADY IN THE GALLERY had not been sent.
//
// And the remedy that error suggests 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.
//
// A header arrives with the request line, so the answer is knowable before anything is
// decided. Charging the hourly rate limit for a retry of an already-stored photo was the same
// mistake one layer up: a 40-photo burst with two retries each exhausted the hour for uploads
// that committed the first time.
//
// The body is still DRAINED rather than abandoned — see `drain_multipart`: replying before
// reading the body makes the proxy see a broken pipe and turn a clean 200 into a 502.
if let Some(cid) = headers
.get("x-client-upload-id")
.and_then(|v| v.to_str().ok())
.and_then(|v| Uuid::parse_str(v.trim()).ok())
&& let Some(existing) =
Upload::find_by_client_upload_id(&state.pool, auth.user_id, cid).await?
{
drain_multipart(multipart).await;
let uploader_name = User::find_by_id(&state.pool, auth.user_id)
.await?
.map(|u| u.display_name)
.unwrap_or_default();
tracing::info!(
client_upload_id = %cid, upload_id = %existing.id,
"upload retry replayed from the header key, before the lock/release gate"
);
let dto = replay_upload_dto(&state, &existing, &uploader_name).await;
return Ok((StatusCode::OK, Json(dto)));
}
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
@@ -197,29 +238,47 @@ pub async fn upload(
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
// `UserBanned`, not `Forbidden`: a ban is reversible, so the client must KEEP the queued
// blob and park it until `user-shown` arrives. Under the generic `forbidden` code it
// purged the photo from IndexedDB and moved the row to `blocked`, which has no retry
// button — so an unban restored everything except whatever was in flight.
return Err(AppError::UserBanned("Du bist gesperrt.".into()));
}
// Check if uploads are locked
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
// Reversible: a host can reopen the event, so the client keeps the queued blob and
// retries on `event-opened` rather than purging it (UploadsLocked, not Forbidden).
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
// released the export has been snapshotted, so a late upload could never make it into
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
// Also reversible (reopen clears `export_released_at`), so likewise UploadsLocked.
// RELEASE IS CHECKED FIRST, AND THE ORDER IS THE WHOLE POINT.
//
// `release ⇒ lock`, so a released gallery satisfies BOTH conditions. Testing the lock first
// made this branch unreachable: every post-release upload — the overwhelmingly common case,
// since release is the end-of-event action every guest's queue runs into — answered
// `uploads_locked`, and the `GalleryReleased` arm below was dead code that read as if it
// worked. The commit-time re-check further down splits the two correctly, so the two paths
// also disagreed about the same event state depending on where the upload was intercepted.
//
// The codes are not interchangeable to the client (see upload-queue.ts): `uploads_locked`
// charges an attempt and re-pushes the whole photo on the backoff ladder, and tells the guest
// to find it via the camera button. `gallery_released` PARKS it — no attempt charged, no
// re-push — and says the photo is safe but needs the hosts to reopen the gallery. Against an
// answer that cannot change on its own, the first is a cellular data leak with a misleading
// message attached.
//
// Both keep the blob; both are cleared by `event-opened`. Only the retry behaviour differs.
if event.export_released_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::UploadsLocked(
"Galerie wurde bereits freigegeben.".into(),
return Err(AppError::GalleryReleased(
"Die Galerie ist abgeschlossen — es können keine neuen Fotos mehr hinzugefügt werden."
.into(),
));
}
if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
// A PLAIN lock (the host paused uploads mid-event) is the reversible-and-likely-soon case,
// so auto-retry is right here: the client keeps the blob and resumes on `event-opened`.
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
// Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
@@ -287,20 +346,16 @@ pub async fn upload(
// 10-20 GB of `.tmp` on a 40 GB volume that the gate cannot see, eating the
// reserve that keeps Postgres able to write WAL. The permit is held until the
// handler returns, which is exactly as long as the temp file can exist.
_admission = Some(
state
.upload_admission
.reserve(cap_bytes)
.await
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Gerade laden sehr viele Gäste hoch. Dein Foto bleibt in der \
_admission = Some(state.upload_admission.reserve(cap_bytes).await.ok_or_else(
|| {
AppError::ServiceUnavailable(
"Gerade laden sehr viele Gäste hoch. Dein Foto bleibt in der \
Warteschlange und wird gleich automatisch gesendet."
.into(),
Some(30),
)
})?,
);
.into(),
Some(30),
)
},
)?);
tokio::fs::create_dir_all(&originals_dir)
.await
.map_err(|e| AppError::Internal(e.into()))?;
@@ -513,10 +568,19 @@ pub async fn upload(
// `media_total` is the opposite: it is a sum of committed DB rows, and this upload's row
// does not exist yet, so the prospective total does need `+ size`.
let free = disk.free as i64;
let media_after = state.media_total.get(&state.pool).await.saturating_add(size);
let media_after = state
.media_total
.get(&state.pool, &state.config.event_slug)
.await
.saturating_add(size);
let keepsake_needs =
crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64;
let required = keepsake_needs.saturating_add(DISK_RESERVE_BYTES);
// Strictly more than the export preflight requires — see `UPLOAD_GATE_HEADROOM_BYTES`.
// Matching it exactly meant the preflight was already at its limit the moment uploads
// stopped, so the night's remaining writes decided whether the keepsake could be built.
let required = keepsake_needs
.saturating_add(DISK_RESERVE_BYTES)
.saturating_add(UPLOAD_GATE_HEADROOM_BYTES);
if free < required {
tracing::error!(
free_bytes = free,
@@ -524,6 +588,7 @@ pub async fn upload(
media_after,
keepsake_needs,
reserve = DISK_RESERVE_BYTES,
headroom = UPLOAD_GATE_HEADROOM_BYTES,
"refusing upload: it would leave too little room to build the keepsake"
);
return Err(AppError::QuotaExceeded(
@@ -533,10 +598,27 @@ pub async fn upload(
.into(),
));
}
} else {
// Failing OPEN when the disk can't be read is deliberate and matches the per-user quota
// below: refusing every upload because a `statfs` failed would be a worse outage than the
// one being guarded against.
//
// But it must not be SILENT. `snapshot` returns `None` when `select_disk` finds neither a
// mount that prefixes the media path nor a `/` entry — and inside a container `/` is an
// overlay rather than a `/dev` device, so this is a real possibility rather than a
// theoretical one. When it happens, the ONLY global disk bound in the app is gone, the
// per-user quota fails open through the same `None`, and the box fills to 100% — at which
// point Postgres cannot write WAL and the whole event stops, with nothing having warned
// anyone. The export preflight already warns on the identical condition; this is the
// louder of the two paths and had no log line at all.
//
// Rate-limited by the disk cache's own TTL, so this cannot spam the log per upload.
tracing::warn!(
media_path = %state.config.media_path.display(),
"disk usage unreadable — the global free-space gate is INACTIVE and uploads are \
proceeding unbounded; check the admin stats page for a plausible free-space figure"
);
}
// Failing OPEN when the disk can't be read is deliberate and matches the per-user quota
// below: refusing every upload because a `statfs` failed would be a worse outage than the
// one being guarded against.
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
@@ -616,7 +698,19 @@ pub async fn upload(
.bind(auth.event_id)
.fetch_one(&mut *tx)
.await?;
if locked_at.is_some() || released_at.is_some() {
// Same order as the fast-path check above, and for the same reason: `release ⇒ lock`, so
// testing the lock first would collapse a release into `uploads_locked` and set the client
// auto-retrying a photo that can never be accepted until a host reopens the gallery. A
// guest who lost the race with `release_gallery` must get `gallery_released` so the queue
// parks it instead.
if released_at.is_some() {
return Err(AppError::GalleryReleased(
"Die Galerie ist abgeschlossen — es können keine neuen Fotos mehr hinzugefügt \
werden."
.into(),
));
}
if locked_at.is_some() {
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
@@ -677,7 +771,47 @@ pub async fn upload(
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
Hashtag::link_to_upload(&mut *tx, upload.id, h.id).await?;
}
tx.commit().await?;
// Hand the bytes to the row BEFORE committing, not after.
//
// `tx.commit().await` is a suspension point, and a COMMIT already written to the
// socket is applied by Postgres whether or not this future lives to read the reply.
// Disarming afterwards left a real window: the guest walks out of range mid-commit,
// axum drops the future, Postgres commits the row anyway, and `Drop` deletes the file
// that freshly committed row points at. The result is invisible to every repair path
// — the row is live so the deleted-media sweep skips it, the file is gone so the
// orphan sweep skips it — and it is missing from the keepsake with nothing in the log
// naming it as loss.
//
// Disarming first cannot fix the cancellation (nothing in-process can), but it moves
// the failure to the recoverable side: if we are dropped mid-commit the bytes leak,
// and leaked bytes under a final name are exactly what the orphan sweeper reclaims.
// A committed row whose file we deleted is unrecoverable. Prefer the leak.
file_guard.disarm();
if let Err(e) = tx.commit().await {
// Deliberately do NOT re-arm the guard here.
//
// A `commit()` that returns `Err` is INDETERMINATE, not "definitely rolled back".
// sqlx writes `COMMIT` to the socket and awaits the reply; if the connection dies
// after Postgres flushed the WAL record but before that reply arrives (a db
// restart, a killed backend, a network blip), the row is durably committed and we
// are told it failed. Re-arming would then delete the file a live row points at —
// the exact unrecoverable case the comment above says to avoid, just reached
// through the error path instead of the cancellation path.
//
// It is worse than it sounds, because the client retries: the idempotency fast
// path finds the committed row, answers 200, and the phone purges the only other
// copy of the photo. So we prefer the leak in both directions. If the commit
// genuinely did not apply, `sweep_orphan_originals` reclaims the bytes on its next
// pass (it deletes files with no DB row, which is precisely this case).
tracing::error!(
error = ?e,
path = %absolute_path.display(),
"upload commit returned an error; leaving the file in place because the commit \
may still have applied — the orphan sweeper reclaims it if it did not"
);
return Err(e.into());
}
Ok(upload)
}
.await;
@@ -687,6 +821,9 @@ pub async fn upload(
// and reclaims the bytes on the way out. That covers the concurrent-duplicate loser below
// as well as the plain error case, and unlike the explicit `remove_file` calls it replaces,
// it also covers axum dropping this future instead of returning.
//
// The successful-commit case disarmed the guard inside the block, immediately before
// `tx.commit()` — see the comment there for why it cannot be done out here.
let upload = match tx_result {
Ok(u) => u,
// The concurrent duplicate resolved inside the transaction. The winner's row is committed;
@@ -699,11 +836,28 @@ pub async fn upload(
.map_err(AppError::from)?,
None => None,
};
// If the winning row has vanished between the conflict and this lookup (deleted in
// the intervening milliseconds), there is nothing to replay — report the conflict.
let existing = existing.ok_or_else(|| {
AppError::Conflict("Dieser Upload wurde bereits verarbeitet.".into())
})?;
// No live row behind the key. Two very different causes, and the guest deserves to be
// told which: either the winning row vanished in the intervening milliseconds, or the
// key is still held by a photo the HOST took down (migration 031), in which case the
// refusal is the whole point and re-sending will never work.
let existing = match existing {
Some(e) => e,
None => {
let taken_down = match client_upload_id {
Some(cid) => {
Upload::taken_down_by_client_upload_id(&state.pool, auth.user_id, cid)
.await
.unwrap_or(false)
}
None => false,
};
return Err(AppError::Conflict(if taken_down {
"Dieses Foto wurde von den Gastgebern entfernt.".into()
} else {
"Dieser Upload wurde bereits verarbeitet.".to_string()
}));
}
};
tracing::info!(
upload_id = %existing.id,
"concurrent duplicate upload resolved; replaying the stored row"
@@ -713,8 +867,6 @@ pub async fn upload(
}
Err(e) => return Err(e),
};
// The committed row now references these bytes — hand ownership over.
file_guard.disarm();
// Spawn compression task
state
@@ -771,7 +923,8 @@ pub async fn edit_upload(
// This endpoint had no rate limit of any kind, while every other mutating route has one.
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let edit_rate_on = config::get_bool(&state.config_cache, "upload_edit_rate_enabled", true).await;
let edit_rate_on =
config::get_bool(&state.config_cache, "upload_edit_rate_enabled", true).await;
if rate_limits_on && edit_rate_on {
let edit_rate =
config::get_i64(&state.config_cache, "upload_edit_rate_per_min", 30).await as usize;
@@ -908,7 +1061,9 @@ pub async fn delete_upload(
// Atomic with the keepsake invalidation: a guest removing their own photo must have it removed
// from the downloadable archive too, and a half-applied delete would leave it there forever.
let mut tx = state.pool.begin().await?;
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
// `by_host: false` — the guest deleted their own photo, so the idempotency key is released and
// a later retry of the same queue item uploads afresh rather than 409ing forever.
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id, false).await?;
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
@@ -1108,6 +1263,31 @@ const MIN_QUOTA_LIMIT_BYTES: i64 = 500 * 1024 * 1024;
/// the shared filesystem long after new uploads have been refused.
pub const DISK_RESERVE_BYTES: i64 = 10_000_000_000;
/// Extra free space the UPLOAD gate demands on top of what the export preflight demands.
///
/// Both gates were computing the identical threshold — `required_free_bytes(media, 2) +
/// DISK_RESERVE_BYTES` — which left exactly zero margin between them. The moment the gate refused
/// its first upload, the preflight was already sitting on its own limit, so anything written
/// between that refusal and the host tapping "Galerie freigeben" pushed the preflight under:
///
/// * Postgres WAL, up to `max_wal_size` (1 GB by default) before a checkpoint reclaims it
/// * container logs, capped at 30 MB x 4 services by `docker-compose.yml`
/// * the compression backlog still draining — ~0.9 MB of derivatives per queued photo, and the
/// backlog is longest exactly at the end of the night
///
/// The failure that produces is the worst one in the app: the release COMMITS (event closed,
/// uploads locked, epoch bumped, `event-closed` fanned out to every phone) and only then do both
/// workers bail, at 01:00, with no second release possible and `rebuild_export` needing the same
/// space it just failed to find. Meanwhile ~10 GB of reserve sits unused — the preflight refused
/// on a threshold, not for want of room.
///
/// Giving the upload gate this much more to satisfy means it closes strictly earlier, so ordinary
/// end-of-night writes cannot flip the preflight. The cost is roughly 0.5 GB off the media ceiling
/// on a 40 GB box (the gate's equilibrium is `3.2 x media`, so headroom divides by 3.2), which is
/// the trade `README.md` already argues for: refusing the 1001st upload beats discovering at 01:00
/// that the archive can never be built.
pub const UPLOAD_GATE_HEADROOM_BYTES: i64 = 1_500_000_000;
/// Pure per-user quota formula: `max(floor((free_disk * tolerance) / divisor), MIN)`.
///
/// `divisor` is the LARGER of the observed uploader count and the operator's
@@ -1141,7 +1321,8 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64, expe
}
/// Computes the per-user storage quota using
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes =
/// `max(floor((free_disk * tolerance) / max(active_uploaders, estimated_guest_count, 1)), 500 MiB)`
/// — see [`quota_limit_bytes`] for the floor's exact conditions. Returns `limit_bytes =
/// None` whenever the storage quota is currently disabled — callers should skip the
/// check (upload handler) or hide the UI (quota endpoint).
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
@@ -1150,11 +1331,20 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
let (active_count,): (i64,) =
sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
// Scoped to THIS event (H12). Without the filter, reusing the install for a second event
// carried the first one's uploaders forward permanently: event one's 30 photographers stayed
// in event two's quota divisor, silently shrinking every new guest's ceiling for a party they
// had nothing to do with. There is no reset path anywhere in the code or the runbook, so the
// only fix would have been hand-written SQL.
let (active_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(DISTINCT up.user_id) FROM upload up
JOIN event e ON e.id = up.event_id
WHERE up.deleted_at IS NULL AND e.slug = $1",
)
.bind(&state.config.event_slug)
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
let active = active_count.max(1);
// The operator's expected headcount, used as a FLOOR on the divisor so the ceiling doesn't
// slide down as guests arrive — see `quota_limit_bytes`. Admin-editable at runtime.
@@ -1196,7 +1386,7 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
/// Outcome of parsing a `Range` request header against a known file length.
#[derive(Debug, PartialEq, Eq)]
enum RangeSpec {
pub(crate) enum RangeSpec {
/// No `Range` header, or one we deliberately don't honour (multi-range, non-`bytes`
/// unit, malformed). RFC 9110 lets a server ignore a Range it can't process and reply
/// 200 with the full body, which is what every one of these cases does.
@@ -1213,7 +1403,7 @@ enum RangeSpec {
/// Deliberately supports only the three forms a media element actually sends —
/// `bytes=N-`, `bytes=N-M`, `bytes=-S` (suffix) — and treats everything else as `Full`.
/// Multi-range responses need `multipart/byteranges`, which no `<video>` requires.
fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
pub(crate) fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
let Some(raw) = header else {
return RangeSpec::Full;
};
@@ -1358,6 +1548,41 @@ async fn stream_media_file(
/// soft-deleted and ban-hidden uploads (via `find_visible_media`) so moderation actually
/// removes access to content. Preview and thumbnail variants are gated the same way (see
/// [`get_preview`] / [`get_thumbnail`]).
/// NO per-IP rate limit on this route, deliberately — a 600/min ceiling was added here and had to
/// come back out.
///
/// The reasoning that put it in was that `/original` serves "100 guests occasionally tapping
/// 'Original anzeigen'", so a venue-wide 10/s could only ever catch a scraper. That is not what
/// this route is. `pickMediaUrl` (frontend/src/lib/data-mode-store.ts) 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; `VirtualFeed.svelte` says as much
/// where it explains its broken-tile retry.
///
/// With every guest behind one NAT address the bucket is venue-wide: ~6 new photos fanned out by
/// `upload-new` to ~100 open feeds exhausts 600 on its own, and then every original fetch from
/// anyone at the party 429s for the rest of the window. The tiles' own 4-second retry uses a fresh
/// `?r=` nonce, so the clients then hold the bucket saturated themselves. The whole venue watches
/// the newest photos render as broken tiles, and the projector starts skipping slides.
///
/// A per-IP bucket cannot separate "one scraper" from "the entire party" when they share an
/// address, and these four media routes are unauthenticated by design (an `<img>` cannot send a
/// bearer token), so there is no per-user key to move to. The certain harm outweighed the
/// speculative protection.
///
/// BE HONEST ABOUT WHAT REPLACED IT: nothing did. This used to say "bandwidth abuse belongs at the
/// proxy, where per-connection limits still work", which reads as though a control exists there.
/// It does not — the `Caddyfile` sets timeouts and no rate or concurrency directive, and the tower
/// stack is `TraceLayer` alone. So this route is unbounded, deliberately, and the cost is real
/// rather than theoretical: `no-store` below plus the feed's fallback to `/original` for any photo
/// whose derivatives are still compressing plus `VirtualFeed`'s nonce'd retry means a hundred open
/// feeds can re-fetch full-resolution originals off the same disk Postgres writes WAL to, each one
/// also holding a connection from a 15-slot pool that upload commits are competing for.
///
/// If that needs bounding, the shape that fits is a concurrency semaphore over media streaming
/// (like `upload_admission`), NOT a request-rate bucket — the venue is one IP, which is what made
/// the previous attempt a self-inflicted outage.
pub async fn get_original(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -1652,7 +1877,8 @@ mod tests {
while media < USABLE {
media += step;
let free = USABLE - media;
if free >= crate::services::export::required_free_bytes(media as u64, 2) as i64 + reserve
if free
>= crate::services::export::required_free_bytes(media as u64, 2) as i64 + reserve
{
ceiling = media;
}
@@ -1672,7 +1898,8 @@ mod tests {
let over = ceiling + step;
let free_over = USABLE - over;
assert!(
free_over < crate::services::export::required_free_bytes(over as u64, 2) as i64 + reserve,
free_over
< crate::services::export::required_free_bytes(over as u64, 2) as i64 + reserve,
"the gate should already be closed one step past the ceiling"
);
@@ -1719,8 +1946,8 @@ mod tests {
);
// Global: the keepsake needs both halves plus the reserve, and they no longer fit.
let required =
crate::services::export::required_free_bytes(media as u64, 2) as i64 + DISK_RESERVE_BYTES;
let required = crate::services::export::required_free_bytes(media as u64, 2) as i64
+ DISK_RESERVE_BYTES;
assert!(
free < required,
"the global gate must already be closed at {media} bytes of media: free {free} \
@@ -1771,7 +1998,10 @@ mod tests {
fn dropping_an_armed_guard_reclaims_the_file() {
let p = scratch("armed.tmp");
drop(TempFileGuard::new(p.clone()));
assert!(!p.exists(), "an abandoned upload must not survive the request");
assert!(
!p.exists(),
"an abandoned upload must not survive the request"
);
}
#[test]
@@ -1780,7 +2010,10 @@ mod tests {
let mut g = TempFileGuard::new(p.clone());
g.disarm();
drop(g);
assert!(p.exists(), "a committed upload's bytes must never be deleted");
assert!(
p.exists(),
"a committed upload's bytes must never be deleted"
);
}
#[test]
@@ -1792,7 +2025,10 @@ mod tests {
std::fs::remove_file(&old).unwrap();
g.retarget(new.clone());
drop(g);
assert!(!new.exists(), "the final-named original is orphaned too until the row commits");
assert!(
!new.exists(),
"the final-named original is orphaned too until the row commits"
);
}
#[test]
@@ -1807,7 +2043,7 @@ mod tests {
/// commit transaction — which holds a FOR SHARE lock on the event row, so one request could
/// stall every other upload behind tens of thousands of round trips.
mod hashtag_caps {
use super::super::{MAX_HASHTAGS_PER_UPLOAD, MAX_HASHTAG_LENGTH, normalize_tags};
use super::super::{MAX_HASHTAG_LENGTH, MAX_HASHTAGS_PER_UPLOAD, normalize_tags};
#[test]
fn a_huge_csv_is_capped_not_upserted_in_full() {

View File

@@ -41,6 +41,20 @@ async fn main() -> Result<()> {
.init();
let config = AppConfig::from_env()?;
// Prove both media directories are writable BEFORE anything else runs. This is first
// because everything downstream — the derivative backfill, export recovery, every upload —
// assumes it silently.
//
// This used to be `create_dir_all(&media_path).await.ok()` far below, which discarded the
// only signal there was, and EXPORT_PATH was never created or probed at all. The failure
// mode that produced: a wrong bind mount or a root-owned volume left the app booting
// *green* — `/health` only probes the database — so Caddy routed traffic to it, guests
// joined, and every single upload failed with EACCES. Existence is not the property we
// need; writability is, and the only way to know is to write.
ensure_writable_dir(&config.media_path, "MEDIA_PATH").await?;
ensure_writable_dir(&config.export_path, "EXPORT_PATH").await?;
let pool = db::create_pool(&config.database_url).await?;
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
@@ -86,9 +100,6 @@ async fn main() -> Result<()> {
config.media_path.clone(),
);
// Ensure media directories exist
tokio::fs::create_dir_all(&config.media_path).await.ok();
let api = Router::new()
// Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
@@ -138,6 +149,10 @@ async fn main() -> Result<()> {
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
.route("/api/v1/me/context", get(handlers::me::get_context))
.route("/api/v1/me/quota", get(handlers::me::get_quota))
// Self-service erasure. There was no user-deletion route at any role, so an erasure
// request could only be honoured with hand-written SQL against production — and the join
// page's data notice now promises this exists. See `me::delete_account`.
.route("/api/v1/me", delete(handlers::me::delete_account))
// Feed
.route("/api/v1/feed", get(handlers::feed::feed))
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
@@ -278,7 +293,10 @@ async fn main() -> Result<()> {
// * The two handlers were the same `SELECT 1` with the same 2s timeout under two
// names, so keeping both bought nothing.
//
// The external uptime monitor the runbook now calls for points at this route.
// The external uptime monitor points at this route — DEPLOYMENT_RUNBOOK.md §10.4,
// which documents the response table and is the only thing in this deployment that
// can page a human. (That section previously did not exist and this comment claimed
// it did; if you are removing §10.4, this route loses its only consumer.)
.route("/health", get(health))
.merge(api)
.layer(TraceLayer::new_for_http())
@@ -299,6 +317,56 @@ async fn main() -> Result<()> {
Ok(())
}
/// Create `dir` if absent, then prove we can actually write inside it. Hard error otherwise.
///
/// `create_dir_all` succeeding proves nothing: it is a no-op on an existing directory, so a
/// root-owned volume, a read-only bind mount and a full filesystem all "succeed". The probe
/// below is the only thing that distinguishes them, and it is worth the two syscalls once per
/// boot to turn a silent evening of failed uploads into a container that refuses to start.
///
/// `label` is the env var name so the operator gets the name of the knob to fix, not a path
/// they then have to trace back to a variable.
async fn ensure_writable_dir(dir: &std::path::Path, label: &str) -> anyhow::Result<()> {
use anyhow::Context;
tokio::fs::create_dir_all(dir)
.await
.with_context(|| format!("{label}: cannot create {}", dir.display()))?;
// A fixed name is fine: this runs once, before the server accepts requests, and two
// instances sharing one volume would be a misconfiguration in its own right. Removed on
// both the success and failure paths so a crashed boot cannot leave litter behind.
let probe = dir.join(".eventsnap-write-probe");
let result = async {
let mut f = tokio::fs::File::create(&probe)
.await
.with_context(|| format!("{label}: cannot create a file in {}", dir.display()))?;
// Write and fsync rather than just create: a full filesystem lets the create succeed
// and fails at the first byte, which is exactly the disk-full endgame this guards.
tokio::io::AsyncWriteExt::write_all(&mut f, b"ok")
.await
.with_context(|| format!("{label}: cannot write to {}", dir.display()))?;
f.sync_all()
.await
.with_context(|| format!("{label}: cannot flush to {}", dir.display()))?;
anyhow::Ok(())
}
.await;
let _ = tokio::fs::remove_file(&probe).await;
result.with_context(|| {
format!(
"{label} ({}) is not writable. The app refuses to start rather than accept uploads \
it cannot store — check the bind mount and that the volume is owned by the \
container's non-root user.",
dir.display()
)
})?;
tracing::info!(path = %dir.display(), "{label} is writable");
Ok(())
}
/// How long `/health` waits for the database before calling the app unhealthy. Deliberately
/// short: the point is to answer "can this process actually serve a request right now", and a
/// probe that blocks for the acquire timeout is itself a symptom.

View File

@@ -62,13 +62,27 @@ impl Comment {
) -> Result<Vec<CommentDto>, sqlx::Error> {
// Two-step: pick the newest `limit` rows older than `before`, then flip
// them back into ascending order so the caller can render top-to-bottom.
// `AND NOT u.is_banned` — the filter that was missing (H11).
//
// Only `deleted_at` was checked, so a banned guest's comments stayed on the live feed
// forever: the host bans somebody for an abusive comment, watches every photo of theirs
// vanish, and the comment is still sitting there on the most-viewed photo of the evening.
// Nothing on the client evicted them either.
//
// The tell that this was an oversight rather than a decision: the EXPORT query already
// filters `is_banned`, so the comment disappeared from the keepsake but not from the app —
// the two views of the same moderation action disagreed. Migration 021 did the same for
// hashtag counts. This brings the live read path in line with both.
//
// A ban is reversible and this is derived at read time, so `unban_user` restores the
// comments with no extra work.
sqlx::query_as::<_, CommentDto>(
"SELECT * FROM (
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
WHERE c.upload_id = $1 AND c.deleted_at IS NULL AND NOT u.is_banned
AND ($2::timestamptz IS NULL OR c.created_at < $2)
ORDER BY c.created_at DESC
LIMIT $3

View File

@@ -32,14 +32,32 @@ impl Event {
.await
}
/// Insert the event, or return the existing row if another request won the race.
///
/// `ON CONFLICT`, not a bare INSERT. `slug` is UNIQUE (migration 002), and the only callers are
/// `/join` and `/admin/login` — both of which run before the row exists, at the one moment the
/// app is most concurrent: the QR code goes up and every phone in the room posts `/join` within
/// the same second. A check-then-insert loses that race by construction, and the losers got a
/// bare unique violation surfaced as a 500 on the very first screen of the event.
///
/// `DO UPDATE SET slug = EXCLUDED.slug` is a deliberate no-op write: `DO NOTHING` returns no
/// row on conflict, which would put the loser right back at square one. It touches only `slug`,
/// so `name`, `export_epoch` and the lock/release timestamps are never disturbed by a late
/// arrival.
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
.bind(slug)
.bind(name)
.fetch_one(pool)
.await
sqlx::query_as::<_, Self>(
"INSERT INTO event (slug, name) VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE SET slug = EXCLUDED.slug
RETURNING *",
)
.bind(slug)
.bind(name)
.fetch_one(pool)
.await
}
/// Reads first so the common case (the row already exists, i.e. every join after the first)
/// stays a plain SELECT and never takes a row lock.
pub async fn find_or_create(
pool: &PgPool,
slug: &str,
@@ -51,3 +69,71 @@ impl Event {
Self::create(pool, slug, name).await
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The QR code goes up and every phone posts `/join` in the same second, before the event row
/// exists. `find_or_create` reads first, so all of them miss, and all of them insert.
///
/// With a bare `INSERT`, exactly one wins and the rest get a unique violation on `slug` —
/// surfaced as a 500 on the first screen of the event, for everyone but the winner. There is no
/// retry on that path and nothing in the UI explains it.
#[sqlx::test]
async fn concurrent_first_joins_all_get_the_same_event(pool: PgPool) {
let racers: Vec<_> = (0..16)
.map(|_| {
let pool = pool.clone();
tokio::spawn(
async move { Event::find_or_create(&pool, "wedding", "Hochzeit").await },
)
})
.collect();
let mut ids = Vec::new();
for r in racers {
let event = r
.await
.expect("task panicked")
.expect("a concurrent first join must not fail — this is the QR-scan burst");
ids.push(event.id);
}
assert_eq!(ids.len(), 16);
assert!(
ids.iter().all(|id| *id == ids[0]),
"every racer must land on ONE event row, not create rivals"
);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM event WHERE slug = 'wedding'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count, 1, "exactly one event row may exist for a slug");
}
/// A late arrival must not clobber the row it collides with — the no-op `DO UPDATE` exists to
/// return the loser a row, not to let it rewrite one mid-event.
#[sqlx::test]
async fn a_late_create_does_not_disturb_the_existing_row(pool: PgPool) {
let first = Event::find_or_create(&pool, "wedding", "Hochzeit")
.await
.expect("first");
sqlx::query("UPDATE event SET name = $1, export_epoch = 7 WHERE id = $2")
.bind("Anna und Ben")
.bind(first.id)
.execute(&pool)
.await
.expect("simulate a live event");
let late = Event::create(&pool, "wedding", "Hochzeit")
.await
.expect("a colliding insert must still return the row");
assert_eq!(late.id, first.id);
assert_eq!(late.name, "Anna und Ben", "the name must survive");
assert_eq!(late.export_epoch, 7, "and so must the export epoch");
}
}

View File

@@ -77,10 +77,21 @@ impl Upload {
//
// The conflict target repeats the index's `WHERE` clause because it is a partial index;
// without it Postgres cannot prove which index to use and rejects the statement.
//
// KEEP THIS IN LOCKSTEP WITH `upload_client_upload_id_key` (migrations 026 and 031). The
// predicate here must match the index's, or the arbiter cannot be inferred and every
// upload that carries a `client_upload_id` fails as a runtime 500 — queries in this
// codebase are not compile-time checked, so nothing catches a drift at build time.
//
// `deleted_at IS NULL` is what makes a retry-after-delete work instead of 409ing forever:
// the key is claimed only while a LIVE row holds it, which is what
// `find_by_client_upload_id` below has always assumed. `OR taken_down_by_host` carves the
// moderation case back out — see migration 031: releasing the key for a HOST takedown let
// a late retry resurrect a photo the host had deliberately removed.
sqlx::query_as::<_, Self>(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL DO NOTHING
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host) DO NOTHING
RETURNING *",
)
.bind(event_id)
@@ -115,6 +126,29 @@ impl Upload {
.await
}
/// Was this key claimed by a row the HOST took down?
///
/// Only used to answer a refused retry honestly. Without it the guest's queue shows
/// "Dieser Upload wurde bereits verarbeitet." for a photo that was in fact removed by the
/// hosts — technically true, actively misleading, and it invites them to try again.
pub async fn taken_down_by_client_upload_id(
pool: &sqlx::PgPool,
user_id: Uuid,
client_upload_id: Uuid,
) -> Result<bool, sqlx::Error> {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (
SELECT 1 FROM upload
WHERE client_upload_id = $1 AND user_id = $2
AND deleted_at IS NOT NULL AND taken_down_by_host
)",
)
.bind(client_upload_id)
.bind(user_id)
.fetch_one(pool)
.await
}
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
@@ -229,6 +263,18 @@ impl Upload {
.await
}
/// Read the lifetime derivative-attempt counter WITHOUT charging it.
///
/// Used by the in-request retries after the first: those re-enter `do_process` but must not
/// spend the lifetime budget again (see `charge_lifetime_attempt`). `None` still means the
/// row vanished, so the caller's "nothing to do" branch keeps working unchanged.
pub async fn derivative_attempts(pool: &PgPool, id: Uuid) -> Result<Option<i16>, sqlx::Error> {
sqlx::query_scalar("SELECT derivative_attempts FROM upload WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}
/// Store why the last derivative attempt failed. Diagnostics only — nothing branches on it.
pub async fn record_derivative_failure(
pool: &PgPool,
@@ -268,20 +314,27 @@ impl Upload {
/// dropped handler future, a failed second tx), the taken-down photo stays in the downloadable
/// archive forever, and recovery can't tell — the keepsake still looks complete at the current
/// epoch, and the host can no longer even find the upload to retry.
///
/// `by_host` records WHO removed it, which decides whether the row keeps holding its
/// idempotency key — see migration 031. A host takedown holds it, so a late retry from the
/// uploader's queue cannot bring the photo back; a guest deleting their own photo releases it,
/// so their next upload of the same queue item succeeds.
pub async fn soft_delete_in_event(
conn: &mut sqlx::PgConnection,
id: Uuid,
event_id: Uuid,
by_host: bool,
) -> Result<bool, sqlx::Error> {
let tx = conn;
let row: Option<(Uuid, i64)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
SET deleted_at = NOW(), taken_down_by_host = $3
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
)
.bind(id)
.bind(event_id)
.bind(by_host)
.fetch_optional(&mut *tx)
.await?;
let deleted = if let Some((user_id, bytes)) = row {

View File

@@ -47,19 +47,41 @@ impl User {
event_id: Uuid,
display_name: &str,
pin_hash: &str,
client_join_id: Option<Uuid>,
) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>(
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
VALUES ($1, $2, $3)
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, client_join_id)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(event_id)
.bind(display_name)
.bind(pin_hash)
.bind(client_join_id)
.fetch_one(pool)
.await
}
/// Look up a join that already succeeded, by the idempotency key its client sent.
///
/// The retry path for H16: the account was created but the response never arrived, so the
/// client re-sends the same `client_join_id`. Finding a row here means "this join already
/// happened" — the caller rotates the PIN and answers with a usable one rather than 409ing
/// on a name the caller itself owns.
pub async fn find_by_client_join_id(
pool: &PgPool,
event_id: Uuid,
client_join_id: Uuid,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM \"user\" WHERE event_id = $1 AND client_join_id = $2",
)
.bind(event_id)
.bind(client_join_id)
.fetch_optional(pool)
.await
}
/// Create a user with an explicit role, in ONE statement.
///
/// `create` + a separate `UPDATE ... SET role` is not equivalent: a crash or a pool error

View File

@@ -0,0 +1,314 @@
//! Append-only record of privileged actions. See migration 029 for why it exists.
//!
//! Design constraints, both learned from the rest of this codebase:
//!
//! * **Never fail the action.** An audit write that can turn a successful ban into a 500 makes
//! moderation less reliable than no audit at all. Every failure here is logged and swallowed.
//! * **Never store a credential.** `reset_pin` is the action most worth recording and the one
//! whose payload must never be in `detail` — a table that could hand back a guest's PIN would
//! be a worse privacy problem than the gap it closes.
//!
//! **Action slugs actually written**, since migration 029's header lists three (`promote_user`,
//! `demote_user`, `delete_user`) that no call site has ever emitted, and the migration file cannot
//! be corrected without changing its checksum and crash-looping every database that ran it:
//!
//! `ban_user`, `unban_user`, `set_role`, `reset_pin`, `delete_upload`, `delete_comment`,
//! `lock_uploads`, `unlock_uploads`, `release_gallery`, `delete_account`, `patch_config`.
//! Eleven, one per `audit::record` call site — grep for it if this list ages.
//!
//! **There is deliberately no read endpoint.** The table is queried by hand:
//!
//! ```sql
//! SELECT created_at, actor_name, actor_role, action, target_name, detail
//! FROM host_action_audit ORDER BY created_at DESC LIMIT 50;
//! ```
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::user::UserRole;
/// Record one privileged action.
///
/// Takes `&PgPool` rather than a transaction on purpose: the audit row is not part of the action's
/// atomicity. If the action commits and the audit write fails we want the action to stand (and a
/// loud log line); if the action rolls back, an orphan audit row saying "someone tried" is more
/// useful than silence.
#[allow(clippy::too_many_arguments)]
pub async fn record(
pool: &PgPool,
event_id: Uuid,
actor_id: Uuid,
actor_name: Option<&str>,
actor_role: UserRole,
action: &str,
target_id: Option<Uuid>,
target_name: Option<&str>,
detail: Option<Value>,
) {
// Resolve whatever names the caller did not supply.
//
// Migration 029 made `actor_id`/`target_id` deliberately non-FK so "the record survives the
// actor's account being removed, which is exactly when it is most likely to be wanted". Every
// caller passed None for both names, so what survived was a bare uuid resolving to nothing —
// the guarantee the column exists for, minus the only thing that made it readable.
//
// Resolved HERE rather than at eleven call sites so none can be missed. The one caller that
// destroys the row it is recording — `me::delete_account` — must still pass the name in, since
// by the time this runs there is nothing left to look up, and that is precisely the row a host
// will be reading the next morning ("whose photos disappeared?").
let (actor_name, target_name) =
resolve_names(pool, actor_id, actor_name, target_id, target_name).await;
let result = sqlx::query(
"INSERT INTO host_action_audit
(event_id, actor_id, actor_name, actor_role, action, target_id, target_name, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(event_id)
.bind(actor_id)
.bind(actor_name.as_deref())
// `as_str()`, not `format!("{actor_role:?}")`: the Debug spelling is not a stable wire format,
// so a `#[derive(Debug)]` change or a renamed variant would silently start writing a different
// string into a column nothing validates. `as_str` is the one the rest of the codebase uses.
.bind(actor_role.as_str())
.bind(action)
.bind(target_id)
.bind(target_name.as_deref())
.bind(detail)
.execute(pool)
.await;
match result {
Ok(_) => {}
Err(e) => {
// `error`, not `warn`: losing an audit row is the kind of thing that should show up in
// whatever is watching the logs, even though it must not fail the request.
tracing::error!(
error = ?e, action, %actor_id, ?target_id,
"failed to write host action audit row"
);
}
}
}
/// Fill in any name the caller left as `None`, in ONE query.
///
/// Best-effort by the same rule as the insert: a failed lookup writes NULL rather than failing the
/// action, and it is one round-trip whether zero, one or both names are missing.
async fn resolve_names(
pool: &PgPool,
actor_id: Uuid,
actor_name: Option<&str>,
target_id: Option<Uuid>,
target_name: Option<&str>,
) -> (Option<String>, Option<String>) {
let need_actor = actor_name.is_none();
let need_target = target_name.is_none() && target_id.is_some();
if !need_actor && !need_target {
return (
actor_name.map(str::to_owned),
target_name.map(str::to_owned),
);
}
let mut wanted: Vec<Uuid> = Vec::with_capacity(2);
if need_actor {
wanted.push(actor_id);
}
if let Some(t) = target_id
&& need_target
{
wanted.push(t);
}
let rows: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, display_name FROM \"user\" WHERE id = ANY($1)")
.bind(&wanted)
.fetch_all(pool)
.await
.unwrap_or_default();
let lookup = |id: Uuid| rows.iter().find(|(i, _)| *i == id).map(|(_, n)| n.clone());
(
actor_name.map(str::to_owned).or_else(|| lookup(actor_id)),
target_name
.map(str::to_owned)
.or_else(|| target_id.and_then(lookup)),
)
}
/// These live HERE, not in `tests/`, and that is the entire point.
///
/// `backend/` is a binary crate, so an integration test cannot import `record`. The house rule in
/// `tests/common/mod.rs` — copy the production SQL character-for-character — works for pinning
/// behaviour that already existed, but applied to a NEW fix whose only coverage is the copy it
/// proves nothing: the fix and its test become two independent implementations, and deleting the
/// fix leaves the test green. The previous `tests/audit_names.rs` did exactly that, down to
/// asserting `actor_role == "host"` against its own hardcoded `.bind("host")` — an assertion that
/// could not fail for any change to the code it named.
///
/// A `#[cfg(test)]` module inside the binary can call the real function, so these do.
#[cfg(test)]
mod tests {
use super::*;
async fn seed_event(pool: &PgPool, slug: &str) -> Uuid {
sqlx::query_scalar("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING id")
.bind(slug)
.bind("Hochzeit")
.fetch_one(pool)
.await
.expect("seed event")
}
async fn seed_user(pool: &PgPool, event_id: Uuid, name: &str) -> Uuid {
sqlx::query_scalar(
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
VALUES ($1, $2, 'x') RETURNING id",
)
.bind(event_id)
.bind(name)
.fetch_one(pool)
.await
.expect("seed user")
}
async fn audit_row(pool: &PgPool, action: &str) -> Option<(Option<String>, Option<String>)> {
sqlx::query_as(
"SELECT actor_name, target_name FROM host_action_audit
WHERE action = $1 ORDER BY created_at DESC LIMIT 1",
)
.bind(action)
.fetch_optional(pool)
.await
.expect("audit lookup")
}
/// The ordinary case: the caller supplies no names and `record` resolves both from the ids.
/// This is what nine of the eleven call sites do. Revert `resolve_names` and both names go NULL.
#[sqlx::test]
async fn a_recorded_action_carries_both_names_without_the_caller_supplying_them(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let host = seed_user(&pool, event_id, "Gastgeberin Greta").await;
let guest = seed_user(&pool, event_id, "Gesperrter Gustav").await;
record(
&pool,
event_id,
host,
None,
UserRole::Host,
"ban_user",
Some(guest),
None,
None,
)
.await;
let (actor_name, target_name) = audit_row(&pool, "ban_user").await.expect("a row");
assert_eq!(actor_name.as_deref(), Some("Gastgeberin Greta"));
assert_eq!(target_name.as_deref(), Some("Gesperrter Gustav"));
}
/// `as_str()`, not the `Debug` spelling. Asserted against `UserRole::as_str` itself rather than
/// a literal, so it tracks a rename instead of pretending to: what must hold is that the column
/// carries the SAME string the rest of the codebase uses, whatever that string is. Swap line 75
/// back to `format!("{actor_role:?}")` and this goes red on the `Host`/`host` casing.
#[sqlx::test]
async fn the_role_column_carries_the_canonical_spelling(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let host = seed_user(&pool, event_id, "Gastgeberin Greta").await;
record(
&pool,
event_id,
host,
None,
UserRole::Host,
"release_gallery",
None,
None,
None,
)
.await;
let role: String = sqlx::query_scalar(
"SELECT actor_role FROM host_action_audit WHERE action = 'release_gallery'",
)
.fetch_one(&pool)
.await
.expect("role");
assert_eq!(role, UserRole::Host.as_str());
assert_ne!(
role,
format!("{:?}", UserRole::Host),
"the Debug spelling is not a wire format"
);
}
/// The case the columns exist for. `delete_account` hard-deletes the user row, so a name
/// resolved AFTER the fact would be NULL — the caller has to pass it in.
#[sqlx::test]
async fn a_name_supplied_by_the_caller_survives_the_row_being_deleted(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let leaver = seed_user(&pool, event_id, "Abschied Anke").await;
// Exactly the order `me::delete_account` runs in: the row goes first, the audit row second.
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
.bind(leaver)
.execute(&pool)
.await
.expect("delete user");
record(
&pool,
event_id,
leaver,
Some("Abschied Anke"),
UserRole::Guest,
"delete_account",
Some(leaver),
Some("Abschied Anke"),
None,
)
.await;
let (actor_name, target_name) = audit_row(&pool, "delete_account").await.expect("a row");
assert_eq!(
actor_name.as_deref(),
Some("Abschied Anke"),
"the audit row must name the deleted account — resolving it later is impossible"
);
assert_eq!(target_name.as_deref(), Some("Abschied Anke"));
}
/// And the failure mode that made this worth testing: with nothing supplied and nothing to look
/// up, the write must still succeed (an audit row must never fail an action) and carry NULLs.
#[sqlx::test]
async fn an_unresolvable_name_writes_the_row_anyway(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let ghost = Uuid::new_v4();
record(
&pool,
event_id,
ghost,
None,
UserRole::Host,
"reset_pin",
Some(ghost),
None,
None,
)
.await;
let (actor_name, target_name) = audit_row(&pool, "reset_pin")
.await
.expect("the row must be written even when no name can be resolved");
assert_eq!(actor_name, None);
assert_eq!(target_name, None);
}
}

View File

@@ -97,7 +97,8 @@ impl CompressionWorker {
let mut attempt = 1u32;
let outcome = loop {
match worker
.do_process(upload_id, &original_path, &mime_type)
// Charge the lifetime budget once per episode, on the first attempt only.
.do_process(upload_id, &original_path, &mime_type, attempt == 1)
.await
{
Ok(v) => break Ok(v),
@@ -205,11 +206,25 @@ impl CompressionWorker {
});
}
/// `charge_lifetime_attempt` is true only for the FIRST `do_process` of a given
/// `process()` call, so the two budgets stay independent.
///
/// They were not. `MAX_PROCESS_ATTEMPTS` (in-request retries, 3) and
/// `MAX_DERIVATIVE_ATTEMPTS` (lifetime, 3) are equal, and every retry re-entered here and
/// charged the lifetime counter — so one request's three retries, six seconds apart,
/// exhausted the entire lifetime budget. A ten-second pool blip during the arrival burst
/// therefore stranded every photo whose worker was inside that window with no preview and no
/// display derivative, permanently, recoverable by nothing: the boot backfill re-selects them
/// and immediately gives up on the same exhausted counter.
///
/// The two exist to bound different things — "this request is flapping" versus "this INPUT is
/// poison" — and only the second should survive across requests.
async fn do_process(
&self,
upload_id: Uuid,
original_path: &str,
mime_type: &str,
charge_lifetime_attempt: bool,
) -> Result<()> {
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
@@ -218,8 +233,15 @@ impl CompressionWorker {
if mime_type.starts_with("image/") {
// Count the attempt BEFORE doing the work — see `begin_derivative_attempt`. If this
// input is the one that kills the container, this write is the only record that
// survives, and it is what stops the boot backfill replaying it forever.
match Upload::begin_derivative_attempt(&self.pool, upload_id).await? {
// survives, and it is what stops the boot backfill replaying it forever. Charging on
// the first attempt preserves that: a container-killing input never reaches a second.
let charged = if charge_lifetime_attempt {
Upload::begin_derivative_attempt(&self.pool, upload_id).await?
} else {
// Already charged for this episode. Re-read the row only to notice it vanished.
Upload::derivative_attempts(&self.pool, upload_id).await?
};
match charged {
Some(attempts) if attempts > Self::MAX_DERIVATIVE_ATTEMPTS => {
anyhow::bail!(
"derivative generation gave up after {} attempt(s)",
@@ -304,7 +326,6 @@ impl CompressionWorker {
/// saving rather than risk the OOM kill.
const OXIPNG_MAX_PIXELS: u64 = 8_000_000;
/// Wall-clock ceiling for one oxipng run.
///
/// Bounds TIME, NOT MEMORY — oxipng checks the deadline between trials, so a single trial
@@ -336,8 +357,10 @@ impl CompressionWorker {
// the upload handler already does via `exceeds_decode_budget`) and, if this job is a
// giant, take the exclusive permit so it cannot overlap another giant. Held for the
// whole blocking section, released on drop including on error.
let estimate =
crate::services::imaging::estimated_processing_peak_bytes(&original, Self::DISPLAY_MAX_EDGE);
let estimate = crate::services::imaging::estimated_processing_peak_bytes(
&original,
Self::DISPLAY_MAX_EDGE,
);
let _heavy_permit = match estimate {
Some(bytes) if bytes > crate::services::imaging::HEAVY_IMAGE_BYTES => {
tracing::debug!(
@@ -345,14 +368,24 @@ impl CompressionWorker {
estimated_mib = bytes / (1024 * 1024),
"waiting for the heavy-image permit"
);
Some(crate::services::imaging::HEAVY_IMAGE_PERMITS.acquire().await)
Some(
crate::services::imaging::HEAVY_IMAGE_PERMITS
.acquire()
.await,
)
}
_ => None,
};
// Run blocking image operations in a spawn_blocking task
tokio::task::spawn_blocking(move || {
write_image_derivatives(upload_id, &original, &mime_owned, &preview_path, &display_path)
write_image_derivatives(
upload_id,
&original,
&mime_owned,
&preview_path,
&display_path,
)
})
.await??;

View File

@@ -148,3 +148,104 @@ pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
_ => default,
}
}
#[cfg(test)]
mod seed_tests {
/// The value a fresh database actually ends up with for `key`, by replaying the migrations.
///
/// This exists because a `config::get_*` default is only a fallback for a MISSING key, and the
/// migrations seed nearly every key there is. So the literal in the handler is dead code on any
/// real install, and changing it changes nothing — which is exactly what happened to
/// `join_ip_rate_per_min`: it was raised 60 → 300 in `auth/handlers.rs` to stop one QR-code
/// burst from locking the venue out of `/join`, shipped, and did nothing at all, because
/// migration 017 seeds 60 and the seed wins. Nothing in the test suite could see it: the e2e
/// regression guard fires 12 concurrent joins, which is green at 60 and at 300 alike.
fn effective_seed(key: &str) -> Option<String> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.expect("migrations directory")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.to_string_lossy().ends_with(".up.sql"))
.collect();
// Version order: migrations are applied in filename order and later ones override.
files.sort();
let mut value: Option<String> = None;
for path in files {
let sql = std::fs::read_to_string(&path).expect("readable migration");
for line in sql.lines() {
let line = line.trim();
if line.starts_with("--") {
continue;
}
// Seed form: ('key', 'value')
if let Some(rest) = line.strip_prefix(&format!("('{key}',"))
&& let Some(v) = rest.split('\'').nth(1)
{
value = Some(v.to_string());
}
// Update form: UPDATE config SET value = 'new' WHERE key = 'key' AND value = 'old'
if line.starts_with("UPDATE config SET value")
&& line.contains(&format!("key = '{key}'"))
&& let Some(new) = line.split('\'').nth(1)
{
let scoped_to = line
.rsplit_once("AND value = '")
.and_then(|(_, tail)| tail.split('\'').next().map(|s| s.to_string()));
// Only applies if the current value still matches the scope it was written for.
if scoped_to.is_none() || scoped_to.as_deref() == value.as_deref() {
value = Some(new.to_string());
}
}
}
}
value
}
/// The invariant, not the number: `join_ip:{ip}` is keyed on an address the WHOLE VENUE shares
/// behind NAT, and `/join` is the one screen with no auto-retry. A ceiling near the size of the
/// party is a ceiling on the party. Asserted against the effective seed rather than the code
/// default precisely because the code default is what silently did not apply.
#[test]
fn the_join_ceiling_a_real_install_gets_is_sized_for_a_whole_venue_arriving_at_once() {
let seeded = effective_seed("join_ip_rate_per_min")
.expect("join_ip_rate_per_min must be seeded by a migration");
let seeded: usize = seeded.parse().expect("numeric");
assert!(
seeded >= 300,
"a fresh database ends up with join_ip_rate_per_min = {seeded}. Every guest shares one \
NAT address, so this is the ceiling for the entire party scanning one QR code. Raise \
it with a value-scoped UPDATE migration (see 030) — changing the default in \
auth/handlers.rs does nothing, because the seed wins."
);
}
/// Pins the other half of the same trap: the seeded value must not exceed the ceiling the
/// handler clamps to, or an operator reading `GET /admin/config` sees a number that is not the
/// one being enforced.
#[test]
fn the_seeded_recover_name_ceiling_is_within_what_the_handler_will_honour() {
let seeded = effective_seed("recover_name_rate_per_15min")
.expect("recover_name_rate_per_15min must be seeded by a migration");
let seeded: usize = seeded.parse().expect("numeric");
assert!(
seeded <= crate::auth::handlers::RECOVER_NAME_CEILING_MAX,
"seeded recover_name_rate_per_15min = {seeded} exceeds RECOVER_NAME_CEILING_MAX = {}; \
the handler clamps at the point of use, so the advertised value would be a lie.",
crate::auth::handlers::RECOVER_NAME_CEILING_MAX
);
}
/// The parser itself, against a value migration 015 really does change. Without this a bug in
/// `effective_seed` makes both tests above vacuously green.
#[test]
fn the_seed_parser_follows_a_value_through_a_later_update_migration() {
assert_eq!(
effective_seed("upload_rate_per_hour").as_deref(),
Some("100"),
"005 seeds 10 and 015 raises it to 100; reading 10 here means the UPDATE form is not \
being applied, and every assertion built on this helper is worthless."
);
assert_eq!(effective_seed("no_such_key_anywhere"), None);
}
}

View File

@@ -423,6 +423,49 @@ async fn invalidate_missing_files(
/// start immediately.
pub const REGEN_DEBOUNCE: Duration = Duration::from_secs(20);
/// Tracks when the CURRENT un-served burst of invalidations began, per event.
///
/// The starvation this fixes (B7): every invalidation bumps `export_epoch` immediately and arms a
/// worker that sleeps `REGEN_DEBOUNCE` before claiming — and any further bump inside that window
/// retires it. Nothing rate-limits epoch bumps; only the HTTP requests are limited, at 120/min for
/// comment deletion and 30/min for caption edits. So one ordinary guest deleting a comment per
/// second, or flipping a caption A/B/A, keeps `GET /export/html` at 404 for the rest of the event
/// while the UI shows "Wird vorbereitet…" forever. **A host moderating faster than one action per
/// 20 seconds produces the same result by accident** — which is what a takedown pass looks like.
///
/// The fix is to measure the debounce from the FIRST request in a burst rather than the latest, so
/// the wait is bounded no matter how long the burst runs: coalescing still collapses a rapid pass
/// into one build, but a build always starts within `REGEN_DEBOUNCE` of the burst beginning.
///
/// In-memory on purpose. It is a scheduling hint, not state: losing it on restart is harmless
/// because `recover_exports` re-arms anything unfinished at boot anyway, and the worst case of a
/// stale entry is one build starting immediately instead of debounced.
type BurstStarts = std::collections::HashMap<Uuid, std::time::Instant>;
static REGEN_BURST_START: std::sync::LazyLock<std::sync::Mutex<BurstStarts>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(BurstStarts::new()));
/// How long to defer the next regen worker for `event_id`, and record that a burst is running.
///
/// Returns `REGEN_DEBOUNCE` for the first invalidation of a burst and progressively less for
/// each one after it, reaching zero once the burst has been going for a full debounce window.
pub fn regen_delay_for(event_id: Uuid) -> Duration {
let mut map = match REGEN_BURST_START.lock() {
Ok(m) => m,
// A poisoned mutex must not take the keepsake down: fall back to the plain debounce,
// which is the pre-existing behaviour.
Err(e) => e.into_inner(),
};
let started = *map.entry(event_id).or_insert_with(std::time::Instant::now);
REGEN_DEBOUNCE.saturating_sub(started.elapsed())
}
/// A regen worker has begun (or the event settled), so the next invalidation starts a fresh burst.
pub fn clear_regen_burst(event_id: Uuid) {
if let Ok(mut map) = REGEN_BURST_START.lock() {
map.remove(&event_id);
}
}
// Export worker entry point: every argument is state the spawned worker is BORN with (notably
// `epoch`). Bundling them into a struct would be a pure-refactor risk on the epoch logic for no
// gain, so the arity stands.
@@ -451,12 +494,19 @@ pub fn spawn_export_jobs(
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
if let Err(e) =
run_zip_export(event_id, epoch, &pool, &media_path, &export_path, &sse_tx).await
{
tracing::error!("ZIP export failed for event {event_id} @ epoch {epoch}: {e:#}");
mark_failed(&pool, event_id, "zip", epoch, &e.to_string()).await;
}
// The burst has now been served: whatever arrives next starts a fresh debounce window.
clear_regen_burst(event_id);
// Run the body in its OWN task so a panic surfaces as a `JoinError` here rather than
// unwinding past `mark_failed` — see `supervise_export`.
let (p, m, x, s) = (
pool.clone(),
media_path.clone(),
export_path.clone(),
sse_tx.clone(),
);
let inner =
tokio::spawn(async move { run_zip_export(event_id, epoch, &p, &m, &x, &s).await });
supervise_export(inner, &pool, event_id, "zip", epoch).await;
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
@@ -464,25 +514,77 @@ pub fn spawn_export_jobs(
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
if let Err(e) = run_html_export(
event_id,
epoch,
&event_name2,
comments_enabled,
&pool2,
&media_path2,
&export_path2,
&sse_tx2,
)
.await
{
tracing::error!("HTML export failed for event {event_id} @ epoch {epoch}: {e:#}");
mark_failed(&pool2, event_id, "html", epoch, &e.to_string()).await;
}
let (p, m, x, s) = (
pool2.clone(),
media_path2.clone(),
export_path2.clone(),
sse_tx2.clone(),
);
let inner = tokio::spawn(async move {
run_html_export(
event_id,
epoch,
&event_name2,
comments_enabled,
&p,
&m,
&x,
&s,
)
.await
});
supervise_export(inner, &pool2, event_id, "html", epoch).await;
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
}
/// Await an export worker and make sure the job row NEVER stays at `running`.
///
/// Both workers were bare `tokio::spawn`s whose `Err` path was handled correctly — but a PANIC
/// unwound straight past `mark_failed` and `maybe_broadcast_complete`, leaving the row at whatever
/// `progress_pct` it had reached. The UI renders that as "Wird erstellt (77%)" with the download
/// disabled, permanently: nothing sweeps `running` rows, and `recover_exports` only runs at boot.
/// So the single most likely cause of a stuck keepsake was also the one the error handling missed.
///
/// Running the body as a child task turns that panic into a `JoinError` we can act on. The
/// maintenance loop already supervises itself this way; this extends the same pattern to the two
/// workers that actually produce the thing guests came for.
async fn supervise_export(
handle: tokio::task::JoinHandle<Result<()>>,
pool: &PgPool,
event_id: Uuid,
export_type: &str,
epoch: i64,
) {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!(
"{export_type} export failed for event {event_id} @ epoch {epoch}: {e:#}"
);
mark_failed(pool, event_id, export_type, epoch, &e.to_string()).await;
}
Err(join_err) => {
// Panicked, or cancelled at shutdown. Either way the row must not be left claiming to
// be in progress — a failed job the host can retry beats one that lies forever.
tracing::error!(
panicked = join_err.is_panic(),
"{export_type} export task for event {event_id} @ epoch {epoch} died without \
reporting: {join_err}"
);
mark_failed(
pool,
event_id,
export_type,
epoch,
"Interner Fehler beim Erstellen des Keepsakes. Bitte über \"Neu erzeugen\" \
erneut versuchen.",
)
.await;
}
}
}
/// Preflight that will sacrifice the previous generation rather than deadlock, and the order is
/// the whole point.
///
@@ -500,6 +602,11 @@ pub fn spawn_export_jobs(
/// generation is the one thing we can reclaim — sacrifice it and try once more. A keepsake that
/// exists beats one we preserved but can never replace.
///
/// But ONLY when the sacrifice is sufficient: we measure what pruning would free and compare it to
/// the shortfall first. Pruning and hoping meant a failed second check left nothing on disk at all
/// — no old archive and no new one — which is worse than either outcome this function chooses
/// between. When the old archive cannot buy us a rebuild, it stays.
///
/// SHARED by both halves deliberately. This started as two copies and one of them (HTML) silently
/// kept the single-phase form, so the ZIP archive rebuilt and the viewer stayed permanently stuck
/// — the exact deadlock above, on half the product. `prefix` is the only thing that differs, and
@@ -512,16 +619,160 @@ async fn ensure_export_space_reclaiming(
prefix: &str,
epoch: i64,
) -> Result<()> {
if ensure_export_space(pool, event_id, export_path).await.is_ok() {
if ensure_export_space(pool, event_id, export_path)
.await
.is_ok()
{
return Ok(());
}
tracing::warn!(
"not enough room to rebuild {prefix} alongside the previous keepsake; reclaiming it first"
);
// LOOK BEFORE YOU DESTROY.
//
// This used to prune unconditionally and then re-check. When the re-check ALSO failed, the old
// keepsake was already gone and the rebuild returned `Err` — leaving **no archive on disk at
// all**, which is strictly worse than the deadlock the reclaim exists to avoid. It needs no
// host action to reach: any guest deleting their own photo arms this path, and the doc comment
// above asserts the opposite invariant.
//
// So sacrifice the old generation only when doing so is actually sufficient. When it is not,
// that old keepsake is the only one anybody will ever have — keep it, and report the failure
// the same way the non-reclaiming check does. `rebuild_export` is the host's retry once they
// have freed space.
let deficit = match export_space_deficit(pool, event_id, export_path).await? {
// Raced back into having room (a concurrent prune, a guest deleting an upload). Nothing
// to reclaim and nothing to fail.
None => return Ok(()),
Some(d) => d,
};
let reclaimable =
reclaimable_superseded_bytes(pool, export_path, prefix, event_id, epoch).await;
// PRUNE EVEN WHEN IT IS NOT ENOUGH ON ITS OWN. This used to refuse unless
// `reclaimable >= deficit`, on the premise that "deleting it would leave no archive at all" —
// and that premise does not survive contact with `prune_superseded_archives`, which can only
// ever touch generations `n < keep_seq` that `protected_files` does not name. Those are exactly
// the archives no handler can serve: a download resolves through `export_current`, which
// requires `j.epoch = e.export_epoch`, and the epoch only ever increments. The bytes this
// branch was protecting were already unreachable from every route, and the next successful
// build deletes them anyway.
//
// What the refusal did cost was the only in-app way out of the deadlock this function exists to
// break. `reclaimable` is scoped to the caller's OWN prefix — one old archive, ~1.1x the
// gallery — while `deficit` is sized for both halves plus the 10 GB reserve. So on a tight
// disk each worker independently measures its own share as insufficient and neither prunes,
// while the two shares are JOINTLY sufficient. Every "Neu erzeugen" reruns the identical
// arithmetic and refuses identically: permanently stuck, with dead archives on the volume that
// nothing will reclaim and nothing can serve.
//
// So reclaim what we can and let the re-check below decide. If it still does not fit, we fail
// exactly as the plain preflight would — but the sibling worker's prune has now freed its share
// too, and the host's retry converges instead of looping.
if reclaimable < deficit {
tracing::warn!(
deficit,
reclaimable,
"pruning the previous {prefix} keepsake will not free the full shortfall on its own; \
reclaiming anyway — it is already unservable, and the sibling half frees the rest"
);
} else {
// `else`, not a second unconditional line: both used to fire in the shortfall case, and
// they read as contradicting each other ("will not free the shortfall" / "reclaiming it
// first") to whoever is reading logs at 2am.
tracing::warn!(
deficit,
reclaimable,
"not enough room to rebuild {prefix} alongside the previous keepsake; reclaiming it first"
);
}
prune_superseded_archives(pool, export_path, prefix, event_id, epoch).await;
ensure_export_space(pool, event_id, export_path).await
}
/// Largest share of an event's media an archive may silently omit and still publish.
///
/// Not zero, deliberately. A skip is *expected* in ordinary operation — a guest deleting their own
/// photo mid-build, or the hourly reclaim collecting an original past its retention window — and
/// the writers go out of their way to degrade one entry rather than fail the whole keepsake,
/// because a released event cannot be rebuilt by the host without reopening uploads. Making any
/// skip fatal would turn a tolerated one-photo gap into total loss of the keepsake, which is the
/// regression the TOCTOU comments in each writer warn about.
///
/// What must never happen is publishing an archive that is missing *most* of the event. 10% of a
/// ~100-photo wedding is ~10 photos: far above the one-or-two a live delete explains, far below
/// the "media_path is wrong so nothing opened" catastrophe.
const MAX_SKIPPED_FRACTION: f64 = 0.10;
/// Skips tolerated regardless of how small the event is.
///
/// A pure fraction inverts this check on a small gallery: at nine uploads one skip is 11%, so the
/// single most ordinary event there is — a guest deleting their own photo while the archive builds
/// — failed the whole keepsake. That is exactly the "one-photo gap becomes total loss" outcome
/// [`MAX_SKIPPED_FRACTION`]'s own comment says it exists to avoid, and it is worst early on, when
/// a host testing a release has only a handful of photos.
///
/// Two is the number of concurrent live deletes worth absorbing; beyond that on a tiny gallery the
/// `written == 0` guard still catches the misconfiguration case, which is the one that matters.
const MIN_TOLERATED_SKIPS: usize = 2;
/// Decide whether an archive that skipped some media may still be published.
///
/// Rejects two cases:
/// * `written == 0` with media expected — the misconfiguration case. This is what produced a
/// few-hundred-byte ZIP containing zero photos that passed every automated check, was
/// advertised green, and was handed to every guest.
/// * more than [`MAX_SKIPPED_FRACTION`] omitted — enough missing that the archive misrepresents
/// the event even though it is structurally valid.
///
/// A tolerated partial still logs at `error` level with the exact counts, so the gap is visible in
/// the record rather than buried in per-entry warnings nobody aggregates.
fn check_export_completeness(
kind: &str,
event_id: Uuid,
expected: usize,
written: usize,
skipped: usize,
) -> Result<()> {
if skipped == 0 {
return Ok(());
}
if written == 0 && expected > 0 {
anyhow::bail!(
"{kind}-Export enthält keine einzige Datei ({expected} erwartet, alle \
übersprungen). Das deutet auf einen falschen MEDIA_PATH oder fehlende \
Zugriffsrechte hin — das Keepsake wurde NICHT veröffentlicht."
);
}
// A PROPORTIONAL skip is loud, but it is NOT fatal, and the difference is the whole point.
//
// This used to `bail!` once skips passed `max(2, 10% of expected)`. That inverts
// MAX_SKIPPED_FRACTION's own justification two doc comments up — "making any skip fatal would
// turn a tolerated one-photo gap into total loss of the keepsake" — because the refusal is
// DETERMINISTIC ACROSS RETRIES. The files that could not be read are still unreadable when the
// host taps "Neu erzeugen", and by then the gallery is released and the uploads cannot be
// collected again. So on a 30-photo event, 4 unreadable originals stopped publishing the other
// 26 — not once, but forever. Refusing to hand over an incomplete keepsake is only defensible
// if something better can still arrive; here nothing can.
//
// `written == 0` above stays fatal, and it is the case that actually mattered: a wrong
// MEDIA_PATH produced a few-hundred-byte archive with zero photos that passed every automated
// check and was advertised as ready. That one is a misconfiguration the host CAN fix and retry.
//
// Everything short of that publishes, with the counts at `error` level so the gap is in the
// record rather than buried in per-entry warnings. A partial keepsake is what the guests get to
// keep; an aborted one is nothing at all.
let budget = MIN_TOLERATED_SKIPS.max((expected as f64 * MAX_SKIPPED_FRACTION).floor() as usize);
let level_note = if expected > 0 && skipped > budget {
"MATERIALLY INCOMPLETE — published anyway because a refused rebuild is not recoverable"
} else {
"published with missing media"
};
tracing::error!(
%event_id, kind, expected, written, skipped, budget,
"{level_note} — {skipped} of {expected} entries could not be read"
);
Ok(())
}
/// Take the process-wide heavy-image permit if this file is big enough to need it.
///
/// Mirrors the compression worker's gate exactly (a header probe, no pixels decoded), so the two
@@ -531,7 +782,10 @@ async fn heavy_permit_for(path: &Path) -> Option<tokio::sync::SemaphorePermit<'s
let estimate = crate::services::imaging::estimated_processing_peak_bytes(path, 2048);
match estimate {
Some(bytes) if bytes > crate::services::imaging::HEAVY_IMAGE_BYTES => {
crate::services::imaging::HEAVY_IMAGE_PERMITS.acquire().await.ok()
crate::services::imaging::HEAVY_IMAGE_PERMITS
.acquire()
.await
.ok()
}
_ => None,
}
@@ -634,6 +888,15 @@ async fn run_zip_export_inner(
let out_name = gen_name(event_id, "Gallery", epoch, ".zip");
let out_path = exports_dir.join(&out_name);
// Skips are TOLERATED but no longer SILENT — see `check_export_completeness`. All three
// writers continued past an unreadable source with only a `warn!`, nothing counted them,
// nothing reached `error_message`, and the job finalized at `progress_pct = 100,
// status = 'done'`. So a wrong `media_path` after a compose edit produced a few-hundred-byte
// ZIP with zero media that passed the liveness check, was advertised green, and was handed to
// every guest as their keepsake.
let mut written = 0usize;
let mut skipped = 0usize;
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
@@ -666,9 +929,11 @@ async fn run_zip_export_inner(
row.id,
src.display()
);
skipped += 1;
continue;
}
};
written += 1;
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = src_file.compat();
@@ -697,6 +962,11 @@ async fn run_zip_export_inner(
file.sync_all().await?;
}
// Refuse to publish an archive that lost media it was supposed to contain. Checked BEFORE the
// rename so a rejected build never reaches a servable path.
check_export_completeness("ZIP", event_id, uploads.len(), written, skipped)
.inspect_err(|_| tracing::error!("ZIP export for event {event_id}: refusing to publish"))?;
tokio::fs::rename(&tmp_path, &out_path).await?;
// Commit ONLY if our generation is still current. `finalize_job` is guarded on `epoch` — a
@@ -833,7 +1103,15 @@ async fn run_html_export_inner(
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
// (zip entry name under media/, where its bytes come from). Built here, streamed
// into the ZIP in step 5 — so we also know the exact file count without a rescan.
let mut media_manifest: Vec<(String, MediaSource)> = Vec::new();
// The bool is "this entry is the FULL variant", i.e. the one `data.json` advertises as the
// photo itself. It exists so `check_export_completeness` can count photos rather than files —
// see the call site. Exactly one full entry is pushed per upload that survives the stat.
let mut media_manifest: Vec<(String, MediaSource, bool)> = Vec::new();
// Uploads dropped at the stat below never enter `media_manifest`, so without counting them
// here a wholly-unreadable media directory yields an EMPTY manifest — expected 0, skipped 0 —
// and the completeness check downstream would wave it through as a legitimately empty event.
// These are the same loss as a skip at write time and are checked as one.
let mut upload_skipped = 0usize;
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
@@ -852,6 +1130,7 @@ async fn run_html_export_inner(
row.id,
src.display()
);
upload_skipped += 1;
continue;
}
};
@@ -922,7 +1201,15 @@ async fn run_html_export_inner(
.context("failed to save thumbnail")?;
Ok(())
})
.await?;
.await
// NOT `?`. The `?` here was on the JoinError, not on the closure's Result — so a
// decoder PANIC (the `image` crate can panic on malformed input, and a resize can
// abort on allocation) propagated out and failed the ENTIRE keepsake, where the very
// same file merely failing returns `Err` and costs one tile. Worse, it was
// deterministic: "Neu erzeugen" reads the same poison file and dies the same way. That
// is the failure shape the completeness guard was reversed to eliminate, arriving
// through the other door.
.unwrap_or_else(|e| Err(anyhow::anyhow!("thumbnail task panicked: {e}")));
// Same dangling-reference hazard as the video branch: a failure here left `thumb`
// pointing at a file the ZIP writer would then skip, so `data.json` advertised an
@@ -959,7 +1246,10 @@ async fn run_html_export_inner(
.context("failed to save compressed full image")?;
Ok(())
})
.await?;
.await
// See the thumbnail branch: a panic here must cost this one full variant (the
// original is then streamed as-is below), never the whole keepsake.
.unwrap_or_else(|e| Err(anyhow::anyhow!("full-image task panicked: {e}")));
match compress_result {
Ok(()) => MediaSource::Temp(full_path),
@@ -983,9 +1273,9 @@ async fn run_html_export_inner(
// writer skip it silently while `data.json` still advertised it — the viewer then drew a
// broken image tile for an entry the archive never contained.
if let Some(name) = &thumb_name {
media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name))));
media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name)), false));
}
media_manifest.push((full_name.clone(), full_source));
media_manifest.push((full_name.clone(), full_source, true));
// Build comments for this upload
let post_comments: Vec<ViewerComment> = comments
@@ -1069,6 +1359,18 @@ async fn run_html_export_inner(
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
// index.html (file://), where a cross-origin fetch() of a sibling file is
// blocked — so the data must be inlined rather than fetched from data.json.
// The viewer IS the keepsake — Memories.zip without it is a folder of files with no way to
// look at them. `write_viewer_with_data` walks `dir.files()`, which iterates nothing at all
// when the compiled-in directory is empty, so a viewer build that failed after Vite emptied
// its output directory used to produce a perfectly valid archive with no viewer in it,
// silently. Asserted here rather than trusted: this costs one lookup per export.
if VIEWER_DIR.get_file("index.html").is_none() {
anyhow::bail!(
"the keepsake viewer is missing from this binary (static/export-viewer/index.html \
was not compiled in). Run `npm run build` in frontend/export-viewer and rebuild — \
an archive without the viewer is not a keepsake."
);
}
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?;
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
@@ -1099,8 +1401,22 @@ async fn run_html_export_inner(
// whose ffmpeg step failed) are skipped — the viewer tolerates gaps.
let file_total = media_manifest.len().max(1) as f32;
let mut files_written = 0u32;
// Photos, not files — the number `check_export_completeness` is actually about.
//
// `files_written` counts MANIFEST ROWS, and there are up to two per upload: a thumbnail and
// a full variant. Thumbnails are 400px JPEGs this export GENERATES ITSELF into its own temp
// dir, so they are no evidence that any original was captured. Counting them meant the one
// remaining fatal case — nothing at all was written — could not fire while thumbnails kept
// succeeding: if the media volume became unreadable after the stat pass, every original
// open failed and 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.
let mut full_written = 0usize;
// See `check_export_completeness`: a viewer that tolerates gaps must still not publish an
// archive with no media in it at all.
let mut media_skipped = 0usize;
for (name, source) in &media_manifest {
for (name, source, is_full) in &media_manifest {
let path = source.path();
// Open-first: a source that disappeared between the manifest being built and now (a
// delete, or the hourly sweep reclaiming a long-failed original) must skip this entry,
@@ -1112,6 +1428,7 @@ async fn run_html_export_inner(
"HTML export: skipping media {name} — cannot read {}: {e}",
path.display()
);
media_skipped += 1;
continue;
}
};
@@ -1124,6 +1441,9 @@ async fn run_html_export_inner(
zip_entry.close().await?;
files_written += 1;
if *is_full {
full_written += 1;
}
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
if !update_progress(pool, event_id, "html", epoch, pct.min(98)).await {
return Err(Superseded.into());
@@ -1135,6 +1455,21 @@ async fn run_html_export_inner(
let mut file = zip.close().await?.into_inner();
file.flush().await?;
file.sync_all().await?;
// Before the rename, so a rejected viewer never reaches a servable path. Inside this
// scope because `media_manifest` and the counters are scoped here.
// Measured against the full upload set, not the manifest: an upload dropped at the stat
// and one dropped at the write are the same loss to the guest looking for their photo.
check_export_completeness(
"HTML",
event_id,
uploads.len(),
full_written,
upload_skipped + media_skipped,
)
.inspect_err(|_| {
tracing::error!("HTML export for event {event_id}: refusing to publish")
})?;
}
// 6. Finalise
@@ -1246,9 +1581,21 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
/// `export_job` (that is the point of the design: one write retires everything). So after a reopen
/// the row is still `pending` at our epoch and this claim SUCCEEDS: the worker will build an archive
/// nobody can ever see, because retirement is enforced at READ time (`export_current` requires
/// `j.epoch = e.export_epoch`), not at write time. That is wasted work, not incorrectness — and the
/// `update_progress` liveness check bails such a worker out early. Do not "optimise" this into a
/// cross-table check: that is exactly the unsound guard we removed.
/// `j.epoch = e.export_epoch`), not at write time. That is wasted work, not incorrectness. Do not
/// "optimise" this into a cross-table check: that is exactly the unsound guard we removed.
///
/// This used to claim that "the `update_progress` liveness check bails such a worker out early".
/// IT DOES NOT, and it cannot: `update_progress`'s predicate is `epoch = ours AND status =
/// 'running'` on the JOB ROW, which a reopen does not touch — so the check returns true on every
/// tick and the worker grinds the whole gallery to completion, every ffmpeg poster and every
/// Lanczos3 resize, before its `finalize_job` writes `done` at an epoch nothing reads.
///
/// The cost is real on a 2-vCPU box: a host reopening the event mid-export — the documented
/// "oops, one more photo" path — leaves a full export burning CPU and the heavy-image semaphore
/// DURING the live event, and lands a full-gallery-sized orphan that nothing reclaims until the
/// next successful build at a higher epoch. Bounded and not corrupting, so it is left as is; but
/// the mitigation the old comment promised was never there, and anyone sizing this box should
/// know that.
///
/// Errors are distinguished from a lost claim: silently treating a pool timeout as "someone else
/// owns it" left the row `pending` at 0% with no live worker and no error — a spinner forever.
@@ -1349,9 +1696,23 @@ async fn protected_files(pool: &PgPool, event_id: Uuid) -> Vec<String> {
/// a hung ffmpeg — left the event with NO archive at all, which is the one outcome the product
/// exists to prevent, at the one moment nobody is watching.
///
/// The single exception is phase 2 of `ensure_export_space_reclaiming`, where the previous
/// generation's bytes are the only way the rebuild can fit at all. Both call sites carry the full
/// reasoning; do not "restore" a pre-build prune on the strength of this function's convenience.
/// The single exception is phase 2 of `ensure_export_space_reclaiming`, and BE PRECISE ABOUT WHAT
/// THAT EXCEPTION NOW COSTS, because the guarantee above is weaker than it reads. That phase used
/// to prune only when the reclaimed bytes would actually close the shortfall. It no longer does:
/// `reclaimable` is scoped to one prefix while `deficit` covers both halves plus the reserve, so on
/// a tight disk each worker measured its own share as insufficient, neither pruned, and every "Neu
/// erzeugen" refused identically — permanently stuck, with dead archives on the volume that nothing
/// would reclaim and nothing could serve. It now prunes anyway and lets the re-check decide.
///
/// The trade that buys: a rebuild can now delete the last physical copy and THEN fail, which is
/// precisely the "no archive at all" outcome this doc argues against. It is accepted because the
/// refusal it replaces was unrecoverable — deterministic across retries — whereas this failure
/// converges once the sibling worker frees its share. But "an epoch can be rolled back, deleted
/// bytes cannot" is no longer a guarantee this module provides end to end, and a manual
/// `UPDATE event SET export_epoch = <n>` can no longer rescue that case.
///
/// Both call sites carry the full reasoning; do not "restore" a pre-build prune on the strength of
/// this function's convenience.
///
/// Narrower than [`prune_stale_export_files`] on purpose: FINAL archives only. Those are inert — a
/// superseded worker either already renamed its file (and will delete it itself when its guarded
@@ -1528,6 +1889,64 @@ async fn ensure_export_space(pool: &PgPool, event_id: Uuid, export_path: &Path)
Ok(())
}
/// How many bytes short of buildable we are, or `None` when there is already room.
///
/// Split out of `ensure_export_space` so the reclaiming wrapper can ask "would pruning be
/// enough?" BEFORE it destroys anything. Mirrors that function's arithmetic exactly; the one
/// deliberate difference is that an unresolvable mount reports `None` (fail open) rather than a
/// deficit, so a missing disk reading can never be the reason we delete the only archive.
async fn export_space_deficit(
pool: &PgPool,
event_id: Uuid,
export_path: &Path,
) -> Result<Option<u64>> {
let media_bytes = estimate_export_bytes(pool, event_id).await?;
let (armed,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM export_job
WHERE event_id = $1 AND status IN ('pending', 'running')",
)
.bind(event_id)
.fetch_one(pool)
.await
.context("counting armed export jobs")?;
let required = required_free_bytes(media_bytes, armed)
.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
let Some(free) = crate::services::disk::free_bytes(export_path) else {
return Ok(None);
};
Ok((free < required).then(|| required - free))
}
/// Sum what `prune_superseded_archives` WOULD reclaim, without deleting anything.
///
/// Deliberately shares `is_superseded_archive` and `protected_files` with the real prune, so the
/// estimate cannot drift from what actually gets removed. An unreadable directory reports 0,
/// which makes the caller refuse to prune — the safe direction.
async fn reclaimable_superseded_bytes(
pool: &PgPool,
exports_dir: &Path,
prefix: &str,
event_id: Uuid,
keep_seq: i64,
) -> u64 {
let protected = protected_files(pool, event_id).await;
let final_prefix = format!("{prefix}.{event_id}.");
let Ok(mut rd) = tokio::fs::read_dir(exports_dir).await else {
return 0;
};
let mut total = 0u64;
while let Ok(Some(entry)) = rd.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if !is_superseded_archive(&name, &final_prefix, keep_seq, &protected) {
continue;
}
total = total.saturating_add(entry.metadata().await.map(|m| m.len()).unwrap_or(0));
}
total
}
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
/// ONLY strictly-older generations (`n < keep_seq`) — never `keep_seq`'s own current file,
/// and never a NEWER generation that a concurrent re-release may already be producing (that
@@ -1916,6 +2335,34 @@ Viel Freude mit den Erinnerungen!\n";
#[cfg(test)]
mod tests {
/// The viewer must actually be compiled into the binary.
///
/// `include_dir!` over an empty directory is not an error, and `write_viewer_with_data`
/// iterates `dir.files()` — zero files, zero writes, `Ok(())`. So a viewer build that failed
/// after Vite emptied its output directory produced a binary whose Memories.zip contains every
/// photo and no way to view them, with nothing anywhere reporting it. This is the cheapest
/// place to notice, and it runs on every `cargo test`.
#[test]
fn the_keepsake_viewer_is_compiled_into_this_binary() {
let index = super::VIEWER_DIR
.get_file("index.html")
.expect("static/export-viewer/index.html must be compiled in — run `npm run build` in frontend/export-viewer");
// Not just present: substantial. An empty or truncated file would satisfy `get_file` and
// still ship a blank keepsake. The real artifact is ~235 KB with the fonts inlined.
assert!(
index.contents().len() > 50_000,
"the compiled-in viewer is only {} bytes — that is not a complete keepsake viewer",
index.contents().len()
);
// And it must be self-contained: the whole point of the inlining is that it opens from
// file:// with no network. A `/fonts/...` reference here is the bug shipping again.
let html = std::str::from_utf8(index.contents()).expect("viewer is valid UTF-8");
assert!(
!html.contains("url(/"),
"the compiled-in viewer references an external asset — it will 404 silently from file://"
);
}
use super::*;
const EVT: &str = "11111111-1111-1111-1111-111111111111";
@@ -1924,6 +2371,69 @@ mod tests {
format!("Gallery.{EVT}.")
}
#[test]
fn a_burst_of_invalidations_still_starts_a_build_within_one_window() {
// B7's invariant, and the reason the burst start is tracked at all. A per-request delay
// meant every invalidation restarted the wait, so a host moderating faster than one action
// per 20 seconds deferred the build forever — `/export/html` 404s and the UI sits on
// "Wird vorbereitet…" for the rest of the event.
//
// What must hold is that the delay is measured from the FIRST request in the burst, so the
// wait never grows: request #50 of a takedown pass is scheduled no later than request #1
// was. Coalescing survives (later requests still collapse into one build); starvation does
// not.
let event = Uuid::new_v4();
clear_regen_burst(event);
let first = regen_delay_for(event);
assert!(
first <= REGEN_DEBOUNCE,
"the first invalidation of a burst waits at most one debounce window"
);
let mut previous = first;
for _ in 0..50 {
let next = regen_delay_for(event);
assert!(
next <= previous,
"a later invalidation must never push the build further out than an earlier one"
);
previous = next;
}
// And the burst is a scheduling window, not a permanent state: once a worker has run,
// the next invalidation is a fresh burst that gets the full coalescing delay again.
clear_regen_burst(event);
assert!(
regen_delay_for(event) >= previous,
"clearing the burst starts a fresh debounce window"
);
clear_regen_burst(event);
}
#[test]
fn one_events_burst_never_schedules_another_events_rebuild() {
// The map is keyed per event because the delay is a property of THAT event's moderation
// pass. A shared window would let a busy event drag an idle one's keepsake along with it —
// or worse, let an idle event's stale entry start a busy one's build immediately, which is
// the stampede the debounce exists to prevent.
let busy = Uuid::new_v4();
let quiet = Uuid::new_v4();
clear_regen_burst(busy);
clear_regen_burst(quiet);
for _ in 0..10 {
regen_delay_for(busy);
}
clear_regen_burst(busy);
assert!(
regen_delay_for(quiet) <= REGEN_DEBOUNCE,
"an untouched event gets its own full window"
);
clear_regen_burst(quiet);
}
#[test]
fn a_strictly_older_archive_is_reclaimed() {
// The whole point: at the start of a rebuild at epoch 5, generation 4's archive is dead
@@ -2094,4 +2604,57 @@ mod tests {
// guarding against — the failure mode must be "refuse", never "wrap and allow".
assert_eq!(required_free_bytes(u64::MAX, 2), u64::MAX);
}
/// The completeness gate decides whether a partially-readable gallery still ships. Both
/// directions are dangerous: too strict and one live delete costs the guests every photo
/// (a released event cannot be rebuilt without reopening uploads); too loose and a wrong
/// MEDIA_PATH ships an archive that silently misrepresents the whole party.
#[test]
fn a_clean_export_and_a_totally_empty_one_are_unambiguous() {
// Nothing skipped is always fine, at any size.
assert!(check_export_completeness("zip", Uuid::nil(), 0, 0, 0).is_ok());
assert!(check_export_completeness("zip", Uuid::nil(), 100, 100, 0).is_ok());
// Everything skipped is the misconfiguration case — never publish it, at any size.
assert!(check_export_completeness("zip", Uuid::nil(), 100, 0, 100).is_err());
assert!(check_export_completeness("zip", Uuid::nil(), 3, 0, 3).is_err());
assert!(check_export_completeness("zip", Uuid::nil(), 1, 0, 1).is_err());
}
#[test]
fn a_small_gallery_survives_an_ordinary_live_delete() {
// These are the regressions a pure 10% fraction caused: on a small gallery a single
// guest deleting their own photo mid-build is >10%, so the whole keepsake failed.
assert!(check_export_completeness("zip", Uuid::nil(), 9, 8, 1).is_ok());
assert!(check_export_completeness("zip", Uuid::nil(), 3, 2, 1).is_ok());
assert!(check_export_completeness("zip", Uuid::nil(), 4, 2, 2).is_ok());
// Half a tiny gallery is published too, and logged as materially incomplete. Refusing it
// would hand the guests nothing at all rather than the three photos that DID survive, and
// the rebuild that refusal invites reads the same unreadable files again.
assert!(check_export_completeness("zip", Uuid::nil(), 6, 3, 3).is_ok());
}
#[test]
fn a_materially_incomplete_archive_is_still_published_but_shouted_about() {
// This used to assert `is_err()` past the 10% tolerance, and that was the wrong trade.
// The 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 photos
// cannot be collected again. So refusing did not buy a better archive later; it converted
// "90 of 100 photos" into "no keepsake, ever". Past the tolerance we publish and log at
// `error` with the counts.
assert!(check_export_completeness("zip", Uuid::nil(), 100, 90, 10).is_ok());
assert!(check_export_completeness("zip", Uuid::nil(), 100, 89, 11).is_ok());
assert!(check_export_completeness("zip", Uuid::nil(), 300, 200, 100).is_ok());
}
#[test]
fn an_archive_with_nothing_in_it_is_still_refused() {
// The one fatal case, and the only one the host can actually act on: a wrong MEDIA_PATH
// produced a few-hundred-byte ZIP with zero photos that passed every automated check and
// was advertised as ready. That IS recoverable — fix the path, rebuild — so refusing to
// publish it is the correct answer, unlike the partial case above.
assert!(check_export_completeness("zip", Uuid::nil(), 100, 0, 100).is_err());
// ...but an event that genuinely has no media is not an error.
assert!(check_export_completeness("zip", Uuid::nil(), 0, 0, 0).is_ok());
}
}

View File

@@ -448,6 +448,12 @@ async fn sweep_orphan_originals(pool: &PgPool, media_path: &std::path::Path) {
}
// Too young to judge: an upload committing RIGHT NOW is indistinguishable from an
// orphan, because the rename precedes the commit.
//
// This sweep is also the backstop for the one case the upload handler's drop guard
// deliberately leaks: a client disconnect while `tx.commit()` is in flight disarms
// the guard first (so a COMMIT that Postgres applied anyway keeps its file), which
// means a COMMIT that did NOT apply leaves a final-named file with no row. The
// `NOT EXISTS` check below is what reclaims it. See `upload.rs`, the disarm site.
let recent = meta
.modified()
.ok()

View File

@@ -55,15 +55,23 @@ impl MediaTotalCache {
/// quota path and the export preflight: a database blip must not turn into "every upload
/// refused". The disk-space half of the gate still applies, so a failure here degrades the
/// check to the old flat-reserve behaviour rather than disabling it.
pub async fn get(&self, pool: &PgPool) -> i64 {
pub async fn get(&self, pool: &PgPool, event_slug: &str) -> i64 {
if let Some((bytes, at)) = *self.inner.read().unwrap()
&& at.elapsed() < TTL
{
return bytes;
}
// Scoped to THIS event (H12). The unscoped `SUM(total_upload_bytes) FROM "user"` summed
// every user row in the table, so reusing the install for a second event carried the first
// one's bytes into the second one's keepsake-headroom gate — closing uploads early with a
// message about "the event's storage" being full, counting media that belongs to a party
// that already happened (and whose files are never reclaimed either).
let queried = sqlx::query_scalar::<_, Option<i64>>(
"SELECT SUM(total_upload_bytes)::bigint FROM \"user\"",
"SELECT SUM(u.total_upload_bytes)::bigint FROM \"user\" u
JOIN event e ON e.id = u.event_id
WHERE e.slug = $1",
)
.bind(event_slug)
.fetch_one(pool)
.await;

View File

@@ -1,3 +1,4 @@
pub mod audit;
pub mod compression;
pub mod config;
pub mod disk;

View File

@@ -7,7 +7,23 @@ use std::time::{Duration, Instant};
/// of recent requests and rejects new ones once the window is full.
#[derive(Clone)]
pub struct RateLimiter {
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
windows: Arc<Mutex<HashMap<String, Bucket>>>,
}
/// One key's recent hits, plus the window they were recorded under.
///
/// The `window` field is what makes pruning correct. `prune` used a single fixed 24 h ceiling for
/// every key, on the reasoning that 24 h is the longest window in use (export downloads) — but that
/// meant a `join:{ip}:{name}` key whose 60-SECOND window expired 23 hours ago was still retained.
/// Minting one costs a single 409 and no bcrypt, at 60/min per IP across three endpoints: roughly
/// 172,800 keys/day/IP, about 31 MB/day/IP inside a 1 GB container. The limiter became the
/// memory-exhaustion primitive it exists to prevent.
///
/// Storing the window per key makes the sweep drop each bucket as soon as ITS OWN window has
/// elapsed, which is also what the hot path already does on every check.
struct Bucket {
hits: Vec<Instant>,
window: Duration,
}
impl RateLimiter {
@@ -34,7 +50,14 @@ impl RateLimiter {
let now = Instant::now();
let key = key.into();
let mut map = self.windows.lock().unwrap();
let timestamps = map.entry(key).or_default();
let bucket = map.entry(key).or_insert_with(|| Bucket {
hits: Vec::new(),
window,
});
// A key's window can change under it when an admin edits the limit at runtime. Track the
// current one so `prune` expires the bucket on the window actually in force.
bucket.window = window;
let timestamps = &mut bucket.hits;
timestamps.retain(|&t| now.duration_since(t) < window);
if timestamps.len() < max {
timestamps.push(now);
@@ -58,6 +81,37 @@ impl RateLimiter {
}
}
/// Is `key` already at or above `max`, WITHOUT recording a hit?
///
/// Needed by limiters whose budget is spent by an outcome rather than by the request — the
/// per-IP failed-PIN ceiling charges only on a wrong PIN, so the gate at the top of the handler
/// has to be able to ask "is this IP shut out?" without itself consuming the budget it guards.
/// Using `check_with_retry` for that would charge every *successful* recovery too, and a venue
/// full of guests legitimately recovering their own devices would lock itself out.
///
/// Returns `Err(retry_after_secs)` when exhausted, mirroring `check_with_retry` so callers can
/// build the same 429.
pub fn peek(&self, key: &str, max: usize, window: Duration) -> Result<(), u64> {
let now = Instant::now();
let mut map = self.windows.lock().unwrap();
let Some(bucket) = map.get_mut(key) else {
return Ok(());
};
bucket
.hits
.retain(|&t| now.duration_since(t) < bucket.window);
if bucket.hits.len() < max {
return Ok(());
}
let Some(&oldest) = bucket.hits.first() else {
return Ok(());
};
Err(window
.saturating_sub(now.duration_since(oldest))
.as_secs()
.max(1))
}
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
pub fn clear(&self) {
@@ -68,17 +122,23 @@ impl RateLimiter {
/// background task (see [`crate::services::maintenance`]) so that long-lived
/// processes don't accumulate one HashMap entry per IP that ever connected.
///
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
/// of which endpoint's window it was tracked under (the longest window today is
/// 24h for export downloads). If we ever add longer windows, raise this constant.
/// Expires each bucket against ITS OWN window (see [`Bucket`]), not one global ceiling. The
/// previous fixed 24 h ceiling retained per-minute keys for a full day — ~172,800 keys/day/IP
/// at 60/min across three endpoints, each mintable with a single 409 and no bcrypt.
///
/// Holds the one global mutex for the length of the sweep, and that mutex is on the hot path of
/// upload, feed, join, recover, social and export — so the retain does the cheap thing per
/// bucket and nothing else. Correct pruning also keeps the map small enough that this stays
/// cheap, which the old ceiling actively undermined.
pub fn prune(&self) {
let now = Instant::now();
let ceiling = Duration::from_secs(24 * 60 * 60);
let mut map = self.windows.lock().unwrap();
let before = map.len();
map.retain(|_, ts| {
ts.retain(|&t| now.duration_since(t) < ceiling);
!ts.is_empty()
map.retain(|_, bucket| {
bucket
.hits
.retain(|&t| now.duration_since(t) < bucket.window);
!bucket.hits.is_empty()
});
let dropped = before.saturating_sub(map.len());
if dropped > 0 {
@@ -230,15 +290,20 @@ mod tests {
fn prune_drops_keys_whose_windows_have_fully_expired() {
let rl = RateLimiter::new();
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
// so backdate the Instant directly.
// A key whose only timestamp is older than its own window. We can't sleep, so backdate
// the Instant directly. A ONE-MINUTE window here on purpose: the old prune applied a flat
// 24 h ceiling to every key, so this bucket — expired for over an hour of wall time —
// survived the sweep. That is the leak (H3), and pinning it needs a short-window key.
let ancient = Instant::now()
.checked_sub(Duration::from_secs(25 * 60 * 60))
.expect("backdating an Instant by 25h");
rl.windows
.lock()
.unwrap()
.insert("stale".to_string(), vec![ancient]);
.checked_sub(Duration::from_secs(90 * 60))
.expect("backdating an Instant by 90 minutes");
rl.windows.lock().unwrap().insert(
"stale".to_string(),
Bucket {
hits: vec![ancient],
window: MIN,
},
);
// ...alongside a key that is still inside its window.
assert!(rl.check_with_retry("live", 5, MIN).is_ok());

View File

@@ -13,6 +13,29 @@ use rand::Rng;
/// stream open. Tickets are consumed on use and expire after `TTL`.
const TTL: Duration = Duration::from_secs(30);
/// Lifetime of a `Download` ticket, and it is deliberately far longer than [`TTL`].
///
/// A keepsake is up to ~1.4 GB over hotel or cellular wifi, so the download itself outlives a 30 s
/// window many times over — and a resumed transfer arrives minutes or hours after the ticket was
/// minted. A `Download` ticket is therefore a short-lived capability for ONE archive rather than a
/// single-shot nonce: [`SseTicketStore::redeem_download`] does not remove it, so a client may
/// resume with `Range` as many times as the transfer needs.
///
/// The abuse this does NOT open: the ticket is bound to a session (revoked with it), only mints at
/// `/export/ticket` where the 3/day limit is charged, and grants nothing but this event's own
/// keepsake — which every authenticated guest is entitled to download anyway. What it buys is that
/// one dropped connection no longer costs a guest a third of their daily allowance, at the
/// emotional payoff of the product.
const DOWNLOAD_TTL: Duration = Duration::from_secs(6 * 60 * 60);
/// The lifetime that applies to a given kind.
fn ttl_for(kind: TicketKind) -> Duration {
match kind {
TicketKind::Download(_) => DOWNLOAD_TTL,
TicketKind::Sse => TTL,
}
}
/// Ceiling on outstanding tickets across the whole process.
///
/// Not really about the bytes (~120 each) — about `issue` having had no bound of any kind.
@@ -23,6 +46,20 @@ const MAX_TICKETS: usize = 4096;
/// EventSources concurrently; 4 absorbs that without letting a reconnect loop accumulate.
const MAX_TICKETS_PER_SESSION: usize = 4;
/// How many times one download ticket may be redeemed.
///
/// Making the ticket non-consuming is what lets a dropped transfer resume without spending another
/// of the guest's three daily downloads — but unbounded it also meant a single mint was an
/// unlimited download key for six hours, at BOTH archive endpoints, with the per-day limiter
/// (charged only at mint) never moving. On a 40 GB box serving ~1.4 GB archives that is the one
/// resource an ordinary guest could exhaust without doing anything obviously wrong.
///
/// 20 is far more than a resumed transfer needs (a browser retries a handful of times, not dozens)
/// and turns "unbounded until the ticket expires" into a bounded multiple. It does not make the
/// daily limit exact — that would mean charging per redemption, which would bill a client that
/// restarts from byte 0 instead of sending a `Range`, i.e. re-break the thing this exists to fix.
const MAX_DOWNLOAD_REDEMPTIONS: u32 = 20;
/// What a ticket may be redeemed for.
///
/// The store began life serving only SSE and stayed untyped when the export download started
@@ -39,8 +76,23 @@ const MAX_TICKETS_PER_SESSION: usize = 4;
pub enum TicketKind {
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
Sse,
/// Downloads an export archive (`GET /export/{zip,html}`). Expensive, rate-limited per day.
Download,
/// Downloads ONE export archive. Expensive, rate-limited per day.
///
/// The archive is part of the ticket, not incidental to it. A bare `Download` ticket was
/// accepted by BOTH `/export/zip` and `/export/html` — they share one authenticator — so with
/// the redemption budget that makes a ticket resumable, a single mint authorised 20 transfers
/// spread across both archives. Three mints a day therefore bought 60 full downloads of a
/// ~1.4 GB keepsake, while the per-day limiter (charged only at mint) never moved.
Download(ExportKind),
}
/// Which archive a [`TicketKind::Download`] is good for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExportKind {
/// `Gallery.<event>.<n>.zip` — the original media.
Zip,
/// `Memories.<event>.<n>.zip` — the offline HTML viewer.
Html,
}
#[derive(Clone)]
@@ -53,6 +105,9 @@ struct Entry {
token_hash: String,
issued_at: Instant,
kind: TicketKind,
/// Times this ticket has been redeemed. Only meaningful for `Download` — see
/// [`MAX_DOWNLOAD_REDEMPTIONS`].
redemptions: u32,
}
impl SseTicketStore {
@@ -83,14 +138,26 @@ impl SseTicketStore {
// Prune on issue rather than only hourly. This alone changes the bound from "tickets
// minted since the last maintenance tick" to "tickets live at once", which is what the
// 30 s TTL was always meant to express.
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
// Cap the caller's own outstanding tickets, evicting their oldest. NOT one-per-session:
// two tabs sharing a token open their EventSources concurrently, and having tab B
// invalidate tab A's unconsumed ticket looks exactly like a flaky SSE connection.
//
// SCOPED TO THE SAME KIND, which matters now that `Download` tickets live 6 h instead of
// being consumed on first use. A long-lived download ticket is ALWAYS the oldest entry for
// its session, so a kind-blind cap made it the first thing ordinary SSE churn threw away —
// and `/export` itself opens an SSE connection on the very session that just minted it.
// A couple of reconnects during the download (a wifi flap is enough; each attempt that
// returns early abandons an unconsumed ticket) evicted the ticket out from under a running
// transfer, so the next `Range` resume 401'd and the guest had to spend another of their
// three daily downloads. Two flaps and they were locked out of their own keepsake for a day.
//
// Per-kind, an SSE reconnect storm can still only evict SSE tickets, which is what the cap
// was written for; the guest's in-flight keepsake is no longer collateral.
let mut mine: Vec<(String, Instant)> = map
.iter()
.filter(|(_, e)| e.token_hash == token_hash)
.filter(|(_, e)| e.token_hash == token_hash && e.kind == kind)
.map(|(k, e)| (k.clone(), e.issued_at))
.collect();
if mine.len() >= MAX_TICKETS_PER_SESSION {
@@ -117,6 +184,7 @@ impl SseTicketStore {
token_hash,
issued_at: Instant::now(),
kind,
redemptions: 0,
},
);
Some(ticket)
@@ -130,10 +198,48 @@ impl SseTicketStore {
/// held, so this is not punitive — but leaving it would let a redemption loop probe the store
/// without ever spending anything, and the client has no legitimate reason to present a
/// ticket at the wrong endpoint.
/// Redeem a `Download` ticket WITHOUT consuming it.
///
/// Downloads must be resumable — see [`DOWNLOAD_TTL`]. A browser resumes by re-issuing the same
/// GET with a `Range` header, so a single-use ticket made `Accept-Ranges` a lie: the retry
/// authenticated against a ticket that the interrupted attempt had already spent, 401'd, and
/// the guest had to mint a new one, spending another of their three daily downloads. Two
/// dropped connections and they were locked out of their own keepsake for ~24 hours.
///
/// Still bound to a live session: the caller re-checks the session on every request, so
/// revoking a session (logout, "sign out everywhere", a host PIN reset) kills the download too.
pub fn redeem_download(&self, ticket: &str, want: ExportKind) -> Option<String> {
let mut map = self.inner.lock().unwrap();
let entry = map.get_mut(ticket)?;
// The archive must match the one this ticket was minted for. Both download routes share
// this authenticator, so without the payload check a ZIP ticket opened the HTML archive
// too and the redemption budget was spent across both.
if entry.kind != TicketKind::Download(want) {
tracing::warn!(
found = ?entry.kind,
?want,
"ticket presented for the wrong archive (or wrong kind); rejected"
);
return None;
}
if entry.issued_at.elapsed() > DOWNLOAD_TTL {
return None;
}
if entry.redemptions >= MAX_DOWNLOAD_REDEMPTIONS {
tracing::warn!(
redemptions = entry.redemptions,
"download ticket exceeded its redemption budget; refusing"
);
return None;
}
entry.redemptions += 1;
Some(entry.token_hash.clone())
}
pub fn consume(&self, ticket: &str, kind: TicketKind) -> Option<String> {
let mut map = self.inner.lock().unwrap();
let entry = map.remove(ticket)?;
if entry.issued_at.elapsed() > TTL {
if entry.issued_at.elapsed() > ttl_for(entry.kind) {
return None;
}
if entry.kind != kind {
@@ -151,7 +257,7 @@ impl SseTicketStore {
/// long-running process doesn't accumulate stale tickets.
pub fn prune(&self) {
let mut map = self.inner.lock().unwrap();
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
}
}
@@ -188,12 +294,14 @@ mod tests {
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
assert_eq!(
store.consume(&sse, TicketKind::Download),
store.consume(&sse, TicketKind::Download(ExportKind::Zip)),
None,
"an SSE ticket must not open the export download"
);
let dl = store.issue("h".into(), TicketKind::Download).unwrap();
let dl = store
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.consume(&dl, TicketKind::Sse),
None,
@@ -203,9 +311,13 @@ mod tests {
// And the matching cases still work, so the guard is not simply rejecting everything.
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
assert_eq!(store.consume(&sse, TicketKind::Sse).as_deref(), Some("h"));
let dl = store.issue("h".into(), TicketKind::Download).unwrap();
let dl = store
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.consume(&dl, TicketKind::Download).as_deref(),
store
.consume(&dl, TicketKind::Download(ExportKind::Zip))
.as_deref(),
Some("h")
);
}
@@ -214,7 +326,10 @@ mod tests {
fn issue_then_consume_returns_the_hash_exactly_once() {
let store = SseTicketStore::new();
let ticket = issue(&store, "hash-1");
assert_eq!(store.consume(&ticket, TicketKind::Sse).as_deref(), Some("hash-1"));
assert_eq!(
store.consume(&ticket, TicketKind::Sse).as_deref(),
Some("hash-1")
);
// Single-use: a replay of the same ticket is rejected.
assert_eq!(
store.consume(&ticket, TicketKind::Sse),
@@ -244,7 +359,10 @@ mod tests {
let store = SseTicketStore::new();
let ticket = issue(&store, "h");
store.prune(); // not expired → kept
assert_eq!(store.consume(&ticket, TicketKind::Sse).as_deref(), Some("h"));
assert_eq!(
store.consume(&ticket, TicketKind::Sse).as_deref(),
Some("h")
);
}
/// Build an entry that is already past the TTL.
@@ -257,6 +375,7 @@ mod tests {
issued_at: Instant::now()
.checked_sub(TTL + Duration::from_secs(1))
.expect("host uptime should exceed the ticket TTL"),
redemptions: 0,
},
);
}
@@ -305,7 +424,11 @@ mod tests {
.count();
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
assert!(
store.inner.lock().unwrap().contains_key(&mine[mine.len() - 1]),
store
.inner
.lock()
.unwrap()
.contains_key(&mine[mine.len() - 1]),
"the newest ticket is the one the caller is about to use"
);
assert_eq!(
@@ -330,6 +453,7 @@ mod tests {
kind: TicketKind::Sse,
token_hash: format!("session-{i}"),
issued_at: Instant::now(),
redemptions: 0,
},
);
}
@@ -344,4 +468,99 @@ mod tests {
"no existing ticket may be sacrificed to make room"
);
}
/// A download ticket is deliberately non-consuming so a dropped 1.4 GB transfer can resume
/// without spending one of the guest's three daily downloads. Unbounded, though, that made a
/// single mint an unlimited download key for six hours while the per-day limiter — charged
/// only at mint — never moved. This pins the bound without breaking resumption.
#[test]
fn a_download_ticket_resumes_freely_but_not_forever() {
let store = SseTicketStore::new();
let ticket = store
.issue("session-a".into(), TicketKind::Download(ExportKind::Zip))
.expect("fresh store should issue");
// Every redemption inside the budget returns the session, so a resumed transfer works.
for i in 0..MAX_DOWNLOAD_REDEMPTIONS {
assert_eq!(
store.redeem_download(&ticket, ExportKind::Zip).as_deref(),
Some("session-a"),
"redemption {i} should still be honoured"
);
}
// Past it the ticket is spent: the guest re-mints (and is charged) rather than holding
// an open-ended key.
assert_eq!(store.redeem_download(&ticket, ExportKind::Zip), None);
}
#[test]
fn a_download_ticket_opens_only_the_archive_it_was_minted_for() {
// Both download routes share one authenticator, so without the archive in the ticket a
// single mint was good for BOTH. Combined with the resume budget that made one mint worth
// 2 x MAX_DOWNLOAD_REDEMPTIONS transfers of a multi-GB keepsake, while the per-day limit —
// charged only at mint — never moved.
let store = SseTicketStore::new();
let zip = store
.issue("s".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.redeem_download(&zip, ExportKind::Html),
None,
"a ZIP ticket must not open the HTML archive"
);
assert_eq!(
store.redeem_download(&zip, ExportKind::Zip).as_deref(),
Some("s"),
"...and the refusal above must be about the archive, not a spent ticket"
);
let html = store
.issue("s".into(), TicketKind::Download(ExportKind::Html))
.unwrap();
assert_eq!(store.redeem_download(&html, ExportKind::Zip), None);
assert_eq!(
store.redeem_download(&html, ExportKind::Html).as_deref(),
Some("s")
);
}
#[test]
fn sse_churn_cannot_evict_a_running_download() {
// A `Download` ticket lives 6 h, so it is ALWAYS the oldest entry for its session — and a
// kind-blind per-session cap therefore threw it away first. `/export` opens its own SSE
// connection on the same session, so a couple of reconnects during the transfer evicted
// the ticket out from under it: the next `Range` resume 401'd and the guest spent another
// of their three daily downloads. Two wifi flaps and they lost their keepsake for a day.
let store = SseTicketStore::new();
let download = store
.issue("one-session".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
// Far more SSE churn than the per-session cap, all on the same session.
for _ in 0..(MAX_TICKETS_PER_SESSION * 3) {
store.issue("one-session".into(), TicketKind::Sse).unwrap();
}
assert_eq!(
store.redeem_download(&download, ExportKind::Zip).as_deref(),
Some("one-session"),
"an in-flight keepsake download must survive an SSE reconnect storm"
);
}
/// The kind split is what stops a free SSE ticket from redeeming a rate-limited download.
#[test]
fn an_sse_ticket_is_never_redeemable_as_a_download() {
let store = SseTicketStore::new();
let sse = store
.issue("session-b".into(), TicketKind::Sse)
.expect("fresh store should issue");
assert_eq!(store.redeem_download(&sse, ExportKind::Zip), None);
// And it is still usable for what it IS, so the rejection above is about kind, not
// the ticket having been quietly spent.
assert_eq!(
store.consume(&sse, TicketKind::Sse).as_deref(),
Some("session-b")
);
}
}

View File

@@ -82,12 +82,7 @@ impl UploadAdmission {
pub async fn reserve(&self, cap_bytes: usize) -> Option<OwnedSemaphorePermit> {
let mib = cap_bytes.div_ceil(1024 * 1024).max(1);
let want = u32::try_from(mib).unwrap_or(BUDGET_MIB).min(BUDGET_MIB);
match tokio::time::timeout(
WAIT,
self.permits.clone().acquire_many_owned(want),
)
.await
{
match tokio::time::timeout(WAIT, self.permits.clone().acquire_many_owned(want)).await {
Ok(Ok(permit)) => Some(permit),
// The semaphore is never closed, so `Err` here is unreachable in practice; treat it
// the same as a timeout rather than panicking on the upload path.
@@ -124,12 +119,12 @@ mod tests {
// Nothing left: a second reservation must not be granted. Raced against a short timeout so
// the test does not sit for the full WAIT.
let blocked = tokio::time::timeout(
Duration::from_millis(150),
admission.reserve(1024 * 1024),
)
.await;
assert!(blocked.is_err(), "budget exhausted, yet a reservation was granted");
let blocked =
tokio::time::timeout(Duration::from_millis(150), admission.reserve(1024 * 1024)).await;
assert!(
blocked.is_err(),
"budget exhausted, yet a reservation was granted"
);
// ...and releasing the permit makes room again, so the budget is not a one-way latch.
drop(whole);

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,9 @@ use common::*;
use sqlx::PgPool;
use uuid::Uuid;
/// SRC: `handlers/upload.rs:313-322` — the guarded quota increment, verbatim.
/// SRC: `handlers/upload.rs::create_upload` — the guarded quota increment, verbatim.
/// (Named, not line-numbered: the previous pointer drifted by ~130 lines and landed in unrelated
/// code, which is how a hand-copied fixture silently stops matching its original.)
/// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0.
async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
sqlx::query(
@@ -146,7 +148,8 @@ async fn quota_guard_is_atomic_under_concurrent_transactions(pool: PgPool) {
// 6. The `FOR SHARE` upload lock vs. the release
// ─────────────────────────────────────────────────────────────────────────────
/// SRC: `handlers/upload.rs:297-303` — the in-transaction re-check under a row lock, verbatim.
/// SRC: `handlers/upload.rs::create_upload` — the in-transaction `FOR SHARE` re-check, verbatim.
/// (Named, not line-numbered — see the note on `quota_inc`.)
async fn lock_and_read_event(
tx: &mut sqlx::PgConnection,
event_id: Uuid,
@@ -254,12 +257,19 @@ async fn for_share_upload_lock_serializes_against_release(pool: PgPool) {
}
/// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE`
/// re-read sees `export_released_at` set and the handler rejects it with `UploadsLocked`.
/// re-read sees `export_released_at` set and the handler bails out.
///
/// PREVENTS: the same lost photo, on the losing side of the race — a photo committing AFTER the
/// export snapshot would be in the live feed but missing from the keepsake. Rejecting is the correct
/// outcome, and it is reversible: `UploadsLocked` (not Forbidden) tells the client to keep the blob
/// and resume when the host reopens.
/// outcome, and it is reversible: the client keeps the blob and resumes when the host reopens.
///
/// SCOPE, because the name overstates it: this asserts only what the LOCKED READ observes. It does
/// not go through the handler, so it says nothing about which error the handler picks. That
/// distinction is load-bearing — `create_upload` answers a released gallery with `GalleryReleased`
/// and a plain lock with `UploadsLocked`, in that order, and the two drive different client
/// behaviour (a `reopen` park vs. a retry). The ordering is covered end-to-end by
/// `e2e/specs/10-flow-review/upload-lock-code.spec.ts` and `02-upload/retry-after-release.spec.ts`;
/// this test's doc used to claim `UploadsLocked` outright and was simply wrong after that split.
#[sqlx::test]
async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
@@ -295,3 +305,99 @@ async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool
);
tx.rollback().await.unwrap();
}
/// SRC: `handlers/me.rs::delete_account` — the last-operator guard, verbatim.
///
/// Returns the ids of the OTHER live operators, holding a row lock on each. The handler refuses the
/// deletion when this is empty.
async fn other_operators(tx: &mut sqlx::PgConnection, event_id: Uuid, self_id: Uuid) -> Vec<Uuid> {
sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
.bind(event_id)
.execute(&mut *tx)
.await
.expect("advisory lock");
sqlx::query_scalar(
"SELECT id FROM \"user\"
WHERE event_id = $1 AND id != $2
AND role IN ('host', 'admin') AND is_banned = FALSE",
)
.bind(event_id)
.bind(self_id)
.fetch_all(tx)
.await
.expect("other_operators")
}
/// Two hosts deleting themselves at the same moment must not both succeed.
///
/// The guard used to run on the pool BEFORE the transaction opened, so each deleter saw the other,
/// both passed, and the event was left with no operator at all — nobody to moderate, nobody to
/// release the gallery, and no way to appoint anyone, because appointing requires a host. Not
/// recoverable from inside the app.
///
/// A transaction-scoped ADVISORY lock serialises them. A row lock on the other operators would
/// deadlock instead — each deleter locks the other's row and then tries to delete its own, so
/// Postgres kills one with a deadlock error; the invariant survives but the loser gets a 500. A
/// lock on the `event` row would serialise cleanly but inverts the order every moderation path
/// takes (upload/user rows first, event last). The advisory lock is a separate space, so it cannot
/// interact with the row-lock graph at all: the loser waits, then counts zero once the winner's row
/// is gone, and is refused with a sentence instead of an error.
#[sqlx::test]
async fn two_hosts_deleting_at_once_cannot_both_leave_the_event(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let a = seed_user(&pool, event_id, "Gastgeber Anton").await;
let b = seed_user(&pool, event_id, "Gastgeberin Berta").await;
for id in [a, b] {
sqlx::query("UPDATE \"user\" SET role = 'host' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("promote");
}
// A opens first and takes the lock on B's row.
let mut tx_a = pool.begin().await.expect("tx a");
let a_sees = other_operators(&mut tx_a, event_id, a).await;
assert_eq!(a_sees, vec![b], "A must see B as the remaining operator");
// B now tries the same and blocks on A's row. Spawned, because it cannot return until A
// commits — which is precisely the serialisation under test.
let pool_b = pool.clone();
let b_task = tokio::spawn(async move {
let mut tx_b = pool_b.begin().await.expect("tx b");
let seen = other_operators(&mut tx_b, event_id, b).await;
tx_b.commit().await.expect("commit b");
seen
});
// Give B a moment to actually reach the lock rather than racing past it.
tokio::time::sleep(Duration::from_millis(300)).await;
// A completes its deletion.
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
.bind(a)
.execute(&mut *tx_a)
.await
.expect("delete a");
tx_a.commit().await.expect("commit a");
let b_sees = b_task.await.expect("b task");
assert!(
b_sees.is_empty(),
"B unblocked and must now see NO remaining operator (A is gone), so its deletion is \
refused — it saw {b_sees:?}"
);
// The event still has exactly one operator: B.
let remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM \"user\" WHERE event_id = $1 AND role IN ('host','admin')",
)
.bind(event_id)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(
remaining, 1,
"the event must never be left without an operator"
);
}

View File

@@ -26,7 +26,7 @@ async fn create_upload(
let row: Option<(Uuid,)> = sqlx::query_as(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL DO NOTHING
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host) DO NOTHING
RETURNING id",
)
.bind(event_id)
@@ -161,7 +161,10 @@ async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
.await
.expect("first insert");
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
// `taken_down_by_host = FALSE` — the GUEST deleted their own photo. See migration 031 and the
// sibling test below: the two cases must behave differently, and this is the one that frees
// the key.
sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = FALSE WHERE id = $1")
.bind(id)
.execute(&pool)
.await
@@ -172,4 +175,94 @@ async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
None,
"a soft-deleted upload must not be replayed"
);
// The other half of that rule, and the half that was missing (H9). Asserting only that the
// lookup returns None left the index free to disagree with it: migration 022's predicate
// covered soft-deleted rows, so the retry's INSERT hit `ON CONFLICT DO NOTHING` against the
// dead row, the replay lookup above then found nothing, and the handler answered 409 — which
// the client classifies terminal and purges the blob for. The photo was gone from the phone
// AND absent from the gallery, with no way back.
//
// Migration 026 narrowed the index to live rows so a retry after a delete inserts a FRESH
// upload, which is what `find_by_client_upload_id`'s own doc comment always claimed happened.
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert!(
retried.is_some(),
"a retry after the guest deleted the photo must create a fresh upload, not 409 forever"
);
assert_ne!(
retried,
Some(id),
"the retry must be a new row, not the dead one"
);
assert_eq!(
find_by_key(&pool, user_id, key).await,
retried,
"the live row is the one the replay lookup must now find"
);
}
/// The mirror of the test above, and the case migration 026's rationale did not consider.
///
/// `deleted_at` is set by the guest deleting their own photo AND by `host_delete_upload`. Freeing
/// the idempotency key on both meant a takedown could be silently undone: the guest's response was
/// lost, so their queue still holds the item; the host removes the photo (bumping the keepsake
/// epoch and rebuilding the archive without it); 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. The host has to find and delete it a second time.
///
/// Migration 031 keeps the key claimed for a host takedown, so the retry resolves to the duplicate
/// path and is refused. Refusing is the correct answer here: the photo was deliberately removed.
#[sqlx::test]
async fn a_host_takedown_is_not_undone_by_a_late_retry(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await;
let user_id = seed_user(&pool, event_id, "Übermütiger Uwe").await;
let key = Uuid::new_v4();
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
.await
.expect("first insert");
// SRC: `models/upload.rs::Upload::soft_delete_in_event` with `by_host = true`.
sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = TRUE WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("host takedown");
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
assert_eq!(
retried, None,
"a retry after a HOST takedown must be suppressed — otherwise the phone silently \
reinstates a photo the hosts removed"
);
let live: i64 = sqlx::query_scalar(
"SELECT count(*) FROM upload WHERE client_upload_id = $1 AND deleted_at IS NULL",
)
.bind(key)
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(live, 0, "the taken-down photo must stay gone");
// And the handler must be able to tell the guest WHY, rather than "already processed".
// SRC: `models/upload.rs::Upload::taken_down_by_client_upload_id`.
let was_taken_down: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1 FROM upload
WHERE client_upload_id = $1 AND user_id = $2
AND deleted_at IS NOT NULL AND taken_down_by_host
)",
)
.bind(key)
.bind(user_id)
.fetch_one(&pool)
.await
.expect("takedown lookup");
assert!(
was_taken_down,
"the refusal must be attributable to a takedown so the queue can say so"
);
}

View File

@@ -18,9 +18,18 @@ services:
logging: *default-logging
env_file: .env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
# `:?` for the same reason EVENTSNAP_VERSION and DOMAIN use it, and this is the worst place
# to omit it. These are interpolated into `environment:`, which OVERRIDES `env_file` — so an
# unset value does not fall back to `.env`, it resolves to the empty string and initdb
# creates a role and database literally named "". `DATABASE_URL` still points at `eventsnap`,
# so the app hits `FATAL: role "eventsnap" does not exist` forever, `pg_isready -U "" -d ""`
# never passes, `app` never turns healthy, and Caddy — gated on `service_healthy` — never
# starts, so port 443 is dead for the whole event. The only clean exit is `down -v`, which
# destroys the volume. The runbook's §3 secrets list omitted both of these, so an operator
# writing `.env` from the runbook rather than from `.env.example` walked straight into it.
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env (see .env.example)}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env (see .env.example)}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -42,6 +51,44 @@ services:
# millisecond, so connections are no longer spent waiting. Raising it back
# toward 30 means raising this limit with it.
memory: 1G
# CPU ceiling. Postgres is the one service that must never be starved: every request
# path touches it, so a CPU-bound image resize elsewhere degrades the whole event
# rather than one feature. 1.5 of 2 cores is a ceiling, not a reservation — it only
# binds when something else is competing.
cpus: '1.5'
reservations:
# Memory floor only. `reservations.cpus` USED TO BE HERE and did 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 claiming it was "the piece that actually
# protects the database" described a guarantee the box never had.
#
# It matters on a CX22: the ceilings below 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 competes on equal footing with two image resizes and
# an ffmpeg poster. `cpu_shares` is the knob that survives the translation — see the
# weights on each service.
memory: 256M
# Relative CPU weight under contention (Docker default is 1024). Only consulted when the
# CPU is actually saturated, which is exactly the moment the database must not lose.
cpu_shares: 2048
# Caps memory+swap together, so the `memory` limit above stays the real ceiling.
#
# Compose sets `Memory` but leaves `MemorySwap` unset, and Docker then permits swap EQUAL to
# the memory limit — so following the runbook's "add 2 GB of swap" step silently DOUBLES every
# container ceiling, to 5 GiB of ceilings on a 3.82 GiB box. Nothing OOMs; instead Postgres's
# working set becomes swap-eligible on a shared-tenancy VPS SSD, turning a bounded OOM-kill
# (which restarts in seconds) into unbounded latency everywhere with no signal but "it's slow".
#
# The runbook used to tell the operator to add this BY HAND, which also broke its own §0 gate
# requiring docker-compose.yml to be unmodified. Shipped here instead. 1152m against a 1G limit
# leaves 128 MB of swap — enough to absorb a spike, not enough to hide one.
#
# Verified rather than assumed: service-level `memswap_limit` DOES compose with
# `deploy.resources.limits.memory` — `docker inspect` reports Memory=1073741824
# MemorySwap=1207959552.
memswap_limit: 1152m
app:
# Production PULLS a prebuilt image; it never compiles. A release build of this crate is
@@ -82,6 +129,24 @@ services:
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
# so this is authoritative for the container.
MEDIA_PATH: /media
# Third member of the MEDIA_PATH / EXPORT_PATH family, and the nastiest of the three because
# the app itself reports nothing wrong. The healthcheck below hardcodes 127.0.0.1:3000 and
# the Caddyfile hardcodes app:3000, while `.env.example` presents APP_PORT as an ordinary
# editable line. Change it there 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 AT ALL. Port 443 is dead for the whole event and the
# only diagnostic is `dependency failed to start`.
APP_PORT: "3000"
# Fourth member of the family, pinned for a reason the other three don't have: this one is
# boot-FATAL. `db.rs` rejects an unparseable value with `bail!` rather than falling back to
# the default (right call — an operator tuning a knob that silently never applied is worse),
# which means a stray quote, a trailing inline comment, or a smart-quote pasted into `.env`
# no longer degrades anything: it exits 1, and `restart: unless-stopped` crash-loops the app
# behind a live Caddy. `.trim()` covers whitespace and CRLF; it cannot cover those.
#
# Sized to the 2 vCPU this box has, not to the guest count — see `.env.example` and the
# `db` memory limit, which must be raised together with this.
DATABASE_MAX_CONNECTIONS: "15"
# Pinned for the same reason as MEDIA_PATH: `environment` beats `env_file`, so this cannot
# be lost by an operator who copies `.env.example` and edits only the secrets — which is
# the likely path, and `.env.example` ships the generic default of `true`.
@@ -122,6 +187,31 @@ services:
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
# OOM the single box and take down Postgres.
memory: 1G
# CPU ceiling for the WHOLE app container. Bounded below 2.0 so it can never take both
# cores on its own, which is what protects Postgres.
# COMPRESSION_WORKER_CONCURRENCY=2 is the memory bound; this is the CPU one.
#
# It does NOT cap "the two image workers + ffmpeg" separately from the request path, as
# this used to claim. `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, so nothing here can tell them apart. Concretely: `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 then freezes the ENTIRE cgroup — uploads,
# feed and SSE included — for the remainder. Across a 100-photo burst (~210 s of
# draining) every request in that window can eat up to 40 ms of throttle stall.
#
# Kept anyway: an app that can take both cores starves Postgres, and every request path
# goes through Postgres. A slightly stalled request beats a starved database. If the
# backlog needs to drain faster, the knob is COMPRESSION_WORKER_CONCURRENCY, not this.
cpus: '1.2'
# Half the default weight, and this is the ceiling's other half: the cap alone leaves
# 0.8 vCPU for db + frontend + caddy, which frontend and caddy can consume between them.
# Compression is throughput work with no guest waiting on it, so it yields to Postgres —
# which every request path, including the app's own, is blocked on.
cpu_shares: 512
# See the `db` service for why this is shipped rather than hand-added: without it, the
# runbook's swap step doubles this ceiling. 1152m against a 1G limit.
memswap_limit: 1152m
frontend:
# Pulled, not built — see the note on `app` above.
@@ -161,6 +251,16 @@ services:
resources:
limits:
memory: 256M
# Node SSR is bursty and not latency-critical for guests (the app is CSR after the
# first paint), so it yields first under contention.
cpus: '0.6'
# Lowest weight of the four, for the reason above: `ssr = false`, so this serves the shell
# and then guests talk to `app` directly. A slow shell delays a reload; a slow database
# breaks the event.
cpu_shares: 256
# See the `db` service for why this is shipped rather than hand-added: without it, the
# runbook's swap step doubles this ceiling. 320m against a 256M limit.
memswap_limit: 320m
caddy:
image: caddy:2-alpine
@@ -187,6 +287,15 @@ services:
resources:
limits:
memory: 256M
# TLS termination and static serving. Small but must stay responsive — a starved
# reverse proxy makes every service look down.
cpus: '0.5'
# Left at the Docker default (1024). Caddy is cheap but sits in front of everything, so
# it must not be the bottleneck; it is capped at 0.5 vCPU regardless.
cpu_shares: 1024
# See the `db` service for why this is shipped rather than hand-added: without it, the
# runbook's swap step doubles this ceiling. 320m against a 256M limit.
memswap_limit: 320m
volumes:
postgres_data:

View File

@@ -42,6 +42,21 @@ export default ts.config(
},
},
{
ignores: ['node_modules/', 'playwright-report/', 'test-results/', '*.config.js'],
ignores: [
'node_modules/',
'playwright-report/',
'test-results/',
'*.config.js',
// Standalone dev/load-test scripts, run directly with `node`. They are not part of the
// Playwright tsconfig project, so `projectService: true` cannot type them and every one of
// them failed with "was not found by the project service" — which meant `npm run lint` had
// been exiting non-zero on main, i.e. the e2e lint gate was not gating at all.
//
// Ignoring is the honest fix rather than widening the tsconfig: these are throwaway harness
// scripts, and the type-aware rules that justify the project service (no-floating-promises)
// exist to protect TEST code.
'*.mjs',
'loadtest/',
],
}
);

View File

@@ -7,6 +7,7 @@ import { BASE } from './env';
*
* Field shape matches [backend/src/handlers/upload.rs]:
* - file (binary; carries filename + content_type in the part headers)
* - client_upload_id (uuid, optional; also sent as the X-Client-Upload-Id header)
* - caption (text, optional)
* - hashtags (CSV text, optional)
*/
@@ -17,6 +18,12 @@ export type UploadOptions = {
contentType?: string;
caption?: string;
hashtags?: string;
/**
* Idempotency key. Sent BOTH as the `X-Client-Upload-Id` header and as the multipart field,
* exactly as the real client does — the header is what lets the server replay a stored upload
* before it evaluates the lock/release gate, and the field covers the concurrent case.
*/
clientUploadId?: string;
};
export async function uploadRaw(
@@ -29,9 +36,12 @@ export async function uploadRaw(
form.append('file', blob as any, opts.filename ?? 'upload.bin');
if (opts.caption !== undefined) form.append('caption', opts.caption);
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
if (opts.clientUploadId !== undefined) form.append('client_upload_id', opts.clientUploadId);
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (opts.clientUploadId !== undefined) headers['X-Client-Upload-Id'] = opts.clientUploadId;
return fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers,
body: form as any,
});
}

View File

@@ -12,8 +12,11 @@ export class AccountPage {
this.page = page;
this.displayName = page.locator('[data-testid="account-display-name"]');
this.pinDisplay = page.locator('[data-testid="account-pin"]');
this.leaveButton = page.getByRole('button', { name: /event verlassen/i });
this.leaveConfirmButton = page.getByRole('button', { name: /^abmelden$/i });
// Keyed on testids, not visible copy. The button was renamed "Event verlassen" ->
// "Abmelden" and these locators silently went stale for a week — the smoke spec that
// guards eight of nine UA projects runs through `leaveEvent()` below.
this.leaveButton = page.getByTestId('account-logout');
this.leaveConfirmButton = page.getByTestId('confirm-sheet-confirm');
this.privacyNote = page.locator('[data-testid="privacy-note"]');
}

View File

@@ -13,6 +13,7 @@
*/
import { test, expect } from '../../fixtures/test';
import { BASE } from '../../helpers/env';
import { seedUpload } from '../../helpers/seed';
test.describe('Rate limits — guests behind a shared NAT', () => {
test('a dozen guests can all join from one IP, and 429s carry Retry-After', async ({
@@ -100,46 +101,139 @@ test.describe('Rate limits — guests behind a shared NAT', () => {
expect((await read(b.jwt)).status, 'B must not inherit As exhausted bucket').toBe(200);
});
test('one guest sweeping /recover cannot lock the venue — or the host — out of PIN recovery', async ({
api,
adminToken,
guest,
}) => {
// The sharpest version of this file's whole premise. `/recover` has a cross-name failure
// budget keyed on IP, meant to catch someone sweeping the public name list. Behind the venue
// NAT that budget is SHARED BY THE ENTIRE PARTY, and it used to be checked before the account
// was even looked up — so it refused a correct PIN.
//
// That is the host's problem specifically: hosts are promoted guests whose only credential is
// a 4-digit PIN, so /recover is their only way back in after losing a session. A guest posting
// invented names could deny it to everyone, indefinitely, for the price of ~2 requests/minute.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
recover_rate_enabled: 'true',
// Raise the per-IP VOLUME ceiling out of the way. It defaults to 30/min, and the
// cross-name FAILURE budget under test is also 30 — so the sweep below would trip the
// volume limiter first and this test would pass for the wrong reason (a 429 that proves
// nothing about whether a correct PIN survives a spent failure budget).
recover_ip_rate_per_min: '500',
});
const victim = await guest('RecoverVictim');
// Burn the shared per-IP budget with names that do not exist — the cheapest sweep, and the
// one that needs no knowledge of the guest list at all.
for (let i = 0; i < 35; i++) {
await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: `Ghost${i}-${Date.now()}`, pin: '0000' }),
});
}
// A real guest, on that same IP, with their REAL PIN, must still get in.
const res = await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: 'RecoverVictim', pin: victim.pin }),
});
expect(
res.status,
'a correct PIN must survive a spent cross-name budget — otherwise any guest can lock the ' +
'host out of the only login path they have'
).toBe(200);
// ...and the sweep is still answered as a sweep: a WRONG pin gets 429, not a bare 401, so the
// budget still does its job on the traffic it was built for.
const wrong = await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: 'RecoverVictim', pin: '0001' }),
});
expect(wrong.status, 'wrong PINs from an exhausted IP are still throttled').toBe(429);
});
test('the export limit is per-user — one guest cannot spend the whole venues quota', async ({
api,
adminToken,
guest,
host,
db,
}) => {
// The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch
// their keepsake was locked out until tomorrow.
await db.setExportReleased('e2e-test-event', true);
//
// A REAL release, not `setExportReleased`. `/export/ticket` pre-validates that the archive is
// actually servable and answers 404 without charging the limiter — deliberately, so a guest
// never spends one of their three daily downloads on an archive that cannot be served. With
// only the released FLAG set and no archive on disk, every mint here 404'd and the per-day
// limiter under test was never reached at all.
await seedUpload(host.jwt, { caption: 'for the keepsake' });
expect(
(
await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: { Authorization: `Bearer ${host.jwt}` },
})
).status
).toBe(204);
await expect
.poll(
async () => {
const s = await (
await fetch(`${BASE}/api/v1/export/status`, {
headers: { Authorization: `Bearer ${host.jwt}` },
})
).json();
return s.released === true && s.zip?.status === 'done';
},
{ timeout: 90_000, intervals: [500] }
)
.toBe(true);
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
export_rate_enabled: 'true',
export_rate_per_day: '1',
});
// The per-day export limit is charged at the MINT, not at the download: the ticket endpoint is
// the authenticated chokepoint, while `/export/zip` authenticates by ticket alone so a resumed
// transfer doesn't spend another of the guest's daily allowance. So a throttled guest is
// refused with 429 at `/export/ticket` and never reaches the archive.
//
// This helper used to destructure `ticket` from that 429 body regardless, then fetch with
// `ticket=undefined` — turning the 429 under test into an unrelated 401 from the download
// endpoint. Surface the mint's refusal instead; that IS the throttle.
const mintAndFetch = async (jwt: string) => {
const res = await fetch(`${BASE}/api/v1/export/ticket`, {
const minted = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
const { ticket } = await res.json();
if (!minted.ok) return minted;
const { ticket } = await minted.json();
return fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
};
const a = await guest('ExportFirst');
const b = await guest('ExportSecond');
// A spends their single daily allowance. The archive itself may not exist (404) —
// what matters is that the limiter admitted the request rather than 429ing it.
expect((await mintAndFetch(a.jwt)).status).not.toBe(429);
// A spends their single daily allowance — on a real archive, so this is a genuine 200 rather
// than merely "not 429", which would have been satisfied by any error at all.
expect((await mintAndFetch(a.jwt)).status, 'As first download must succeed').toBe(200);
expect((await mintAndFetch(a.jwt)).status, 'As second download is throttled').toBe(429);
// B shares A's IP and must still get their keepsake.
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by As download').not.toBe(
429
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by As download').toBe(
200
);
// And the host too, for good measure.
expect((await mintAndFetch(host.jwt)).status).not.toBe(429);
expect((await mintAndFetch(host.jwt)).status).toBe(200);
});
});
@@ -202,5 +296,22 @@ test.describe('Rate limits — /recover name cycling', () => {
const codes: number[] = [];
for (let i = 0; i < 7; i++) codes.push((await attempt()).status);
expect(codes.at(-1), 'guessing one name must still be throttled').toBe(429);
// AND THE OTHER HALF, which is the whole reason this bucket counts failures instead of
// requests: the victim's own CORRECT PIN must still let them in, from the same IP, while that
// budget is spent. Behind the venue's NAT the "attacker" and the victim are the same address,
// so a bucket that refused before verifying handed any guest a fifteen-minute lockout of any
// named person — the host included, whose only credential is a 4-digit PIN and whose only way
// back after losing a session is this endpoint. Four wrong guesses did it, and four more every
// fifteen minutes sustained it indefinitely.
const rightful = await fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: victim.displayName, pin: victim.pin }),
});
expect(
rightful.status,
'a correct PIN must authenticate even when this IP has spent the name budget'
).toBe(200);
});
});

View File

@@ -90,6 +90,14 @@ test.describe('Upload — storage quota enforcement', () => {
await api.patchConfig(adminToken, {
quota_enabled: 'true',
storage_quota_enabled: 'true',
// The per-user ceiling divides the disk budget by
// `max(active_uploaders, estimated_guest_count, 1)` — the operator's expected headcount is a
// FLOOR on the divisor, so the ceiling settles early instead of sliding down all evening as
// guests arrive. It is seeded at 100, and `setLimitTo` below solves for a target using the
// OBSERVED uploader count, so every limit it aimed for came out 100x too small and every
// "within the quota" upload 413'd. Pin it to 1 so the divisor is the count the helper
// actually controls; the floor itself is exercised by the Rust unit tests.
estimated_guest_count: '1',
});
});

View File

@@ -55,13 +55,23 @@ test.describe('Upload — a rejected upload is surfaced', () => {
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
timeout: 10_000,
});
// Both the status chip ("Gesperrt") and the server's reason ("Du bist gesperrt.") must
// render — the reason is the part that had no UI at all before.
await expect(page.getByText('Gesperrt', { exact: true })).toBeVisible();
// The server's reason ("Du bist gesperrt.") must render — it had no UI at all before.
await expect(page.getByText('Du bist gesperrt.')).toBeVisible();
// The chip reads "Fehler", NOT "Gesperrt", and that is the fix rather than a regression.
// 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 an unban restored everything
// except the photo that was actually in flight, which is the one the guest cares about.
// It is now a distinct `user_banned` code that PARKS the row (status `error`, blob kept,
// `parkedFor: 'unban'`) and resumes it when `user-shown` arrives.
await expect(page.getByText('Gesperrt', { exact: true })).toHaveCount(0);
// Positively, not just negatively: a chip that rendered empty would satisfy the line above.
await expect(page.getByText('Fehler', { exact: true }).first()).toBeVisible();
// 3. The badge must not read as success. It counted only pending/uploading before, so a
// rejected item dropped it to 0 — indistinguishable from a completed upload.
// The row is now parked (`error` + `parkedFor: 'unban'`) rather than terminally `blocked`,
// so it is the parked count that must be exactly one — and critically the blob must still
// be there, since that is what an unban replays.
await expect
.poll(
() =>
@@ -74,7 +84,10 @@ test.describe('Upload — a rejected upload is surfaced', () => {
const all = tx.objectStore('queue').getAll();
all.onsuccess = () =>
resolve(
all.result.filter((r: { status: string }) => r.status === 'blocked').length
all.result.filter(
(r: { status: string; parkedFor?: string; blob?: Blob }) =>
r.status === 'error' && r.parkedFor === 'unban' && !!r.blob
).length
);
all.onerror = () => reject(all.error);
};

View File

@@ -0,0 +1,95 @@
/**
* A retry of a photo that was ALREADY STORED must return that photo — even after the gallery has
* been released.
*
* The idempotency key arrives as a multipart field as well, and there is a replay for it in the
* handler; but 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 precisely the case that matters:
*
* the photo commits → the response is lost on the way back (the flaky-wifi failure the key
* exists for) → the host releases the gallery at the end of the night → the phone retries →
* `gallery_released`.
*
* The guest is then told a photo that is sitting in the gallery was never sent, and the remedy the
* client offers — ask the hosts to reopen — bumps `export_epoch`, retiring the whole keepsake and
* forcing a rebuild, to re-send something that was never missing.
*
* The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { BASE } from '../../helpers/env';
const SLUG = 'e2e-test-event';
function sample(): Buffer {
return readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
}
test.describe('Upload — a retry after release replays instead of refusing', () => {
test('the stored photo comes back, and the guest is not told to reopen the gallery', async ({
guest,
db,
}) => {
const g = await guest('WiederholerWilli');
const key = crypto.randomUUID();
// 1. The upload commits. In the real failure the guest never sees this response.
const first = await uploadRaw(g.jwt, sample(), {
filename: 'a.jpg',
contentType: 'image/jpeg',
clientUploadId: key,
});
expect(first.status).toBe(201);
const original = await first.json();
// 2. The host releases the gallery — the end-of-event action every queue runs into.
await db.setExportReleased(SLUG, true);
// A DIFFERENT photo must still be refused: this is the control that proves the release is
// actually in effect, so the replay below is not just "the gate was open all along".
const stranger = await uploadRaw(g.jwt, sample(), {
filename: 'b.jpg',
contentType: 'image/jpeg',
clientUploadId: crypto.randomUUID(),
});
expect(stranger.status, 'a genuinely new upload must still be refused after release').toBe(403);
expect((await stranger.json()).error).toBe('gallery_released');
// 3. The phone retries the FIRST photo. It is already in the gallery, so the honest answer is
// the stored row — not "the gallery is closed".
const retry = await uploadRaw(g.jwt, sample(), {
filename: 'a.jpg',
contentType: 'image/jpeg',
clientUploadId: key,
});
expect(
retry.status,
'a retry of an already-stored photo must be replayed, not refused with gallery_released'
).toBe(200);
const replayed = await retry.json();
expect(replayed.id, 'the replay must return the ORIGINAL upload, not a new one').toBe(
original.id
);
// 4. And no second row was created — the whole point of the key.
//
// Counted by UPLOADER, not by `id`. Filtering on `u.id === original.id` looks like a duplicate
// check and is not one: a duplicate row gets a fresh uuid, so it could never match, and the
// filter yields exactly 1 whether the gallery holds one copy or five. This guest uploaded once
// successfully ('a.jpg'); 'b.jpg' was refused at step 2 and the retry must have replayed rather
// than stored, so their total must be exactly one.
const feed = await fetch(`${BASE}/api/v1/feed?limit=100`, {
headers: { Authorization: `Bearer ${g.jwt}` },
});
const items: any[] = (await feed.json()).uploads ?? [];
const mine = items.filter((u) => u.user_id === g.userId);
expect(
mine.length,
`the retry must not have stored a second copy; got ${mine.map((u) => u.id).join(', ')}`
).toBe(1);
expect(mine[0].id, 'and the one that exists is the original').toBe(original.id);
});
});

View File

@@ -108,31 +108,25 @@ test.describe('Host — moderation from the UI', () => {
).toBeVisible({ timeout: 10_000 });
});
test('a host can remove the comment of a guest they have already banned', async ({
test('a host removes a guest comment through the lightbox, via the confirm sheet', async ({
page,
api,
host,
guest,
signIn,
}) => {
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
// on the author gets 403 on their own delete, so if the host has no removal affordance
// the comment is stuck on screen forever.
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
// uploads, so if the comment sat on their own photo the whole card would vanish and
// there would be nothing left to moderate.
const victim = await guest('PhotoOwner');
// The UI leg of host comment moderation, and the ONLY test that clicks it. The affordance is
// rendered solely by LightboxModal (`$isStaff` gates the trash button, and it routes through a
// ConfirmSheet rather than deleting on first tap). Without this, `pendingCommentDelete` could
// stop being wired to the sheet's onConfirm, or the staff gate could invert, and every
// remaining comment-moderation test would still pass — they all call the API directly.
//
// The author is NOT banned here, deliberately. A ban hides the comment from every reader
// including the host (see the next test), so a banned author's comment is unreachable in the
// UI by construction and cannot exercise this path.
const victim = await guest('LightboxPhotoOwner');
const uploadId = await seedUpload(victim.jwt);
const author = await guest('CommentOffender');
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
await api.banUser(host.jwt, author.userId);
// Confirm the deadlock really exists — the author cannot retract it themselves.
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${author.jwt}` },
});
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
const author = await guest('LightboxCommenter');
await seedComment(author.jwt, uploadId, 'bitte entfernen');
await signIn(page, host);
await page.goto('/feed');
@@ -141,9 +135,88 @@ test.describe('Host — moderation from the UI', () => {
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const comment = page.getByText('unangebrachter Kommentar');
const comment = page.getByText('bitte entfernen');
await expect(comment).toBeVisible({ timeout: 10_000 });
// "entfernen" (host removing someone else's) rather than "löschen" (deleting your own) —
// the aria-label distinguishes them and the host must get the host one.
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
// It must NOT delete on first tap; the comment is still there behind the sheet.
await expect(comment).toBeVisible();
await page.getByTestId('confirm-sheet-confirm').click();
await expect(comment).toHaveCount(0, { timeout: 10_000 });
});
test('banning hides a comment for everyone, and the host can still delete it permanently', async ({
page,
api,
host,
guest,
signIn,
}) => {
// This used to assert that the host could remove a banned author's comment FROM THE FEED,
// on the premise that a ban leaves the comment "stuck on screen forever". That premise no
// longer holds: `Comment::list_for_upload` filters `NOT u.is_banned`, so a ban hides the
// comment from every reader — host included — which is why there was nothing on screen to
// click. The export and hashtag-count queries already filtered banned authors, so this
// brought the live read path in line with them.
//
// But hiding is derived AT READ TIME, and a ban is reversible. Unbanning a guest — because
// the host was hasty, or the guest apologised — would republish the abusive comment. So the
// property worth pinning is the pair: the ban hides it immediately, and the host's permanent
// removal outlives the ban.
// The photo belongs to an innocent third party — a ban hides the banned user's OWN uploads,
// so if the comment sat on their own photo the whole card would vanish with it.
const victim = await guest('PhotoOwner');
const uploadId = await seedUpload(victim.jwt);
const author = await guest('CommentOffender');
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
const listFor = async (jwt: string) =>
(await (
await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
headers: { Authorization: `Bearer ${jwt}` },
})
).json()) as Array<{ id: string }>;
expect(
(await listFor(host.jwt)).map((c) => c.id),
'before the ban the comment is live'
).toContain(commentId);
await api.banUser(host.jwt, author.userId);
// The author cannot retract it themselves — so removal has to be the host's to make.
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${author.jwt}` },
});
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
// Gone for the host and the photo's owner alike, with no further action.
expect((await listFor(host.jwt)).map((c) => c.id)).not.toContain(commentId);
expect((await listFor(victim.jwt)).map((c) => c.id)).not.toContain(commentId);
// ...and gone from the rendered feed, which is what the host actually looks at.
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: victim.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
await expect(page.getByText('unangebrachter Kommentar')).toHaveCount(0);
// The permanent removal the host still needs: soft-delete survives an unban, so letting the
// guest back in does not republish what they were banned for.
const removed = await fetch(`${BASE}/api/v1/host/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(removed.status, 'the host can delete a banned authors comment outright').toBe(204);
await api.unbanUser(host.jwt, author.userId);
expect(
(await listFor(host.jwt)).map((c) => c.id),
'an unban must not resurrect a comment the host deleted'
).not.toContain(commentId);
});
});

View File

@@ -39,9 +39,9 @@ test.describe('Role — follows the identity across a same-tab switch', () => {
await expect(page.getByRole('button', { name: REMOVE })).toBeVisible();
await page.keyboard.press('Escape');
// 2. Host leaves, in-app — no reload. This is the path "Event verlassen" takes.
// 2. Host leaves, in-app — no reload. This is the path the "Abmelden" button takes.
await page.goto('/account');
await page.getByRole('button', { name: /event verlassen/i }).click();
await page.getByTestId('account-logout').click();
const confirm = page.getByTestId('confirm-sheet-confirm');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
await page.waitForURL('**/join', { timeout: 10_000 });

View File

@@ -59,7 +59,7 @@ test.describe('Export — the archives extract to readable files', () => {
.toBe(true);
for (const kind of ['zip', 'html'] as const) {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=${kind}`, {
method: 'POST',
headers: bearer,
});

View File

@@ -0,0 +1,85 @@
/**
* A resumed keepsake download must never splice two different archives together.
*
* The download endpoint re-resolves `export_current` on EVERY request, and a download ticket
* outlives several redemptions. So the dangerous sequence was:
*
* guest's 500 MB download drops at 500 MB
* → host takes a photo down (epoch bumps, rebuild lands, old generation pruned)
* → client resumes with `Range: bytes=500000000-`
* → server seeks 500 MB into a DIFFERENT file of a different length and streams it
* → the client concatenates the two halves into a structurally corrupt ZIP
*
* Nothing anywhere logged an error. The archive is the one artifact the whole event exists to
* produce, so a partial is now served only against a matching `If-Range` validator.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Export — a resume cannot splice two archives', () => {
test('partial content requires a matching If-Range; a blind Range restarts instead', async ({
host,
}) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
await seedUpload(host.jwt, { caption: 'resumable' });
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: bearer,
});
expect(rel.status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).zip?.status;
},
{ timeout: 45_000, intervals: [500] }
)
.toBe('done');
const mint = async () => {
const r = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
method: 'POST',
headers: bearer,
});
return (await r.json()).ticket as string;
};
const url = async () => `${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(await mint())}`;
// 1. The full download advertises a validator. Without one a browser will not even attempt a
// resume, so this header is what makes the feature work at all — and it is what the
// partial below is checked against.
const full = await fetch(await url());
expect(full.status).toBe(200);
const etag = full.headers.get('etag');
expect(etag, 'the archive must carry an ETag or no client can resume safely').toBeTruthy();
expect(full.headers.get('accept-ranges')).toBe('bytes');
// 2. A resume that PROVES continuity gets its partial.
const resumed = await fetch(await url(), {
headers: { Range: 'bytes=0-99', 'If-Range': etag! },
});
expect(resumed.status, 'a matching If-Range must still get 206').toBe(206);
expect(resumed.headers.get('content-range')).toMatch(/^bytes 0-99\/\d+$/);
// 3. A resume that cannot prove it — `curl -C -`, `wget -c`, the Android download manager —
// gets the whole file instead of a silently spliced one. Restarting a download is a cost;
// a corrupt keepsake is not recoverable.
const blind = await fetch(await url(), { headers: { Range: 'bytes=0-99' } });
expect(blind.status, 'a Range with no If-Range must NOT be served as a partial').toBe(200);
expect(blind.headers.get('content-range')).toBeNull();
// 4. And a stale validator — the exact case that used to splice — is refused a partial too.
const stale = await fetch(await url(), {
headers: { Range: 'bytes=0-99', 'If-Range': '"Gallery.some-other-event.99.zip-123"' },
});
expect(stale.status, 'an If-Range from a different generation must not get a partial').toBe(
200
);
});
});

View File

@@ -0,0 +1,73 @@
/**
* A ticket minted against a LIVE archive must stop working the moment that archive is retired.
*
* This is the download-side half of the stale-keepsake guarantee, and it was left unasserted. Both
* tests in `export.spec.ts` are named "ZIP download 404s…" but now assert only that the *mint*
* refuses — correctly, since `/export/ticket` pre-validates and refusing there spends none of the
* guest's three daily downloads. The consequence is that nothing exercised `resolve_export_file`
* on the download path itself, so removing that check would not have turned anything red.
*
* It cannot be tested by minting against an already-dead archive (the mint refuses first), so the
* order has to be: real release → mint while healthy → retire → download. Which is also exactly
* what a host taking a photo down mid-download does to a ticket already in flight.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
const SLUG = 'e2e-test-event';
test.describe('Export — a retired generation cannot be downloaded', () => {
test('a ticket minted before the epoch moved is refused at the download, not served stale', async ({
host,
db,
}) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
await seedUpload(host.jwt, { caption: 'about to go stale' });
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: bearer,
});
expect(rel.status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).zip?.status;
},
{ timeout: 45_000, intervals: [500] }
)
.toBe('done');
// Mint while everything is healthy. This must succeed, or the assertion below proves nothing —
// a 404 on a ticket that was never valid would be green for the wrong reason.
const mint = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
method: 'POST',
headers: bearer,
});
expect(mint.status, 'the ticket must be mintable while the archive is live').toBe(200);
const { ticket } = await mint.json();
expect(ticket).toBeTruthy();
// And it genuinely works right now — the positive control for the negative below.
const before = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
expect(before.status, 'the ticket must serve the archive while it is current').toBe(200);
// Now retire the generation, which is what a reopen or a post-release takedown does.
await db.setExportZipReady(SLUG, false);
// The SAME ticket — still unexpired, still under its redemption cap, session still valid —
// must now be refused. Readiness is derived at READ time from `job.epoch = event.export_epoch`,
// so this is the check that stops a superseded archive being served to a guest who happened to
// hold a ticket when the host moderated.
const after = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
expect(
after.status,
'a retired archive must 404 on the download path, not be served from a still-valid ticket'
).toBe(404);
});
});

View File

@@ -80,7 +80,7 @@ test.describe('Export — EXIF orientation in the keepsake', () => {
)
.toBe('done');
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
method: 'POST',
headers: bearer,
});

View File

@@ -51,7 +51,7 @@ test.describe('Export — no public leak (CR2)', () => {
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
// 404 above means "not public", not merely "no file was produced".
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
method: 'POST',
headers: bearer,
});

View File

@@ -53,7 +53,7 @@ test.describe('Export — video streaming (P4)', () => {
.toBe('done');
// Download Memories.zip via the gated single-use ticket.
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
method: 'POST',
headers: bearer,
});

View File

@@ -88,12 +88,14 @@ test.describe('Export — release and download', () => {
// Browser downloads stream to disk via a top-level navigation, so the download
// endpoint authenticates with a single-use ticket (no Bearer header).
async function mintTicket(jwt: string): Promise<string> {
const res = await fetch(base + '/api/v1/export/ticket', {
/** The raw mint response — `/export/ticket` pre-validates that the archive is actually
* servable, so an unavailable keepsake is refused HERE rather than after charging one of the
* guest's three daily downloads. */
async function mintTicketResponse(jwt: string, kind: 'zip' | 'html' = 'zip') {
return fetch(base + `/api/v1/export/ticket?kind=${kind}`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
return (await res.json()).ticket;
}
test('ZIP download 404s for a `done` job at a RETIRED epoch', async ({ guest, db }) => {
@@ -105,10 +107,12 @@ test.describe('Export — release and download', () => {
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.setExportZipReady(SLUG, false); // retire the job to a dead epoch
const ticket = await mintTicket(g.jwt);
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(404);
// Refused at the MINT. This used to be asserted one step later, on the download, because the
// spec did not send `kind` and so skipped the pre-check entirely — now that a ticket is bound
// to an archive the kind is always known, and the guest is told the truth before a daily
// download is spent on an archive that cannot be served.
expect((await mintTicketResponse(g.jwt)).status).toBe(404);
});
test('ZIP download 404s when the job is current but the file is missing on disk', async ({
@@ -122,9 +126,9 @@ test.describe('Export — release and download', () => {
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.setExportZipReady(SLUG, true);
const ticket = await mintTicket(g.jwt);
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(404);
// Same as above: the pre-check resolves the file on disk, so a `done` job whose archive is
// missing is refused at the mint rather than 404ing mid-download.
expect((await mintTicketResponse(g.jwt)).status).toBe(404);
});
});

View File

@@ -0,0 +1,99 @@
/**
* A mint that is REFUSED must not cost the guest the download they already have running.
*
* `/export/ticket` issues the ticket before charging the daily limit, deliberately: charging first
* meant a store-capacity 503 — a server-side condition the guest cannot see or cause — still spent
* one of their three daily downloads, with no refund path.
*
* But the ticket a refused mint created was left in the store, and it occupies a slot there for six
* hours, because a download ticket is long-lived so a multi-GB transfer can resume with `Range`.
* The per-session cap is four tickets of the same kind, so:
*
* the 1.4 GB transfer starts on ticket A → the progress bar looks stuck on venue wifi → the guest
* taps "Herunterladen" again → mints 2 and 3 succeed, 4 and 5 are refused with 429 but STILL mint
* → the fifth evicts the oldest download ticket for the session, which is A
* → the transfer drops, resumes with `Range`, and 401s
* → re-minting is impossible: they are at the daily limit
*
* The keepsake is then unreachable until the next day, for tapping a button that appeared to do
* nothing. A refused mint now discards its own ticket.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Export — a refused mint does not evict a running download', () => {
test('the first ticket still works after the daily limit has refused later mints', async ({
host,
api,
adminToken,
}) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
await seedUpload(host.jwt, { caption: 'keepsake' });
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: bearer,
});
expect(rel.status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).zip?.status;
},
{ timeout: 45_000, intervals: [500] }
)
.toBe('done');
// The e2e reseed forces every limiter OFF, so without this the daily limit never bites and the
// whole test passes vacuously. `export_rate_per_day` is seeded at 3 (migration 005).
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
export_rate_enabled: 'true',
});
try {
const mint = async () =>
fetch(`${BASE}/api/v1/export/ticket?kind=zip`, { method: 'POST', headers: bearer });
// 1. The ticket the "running" transfer holds.
const first = await mint();
expect(first.status).toBe(200);
const ticketA = (await first.json()).ticket as string;
expect(ticketA).toBeTruthy();
// 2. Two more legitimate mints, exhausting the day's three.
for (const i of [2, 3]) {
const r = await mint();
expect(r.status, `mint ${i} is still within the daily allowance`).toBe(200);
}
// 3. Two refused mints — the impatient taps. This is the CONTROL: if these came back 200 the
// limit was not in force and step 4 would prove nothing.
for (const i of [4, 5]) {
const r = await mint();
expect(r.status, `mint ${i} must be refused — the daily limit is spent`).toBe(429);
}
// 4. The running transfer resumes. Its ticket must have survived the refused mints: it is the
// guest's only remaining way to reach the keepsake today.
const resumed = await fetch(
`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticketA)}`
);
expect(
resumed.status,
'a refused mint must not evict the ticket an in-flight download is holding'
).toBe(200);
await resumed.arrayBuffer();
} finally {
await api.patchConfig(adminToken, {
rate_limits_enabled: 'false',
export_rate_enabled: 'false',
});
}
});
});

View File

@@ -69,7 +69,7 @@ test.describe('Export — a caption cannot brick the keepsake viewer', () => {
)
.toBe('done');
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
method: 'POST',
headers: bearer,
});

View File

@@ -61,7 +61,7 @@ test.describe('Export — the keepsake has no broken tiles', () => {
)
.toBe('done');
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
method: 'POST',
headers: bearer,
});
@@ -98,8 +98,18 @@ test.describe('Export — the keepsake has no broken tiles', () => {
).toBe(true);
}
// THE assertion: nothing rendered broken. Give the images a moment to settle first.
await page.waitForLoadState('networkidle');
// POSITIVE anchor FIRST, and it is load-bearing. The "nothing is broken" assertion below
// filters `img` elements, so a viewer that rendered NOTHING AT ALL yields `[]` and passes —
// this file, whose whole subject is that the images resolve, was the one spec that would
// have stayed green through a total viewer regression. Everything else it checks
// (`__EXPORT_DATA__`, the archive entries) comes from the backend and the classic head
// script, neither of which needs the viewer bundle to have run at all.
const rendered = await page.locator('img').count();
expect(rendered, 'the keepsake viewer rendered no images at all').toBeGreaterThan(0);
// THE assertion: nothing rendered broken.
const broken = await page.evaluate(() =>
Array.from(document.querySelectorAll('img'))
.filter((i) => i.complete && i.naturalWidth === 0)

View File

@@ -133,10 +133,14 @@ test.describe('Adversarial — PIN brute-force', () => {
});
statuses.push(r.status);
}
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(
0
);
expect(statuses.some((s) => s === 429), 'the attacker must be throttled').toBe(true);
expect(
statuses.filter((s) => s === 200),
'a wrong PIN must never authenticate'
).toHaveLength(0);
expect(
statuses.some((s) => s === 429),
'the attacker must be throttled'
).toBe(true);
expect(
await db.isPinLocked(g.userId),
@@ -255,29 +259,46 @@ test.describe('Adversarial — admin password brute-force', () => {
expect(statuses.some((s) => s === 200)).toBe(false);
});
test('once throttled, even the CORRECT admin password is refused (it is an IP limit, not a password check)', async ({
api,
adminToken,
}) => {
// This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the
// password logic. If the throttle were removed, the correct password would return 200 here.
test('the FAILURE bucket never refuses a correct admin password', async ({ api, adminToken }) => {
// This asserted the OPPOSITE — that a throttled IP is refused even with the right password —
// and that contract was deliberately removed, because at a real event it is a denial of
// service against the operator. Every guest at the venue shares one public IP behind NAT and
// `/admin/login` is a publicly linkable page, so a single tight IP bucket charged before the
// password check meant any phone in the room could keep it full and the host, on that same IP,
// could never spend a slot. The escape hatch was circular: `admin_login_rate_enabled` is only
// reachable through `PATCH /admin/config`, which needs the session being blocked.
//
// So the tight bucket is now charged ONLY on a wrong password. Brute force stays bounded (every
// guess costs a slot, per IP) while a valid credential is always honoured. A separate, generous
// per-IP ceiling bounds bcrypt CPU regardless of correctness — see ADMIN_LOGIN_CPU_CEILING.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
admin_login_rate_enabled: 'true',
});
// Exhaust the window with wrong passwords until throttled.
// Exhaust the failure window with wrong passwords until throttled.
let throttled = false;
for (let i = 0; i < 10 && !throttled; i++) {
throttled = (await tryLogin('wrong-' + i)).status === 429;
}
expect(throttled, 'the IP should be throttled after a burst').toBe(true);
expect(throttled, 'a burst of WRONG passwords from one IP must be rate-limited').toBe(true);
// The right password, while throttled, must STILL be refused — the limiter is checked before
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
// The limiter is real (above) and yet the operator gets in. That combination is the whole
// property: THIS bucket keys on failure, not on the IP alone.
//
// Deliberately not claimed here: "guests cannot lock the host out". They still can — the
// separate CPU ceiling below refuses any password, correct included. This test stays under
// that ceiling on purpose so the two are not conflated.
expect(
(await tryLogin(ADMIN_PASSWORD)).status,
'a throttled IP is refused even with the correct password'
'a burst of wrong guesses must not cost the operator their own admin panel'
).toBe(200);
// And guessing is still throttled AFTER a successful login — a correct password must not
// refill or bypass the attacker's bucket.
expect(
(await tryLogin('wrong-again')).status,
'a successful login must not clear the failure bucket for wrong guesses'
).toBe(429);
});

View File

@@ -101,10 +101,26 @@ test.describe('Adversarial — small-scale abuse', () => {
})
);
const responses = await Promise.all(requests);
// All accepted (or some rate-limited — both fine).
for (const r of responses) {
expect([200, 429]).toContain(r.status);
// One session may hold only MAX_TICKETS_PER_SESSION (4) live tickets — enough for a guest with
// a couple of tabs open, deliberately not enough for a reconnect loop to accumulate. Minting a
// 5th evicts the oldest, so most of these ten tickets are already dead when their stream opens
// and the server answers 401. That IS the cap working; this used to allow only [200, 429] and
// so failed on the very defence it was written to exercise.
//
// What must hold is that the server sheds the flood deliberately rather than falling over: no
// 5xx, and the surviving tickets still get their stream.
const statuses = responses.map((r) => r.status);
for (const s of statuses) {
expect([200, 401, 429], `unexpected status from the stream flood: ${statuses}`).toContain(s);
}
// EXACTLY four, not merely "at least one". The cap is a known constant, so asserting a
// bound this loose would still pass if it were tightened to 1 (a guest with two tabs loses
// their live feed) or if eviction kept the OLDEST ticket instead of the newest (every
// reconnect throws away the ticket it just minted — a permanently dead feed for that guest).
expect(
statuses.filter((s) => s === 200).length,
`exactly MAX_TICKETS_PER_SESSION streams should survive, got: ${statuses}`
).toBe(4);
// Tear them all down so the next test doesn't see leaked connections.
controllers.forEach((c) => c.abort());

View File

@@ -81,7 +81,16 @@ test.describe('Browser chaos — storage purge', () => {
await clearAllStorage(page);
await page.goto('/admin');
// The `goto` is deliberately allowed to REJECT. What is under test is a race the app wins:
// the admin layout redirects to /admin/login the moment it sees no JWT, and a redirect that
// lands first makes this navigation either "interrupted by another navigation" or
// ERR_ABORTED. Both mean the app did exactly the right thing, quickly — so failing on them
// made the test red precisely when the behaviour was correct, roughly one run in three, and
// it read as a regression in whatever change happened to be in flight. (`waitUntil: 'commit'`
// narrows the window but does not close it; an abort can beat commit too.)
//
// `waitForURL` below is the assertion, and it is unaffected by how the navigation ended.
await page.goto('/admin').catch(() => {});
// The admin layout should bounce them to /admin/login when the JWT is gone.
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
});
@@ -94,6 +103,12 @@ test.describe('Browser chaos — storage purge', () => {
const g = await guest('PurgePin');
await signIn(page, g);
await page.goto('/account');
// Let the page finish settling before touching its execution context. `goto` resolves at
// `load`, but the layout's boot hydration is still in flight and any navigation it triggers
// destroys the context out from under the `page.evaluate` below — which surfaced as an
// intermittent "Execution context was destroyed" that has nothing to do with what this test
// asserts.
await page.waitForLoadState('networkidle');
// Simulate clearAuth() — clears JWT + user_id but keeps PIN so the user can recover.
await page.evaluate(() => {

View File

@@ -44,7 +44,7 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
// click reaches a live handler. See the test above.
await expect(page.getByText(g.displayName)).toBeVisible();
await page.getByRole('button', { name: /Event verlassen/i }).click();
await page.getByTestId('account-logout').click();
const sheet = page.getByTestId('confirm-sheet');
await expect(sheet).toBeVisible();

View File

@@ -80,8 +80,16 @@ async function exportStatus(jwt: string): Promise<any> {
return res.json();
}
async function mintTicket(jwt: string): Promise<string> {
const res = await post('/api/v1/export/ticket', jwt);
/** The raw mint response. `/export/ticket` pre-validates that the archive is servable, so an
* unavailable keepsake is refused HERE — before one of the guest's three daily downloads is
* charged for an archive that cannot be served. */
function mintTicketResponse(jwt: string, kind: 'zip' | 'html' = 'zip') {
return post(`/api/v1/export/ticket?kind=${kind}`, jwt);
}
async function mintTicket(jwt: string, kind: 'zip' | 'html' = 'zip'): Promise<string> {
// `kind` is REQUIRED: the ticket is bound to one archive (see TicketKind::Download).
const res = await post(`/api/v1/export/ticket?kind=${kind}`, jwt);
return (await res.json()).ticket;
}
@@ -294,9 +302,11 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
);
expect(ev.released_at, 'reopen clears the release timestamp').toBeNull();
const ticket = await mintTicket(host.jwt);
const dl = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(dl.status, 'a reopened event serves no keepsake').toBe(404);
// Refused at the MINT, not one step later at the download: the ticket is bound to an archive,
// so the pre-check always knows which one to resolve and answers honestly up front.
expect((await mintTicketResponse(host.jwt)).status, 'a reopened event serves no keepsake').toBe(
404
);
});
test('open ‖ release churn always converges to a consistent, downloadable keepsake', async ({
@@ -387,11 +397,12 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
expect(
res.status,
'an upload whose body completed after the release MUST be rejected (403 uploads_locked). ' +
'A 201 here means the server accepted a photo it will never put in the keepsake — silent, ' +
'permanent data loss.'
'an upload whose body completed after the release MUST be rejected (403). A 201 here means ' +
'the server accepted a photo it will never put in the keepsake — silent, permanent data loss.'
).toBe(403);
expect((await res.json()).error).toBe('uploads_locked');
// `gallery_released`, matching the pre-flight check: the release is what rejected this, and
// that code is the one that PARKS the blob instead of re-pushing it on the retry ladder.
expect((await res.json()).error).toBe('gallery_released');
// And the keepsake holds exactly the one upload that was genuinely committed before the release.
await waitExportDone(host.jwt);
@@ -679,10 +690,7 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
expect(failed.zip.status).toBe('failed');
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
const ticket = await mintTicket(host.jwt);
expect(
(await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status
).toBe(404);
expect((await mintTicketResponse(host.jwt)).status).toBe(404);
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);

View File

@@ -36,7 +36,15 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi
expect(ok.status, 'upload succeeds once the host reopens').toBeLessThan(300);
});
test('released gallery → 403 uploads_locked (also reversible via reopen)', async ({ host }) => {
test('released gallery → 403 gallery_released, the PARKING code (not uploads_locked)', async ({
host,
}) => {
// `release ⇒ lock`, so a released gallery satisfies both conditions and the handler's check
// ORDER decides which code the guest gets. It must be the release one, and the difference is
// not cosmetic: `uploads_locked` charges a retry attempt and re-pushes the whole photo on the
// backoff ladder, against an answer that cannot change until a host acts. `gallery_released`
// parks it — blob kept, nothing re-sent, and the guest is told to ask the hosts to reopen.
// This asserted `uploads_locked` while the release branch was unreachable dead code.
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: { Authorization: `Bearer ${host.jwt}` },
@@ -49,6 +57,6 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi
});
expect(rejected.status).toBe(403);
const body = await rejected.json();
expect(body.error).toBe('uploads_locked');
expect(body.error).toBe('gallery_released');
});
});

View File

@@ -3,6 +3,93 @@ import tailwindcss from '@tailwindcss/vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
/** Webfonts the shared theme declares, and where their bytes actually live. */
const FONTS = ['Inter', 'Fraunces'];
/**
* Inline the webfonts the shared theme CSS references by ABSOLUTE path.
*
* `src/tailwind-theme.css` is the design-token source of truth for both the live app and this
* viewer, and it declares `src: url('/fonts/Inter.woff2')`. 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, silently: `font-display: swap` means the viewer renders in a fallback system font
* with no error, so nothing on the server side can ever report it. The keepsake is the one artifact
* the whole event exists to produce, and it was shipping without the typography it was designed in.
*
* Fixing it in the shared CSS would break the app (a data URI there would inline ~154 KB into every
* page load for no reason), and shipping a `fonts/` folder beside `index.html` would give the guest
* a directory they can break by moving one file. So the substitution belongs HERE, in the build that
* knows its output has no origin: rewrite the emitted HTML only.
*
* Runs in `generateBundle` rather than `transformIndexHtml` because `viteSingleFile` inlines the
* stylesheet during the latter — the `url()` we need to rewrite does not exist in the HTML until
* after it has run.
*/
function inlineThemeFonts() {
return {
name: 'eventsnap:inline-theme-fonts',
enforce: 'post',
generateBundle(_options, bundle) {
const html = bundle['index.html'];
if (!html || typeof html.source !== 'string') return;
for (const family of FONTS) {
const bytes = readFileSync(
fileURLToPath(new URL(`../static/fonts/${family}.woff2`, import.meta.url))
);
const uri = `data:font/woff2;base64,${bytes.toString('base64')}`;
const before = html.source;
html.source = html.source.replaceAll(`/fonts/${family}.woff2`, uri);
// A silent no-op here is the exact failure this plugin exists to prevent, and it
// would come back the moment the theme renames a font or switches to a CDN. Fail
// the build instead of shipping another keepsake in Times New Roman.
if (html.source === before) {
this.error(
`inline-theme-fonts: no reference to /fonts/${family}.woff2 in the built ` +
`keepsake. The shared theme CSS changed how it loads webfonts — update ` +
`FONTS in vite.standalone.config.js to match, or the offline viewer will ` +
`render in a fallback font.`
);
}
}
},
// The FONTS loop above only catches a RENAME. It cannot catch an ADDITION, and an addition
// is the likelier accident by far: 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 correct to everyone who happens to
// have the file locally, and renders in Times New Roman for the couple.
//
// So assert the invariant itself rather than a list: nothing in the emitted keepsake may
// reference an external URL. Self-maintaining — it covers renames, additions, fonts,
// images and stylesheets alike, and nobody has to remember it exists.
//
// In `writeBundle`, NOT `generateBundle`: the latter runs more than once, and on the
// earlier pass the stylesheet has not been inlined yet, so asserting there fails a
// perfectly good build. This hook sees only what was actually written.
writeBundle(_options, bundle) {
const html = bundle['index.html'];
if (!html || typeof html.source !== 'string') return;
const external = [...html.source.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)]
.map((m) => m[2].trim())
.filter((u) => !u.startsWith('data:'));
if (external.length) {
this.error(
`inline-theme-fonts: the built keepsake still references ${external.length} ` +
`external asset(s): ${[...new Set(external)].join(', ')}. The viewer is opened ` +
`from file:// with no network and no origin, so every one of these resolves to ` +
`the root of the guest's disk and 404s silently. Inline them (see FONTS above) ` +
`or remove them from the theme the viewer imports.`
);
}
}
};
}
// Builds the keepsake viewer as ONE self-contained index.html (all JS + CSS
// inlined) so it renders when opened via file://. Uses the plain Svelte plugin
@@ -12,14 +99,34 @@ export default defineConfig({
plugins: [
tailwindcss(),
svelte({ configFile: false, preprocess: vitePreprocess(), compilerOptions: { runes: true } }),
viteSingleFile()
viteSingleFile(),
inlineThemeFonts()
],
resolve: {
alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) }
},
build: {
outDir: fileURLToPath(new URL('../../backend/static/export-viewer', import.meta.url)),
emptyOutDir: true,
// NOT `true`. Vite empties outDir BEFORE generating, so a build that fails late — which is
// now a real possibility, since `inlineThemeFonts` calls `this.error` on a keepsake that is
// not self-contained — left the directory EMPTY. `include_dir!` over an empty directory
// compiles perfectly happily, and `write_viewer_with_data` iterates zero files and returns
// Ok, so the next `cargo build` produced a binary whose Memories.zip has the photos and no
// viewer at all. Before the guard existed the build could not fail, so neither could this.
//
// The only output is a single `index.html`, overwritten on every successful build, so
// there is nothing to accumulate.
//
// This protects ONE of the two failure paths, not both. `inlineThemeFonts` errors from
// `generateBundle`, before anything is written, so the previous artifact survives intact.
// The external-asset assertion errors from `writeBundle` — which runs AFTER Vite has
// written `index.html` — so that failure does overwrite the good viewer with the broken
// one. It cannot ship: the build exits non-zero, and `the_keepsake_viewer_is_compiled_into_
// this_binary` plus the `!html.contains("url(/")` assertion both fail the Rust test suite
// before any image is built. But if a `writeBundle` failure is what you are looking at,
// restore the artifact with `git checkout backend/static/export-viewer/` rather than
// assuming the working copy is still the last good one.
emptyOutDir: false,
target: 'es2020'
}
});

View File

@@ -78,7 +78,10 @@
'<button id="app-boot-reload" class="app-boot__fail-btn">Neu laden</button>' +
'</div>';
var btn = document.getElementById('app-boot-reload');
if (btn) btn.addEventListener('click', function () { location.reload(); });
if (btn)
btn.addEventListener('click', function () {
location.reload();
});
}, 15000);
})();
</script>
@@ -151,7 +154,12 @@
.app-boot__fail {
max-width: 20rem;
text-align: center;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
.app-boot__fail-title {
margin: 0 0 0.5rem;
@@ -198,7 +206,12 @@
padding: 2rem;
text-align: center;
background: #faf9f7;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: #545350;
}
html.dark .app-boot__noscript {

View File

@@ -15,11 +15,28 @@ export class ApiError extends Error {
const TIMEOUT_MS = 20_000;
/** Pages that ARE the recovery flow — redirecting from them would loop. */
const AUTH_ROUTES = ['/join', '/recover'];
/**
* Pages that ARE a credential-entry form — redirecting away from them would loop.
*
* `/admin/login` belongs here: a mistyped admin password 401s, the hard redirect below fired, and
* the admin was thrown off their own login form — destroying the error message that would have
* told them what happened. The redirect exists to rescue a session that died mid-app; someone
* actively typing credentials into a login form does not need rescuing.
*
* It must be `/admin/login` and NOT the `/admin` prefix. `/admin` would also match the dashboard,
* and suppressing `clearAuth()` there is a trap: the dead token stays resident, `admin/+page`
* bounces to `/admin/login`, and that page's `getRole() === 'admin'` guard — which decodes the JWT
* without checking `exp` — bounces straight back. The result is an unbreakable flip-flop with no
* way to reach the login form short of clearing site data, on the one device running the party.
*/
const AUTH_ROUTES = ['/join', '/recover', '/admin/login'];
/**
* Send a guest whose session died back to the join screen.
* Send someone whose session died back to the right credential form.
*
* Staff go to `/admin/login`, guests to `/join`. Sending an admin whose session expired to the
* guest join screen is a dead end: they do not have a name and PIN to type, and the screen gives
* them no route back to their own login.
*
* Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already
* imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would
@@ -29,9 +46,31 @@ const AUTH_ROUTES = ['/join', '/recover'];
*/
function redirectToJoin(): void {
if (typeof window === 'undefined') return;
if (onAuthRoute()) return;
const staff = window.location.pathname.startsWith('/admin');
window.location.assign(staff ? '/admin/login' : '/join');
}
/**
* Are we currently ON a credential-entry page?
*
* A 401 means two completely different things depending on the answer, and treating them alike
* destroyed data (H14). Off these pages it means "your session died" — clear it and rescue the
* guest. ON them it means "the credentials you just typed were wrong", which is ordinary form
* validation and must change no stored state at all.
*
* The concrete failure: an authenticated guest taps "Gerät wechseln", mistypes their own name, and
* the backend answers 401 (deliberately identical for a wrong PIN and an unknown name — that
* indistinguishability is an anti-enumeration property, see `recover`'s dummy bcrypt, so it must not
* be "fixed" by making the responses differ). `clearAuth()` then threw away their working token,
* and the recover page additionally called `clearPin()` — which discarded the ONLY copy of their
* PIN, since localStorage is where it lives and the server holds only the bcrypt. One typo, and
* both their session and their credential were gone; rejoining under the same name 409s.
*/
function onAuthRoute(): boolean {
if (typeof window === 'undefined') return false;
const path = window.location.pathname;
if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return;
window.location.assign('/join');
return AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`));
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
@@ -106,7 +145,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
// An expired/invalid token (401) clears the dead session. Banned users are
// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
// simply get a 403 "gesperrt" toast on writes.
if (res.status === 401) {
if (res.status === 401 && !onAuthRoute()) {
clearAuth();
// Clearing auth alone leaves the guest stranded: the bottom nav and FAB are
// gated on `isAuthenticated` so they simply vanish, route guards only run in

View File

@@ -19,7 +19,9 @@ import {
getRole,
getUserId,
clearAuth,
clearPin
clearPin,
getPinOwner,
getDisplayName
} from './auth';
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
@@ -115,3 +117,35 @@ describe('auth — JWT claim decode', () => {
expect(getRole()).toBeNull();
});
});
describe('auth — the cached PIN knows whose it is', () => {
// A host PIN reset also revokes the guest's sessions, so the guest reaches /recover only AFTER
// clearAuth has run. /recover clears a rejected cached PIN only when the submitted name matches
// this device's — so if the owner name did not survive clearAuth, the dead PIN could never be
// cleared and would keep pre-filling the field.
it('the PIN owner survives clearAuth, exactly as the PIN itself does', () => {
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
expect(getPinOwner()).toBe('Alice');
clearAuth();
expect(getPin(), 'the PIN is deliberately kept so the guest can recover').toBe('1234');
expect(getDisplayName(), 'the display name is wiped for shared-device privacy').toBeNull();
expect(getPinOwner(), 'so the PIN owner must be stored separately, or the pair is broken').toBe(
'Alice'
);
});
it('clearPin drops the owner too — no orphan pointing at a PIN that is gone', () => {
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
clearPin();
expect(getPin()).toBeNull();
expect(getPinOwner()).toBeNull();
});
it('no cached PIN means no owner, even if a display name is present', () => {
setAuth('a.b.c', null, 'uid-1', 'Alice');
expect(getPin()).toBeNull();
expect(getPinOwner()).toBeNull();
});
});

View File

@@ -3,6 +3,23 @@ import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin';
/**
* Whose PIN `PIN_KEY` holds — and it is a SEPARATE key from `DISPLAY_NAME_KEY` on purpose.
*
* `/recover` only clears a rejected cached PIN when the name submitted is the one this device
* belongs to, so that a guest who mistypes their own name does not lose the only copy of their PIN
* (the server keeps just the bcrypt). That check read `DISPLAY_NAME_KEY` — which `clearAuth`
* deletes, for shared-device privacy, one step BEFORE the guest ever reaches `/recover`:
*
* host taps "PIN zurücksetzen" → the backend also revokes every session for that user
* (`host.rs`, `Session::delete_all_for_user`) → the guest's next request 401s → `clearAuth`
* → redirect to /join → the guest goes to /recover, where the field is PRE-FILLED with the
* dead PIN and can never be cleared, because the name it would be compared against is gone
*
* Since `clearAuth` deliberately keeps the PIN so the guest can recover, it must keep the PIN's
* owner too, or the pair is inconsistent and the guard is unreachable exactly when it is needed.
*/
const PIN_OWNER_KEY = 'eventsnap_pin_owner';
const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
@@ -43,9 +60,22 @@ export function getPin(): string | null {
export function clearPin(): void {
if (!browser) return;
localStorage.removeItem(PIN_KEY);
localStorage.removeItem(PIN_OWNER_KEY);
currentPin.set(null);
}
/**
* The display name the cached PIN belongs to, or `null` if there is no cached PIN.
*
* Survives `clearAuth` alongside the PIN itself — see [`PIN_OWNER_KEY`]. Falls back to the auth
* display name for devices that cached a PIN before this key existed.
*/
export function getPinOwner(): string | null {
if (!browser) return null;
if (localStorage.getItem(PIN_KEY) === null) return null;
return localStorage.getItem(PIN_OWNER_KEY) ?? readAuth(DISPLAY_NAME_KEY);
}
export function getUserId(): string | null {
return readAuth(USER_ID_KEY);
}
@@ -81,6 +111,8 @@ export function setAuth(
localStorage.setItem(TOKEN_KEY, jwt);
if (pin) {
localStorage.setItem(PIN_KEY, pin);
// Stored with the PIN, not derived from it later — see `PIN_OWNER_KEY`.
if (displayName) localStorage.setItem(PIN_OWNER_KEY, displayName);
currentPin.set(pin);
}
localStorage.setItem(USER_ID_KEY, userId);
@@ -112,7 +144,7 @@ export function setAdminAuth(jwt: string, userId: string, displayName?: string):
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
// here at import-time so they get reset on every clearAuth path — both the
// explicit "Event verlassen" button and the api.ts 401 auto-clear. Keeps
// explicit "Abmelden" button and the api.ts 401 auto-clear. Keeps
// clearAuth the single source of truth without baking dependencies on every
// downstream store into this module (which would create circular imports).
const clearAuthHooks: Array<() => void> = [];

View File

@@ -61,6 +61,9 @@ const KNOWN_EVENTS = [
'new-comment',
'comment-deleted',
'user-hidden',
// The mirror of `user-hidden`: a host lifted a ban, so the guest's uploads return to every
// feed and the projector, and their own parked upload queue resumes.
'user-shown',
'event-closed',
'event-opened',
'event-updated',
@@ -144,7 +147,9 @@ export function connectSse(): void {
for (const eventName of KNOWN_EVENTS) {
eventSource.addEventListener(eventName, (e) => {
noteStreamActivity();
dispatch(eventName, (e as MessageEvent).data);
const data = (e as MessageEvent).data;
noteDelivered(eventName, data);
dispatch(eventName, data);
});
}
@@ -191,6 +196,10 @@ export function disconnectSse(): void {
eventSource.close();
eventSource = null;
}
// A new stream has delivered nothing, so anything the next delta returns is genuinely
// undelivered as far as THIS connection is concerned. Keeping the old set would suppress the
// liveness signal for content that arrived during the gap — the opposite failure to H1.
forgetDelivered();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -305,6 +314,85 @@ function dispatch(eventType: string, data: string): void {
}
}
/**
* Ids the LIVE STREAM has actually delivered to us — the evidence the liveness backstop needs.
*
* The backstop asks "did the poll find something the stream never pushed?", and the old answer was
* simply "did the poll return any rows?". Those are not the same question, and on a live event they
* diverge constantly: `dispatch` advances the cursor to an upload's `created_at`, which is always
* EARLIER than the `server_time` the previous delta stored, so the cursor rewinds onto
* `feed_delta`'s deliberately inclusive `>=` boundary and the poll re-returns an upload the stream
* had already delivered a moment ago. Row count > 0, so the backstop concluded the socket was dead
* and tore down a perfectly healthy stream — then reset `reconnectAttempt` to 0, bypassing the
* jittered backoff. At 100 guests that is roughly one reconnect per second, sustained, all evening,
* each costing ~10 queries.
*
* Bounded FIFO: an event runs for hours and this must not grow without limit. The cap only needs to
* exceed what one delta window can return (`DELTA_LIMIT` server-side), because anything older than
* the current window cannot be re-returned as "new".
*/
const DELIVERED_MEMORY = 500;
const deliveredIds: string[] = [];
const deliveredSet = new Set<string>();
function rememberDelivered(id: string): void {
if (deliveredSet.has(id)) return;
deliveredSet.add(id);
deliveredIds.push(id);
if (deliveredIds.length > DELIVERED_MEMORY) {
const evicted = deliveredIds.shift();
if (evicted !== undefined) deliveredSet.delete(evicted);
}
}
/** Record whatever ids a stream payload carried, so a later delta can be recognised as a repeat.
*
* Only the id that IS the thing the liveness check tests, per event — not every id field present.
*
* This used to harvest `id`, `upload_id` and `user_id` from every payload, which quietly disarmed
* two thirds of the backstop. `new-upload` carries `id` + `user_id`, and `like-update` /
* `new-comment` carry `upload_id` + `user_id`, so by the time anything was deleted or anyone was
* banned their ids were already in `deliveredSet` — recorded from ordinary traffic about content
* that was still perfectly live. `carried`'s `deleted_ids` and `hidden_user_ids` clauses were then
* false essentially always.
*
* The cost: a socket that goes half-open (a phone roaming APs leaves `readyState === OPEN`, so the
* cheap check misses it) is only noticed once a genuinely NEW upload appears. A host moderating
* three photos, or banning a guest, produced a delta whose every id was "already delivered" — so
* the stream stayed dead and the host kept moderating into a feed nobody's app was listening to.
*/
function noteDelivered(eventName: string, data: string): void {
try {
const p = JSON.parse(data) as { id?: unknown; upload_id?: unknown; user_id?: unknown };
// Mirrors the three clauses in `carried`: uploads by upload id, deletions by upload id,
// ban-hides by user id.
const relevant =
eventName === 'new-upload'
? p.id
: // `upload-processed` carries `upload_id`, not `id` (see compression.rs) — reading
// `p.id` recorded nothing at all, so this branch quietly did the opposite of what
// the comment above claims. Harmless today only because the delta cursor is
// anchored by `new-upload`, which is not a property worth depending on.
eventName === 'upload-processed' || eventName === 'upload-deleted'
? (p.upload_id ?? p.id)
: // Only `user-hidden` has a matching clause in `carried` (`hidden_user_ids` comes
// from `uploads_hidden = TRUE`). Recording an UNBAN's user id could only ever
// suppress a later genuine signal, so it is deliberately not recorded.
eventName === 'user-hidden'
? p.user_id
: undefined;
if (typeof relevant === 'string') rememberDelivered(relevant);
} catch {
// non-JSON payload — nothing to record
}
}
/** Reset on disconnect: a fresh stream has delivered nothing yet. */
function forgetDelivered(): void {
deliveredIds.length = 0;
deliveredSet.clear();
}
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
function extractCreatedAt(data: string): string | undefined {
try {
@@ -333,10 +421,18 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
// reconnect resumes exactly where the server left off (no browser-clock skew).
lastEventTime = response.server_time;
dispatch('feed-delta', JSON.stringify(response));
// "Did the poll find something the STREAM never delivered?" — not "did it return rows?".
// See `deliveredIds`: on a live event the cursor rewinds onto the inclusive `>=` boundary
// and re-returns uploads the stream already pushed, so a row count made every healthy
// stream look dead and produced a sustained reconnect storm.
//
// Ids the stream delivered while we were connected are excluded. Anything genuinely new —
// including everything that arrived while the socket was half-open — still counts, which is
// the signal this backstop exists for.
return (
response.uploads.length > 0 ||
response.deleted_ids.length > 0 ||
response.hidden_user_ids.length > 0
response.uploads.some((u) => !deliveredSet.has(u.id)) ||
response.deleted_ids.some((id) => !deliveredSet.has(id)) ||
response.hidden_user_ids.some((id) => !deliveredSet.has(id))
);
} catch (e) {
// A throttled delta (429) must NOT be silently dropped: live events keep advancing

View File

@@ -142,7 +142,6 @@ describe('shouldAbortForStall', () => {
expect(shouldAbortForStall(now - 91_000, now, true)).toBe(false);
expect(shouldAbortForStall(now - 121_000, now, true)).toBe(true);
});
});
/**

View File

@@ -1,4 +1,5 @@
import { openDB, type IDBPDatabase } from 'idb';
import { uuid } from '$lib/uuid';
import { writable, get } from 'svelte/store';
import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth';
import { onSseEvent } from './sse';
@@ -215,6 +216,21 @@ interface QueueEntry {
* Cleared by `retryItem` — an explicit tap is the guest changing their mind.
*/
cancelled?: boolean;
/**
* Parked waiting for a specific host action, with the blob intact.
*
* Both values are reversible 403s whose answer cannot change without somebody deciding to
* change it, which makes automatic retries pure waste: they re-pushed the whole photo over
* cellular on every budget refill for the rest of the night while the guest was told to tap
* a camera button that 403s.
*
* - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`.
* - `'unban'` — the uploader is banned (`user_banned`). Cleared by `user-shown`.
*
* An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try
* anyway, and if the condition still holds the next response simply re-parks it.
*/
parkedFor?: 'reopen' | 'unban';
blob?: Blob;
}
@@ -266,7 +282,7 @@ onClearAuth(() => queueItems.set([]));
let sseBound = false;
function bindSse(): void {
if (sseBound || typeof window === 'undefined') return;
const resume = (options: { resetAttempts?: boolean } = {}) => {
const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => {
void (async () => {
await requeueRetriable(options);
await processQueue();
@@ -275,7 +291,20 @@ function bindSse(): void {
// A reopen is a deliberate host action that changes the server's answer, so it's fair to
// give parked items a fresh retry budget. A plain reconnect is not — that's the signal
// that fires over and over on a flapping AP.
onSseEvent('event-opened', () => resume({ resetAttempts: true }));
onSseEvent('event-opened', () => resume({ resetAttempts: true, release: 'reopen' }));
// An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to
// everyone (every feed needs to un-hide that user's photos), so check it is actually us
// before resuming — otherwise one guest's unban would resume every OTHER banned guest's
// queue straight into another 403.
onSseEvent('user-shown', (payload) => {
let userId: unknown;
try {
userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id;
} catch {
return;
}
if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' });
});
onSseEvent('feed-delta', () => resume());
sseBound = true;
}
@@ -293,8 +322,14 @@ bindSse();
*
* `resetAttempts` is for signals that are positive evidence the blocking condition changed
* (the host reopening the event), where starting the budget over is warranted.
*
* `release` names the host action that just happened, and un-parks only the items that were
* waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and
* an unban must not resume uploads into a released gallery — both would just 403 again.
*/
async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Promise<void> {
async function requeueRetriable(
options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}
): Promise<void> {
const myUserId = getUserId();
const all = await storeGetAll();
const now = Date.now();
@@ -306,6 +341,13 @@ async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Prom
// not even on `resetAttempts` (the host reopening the event says nothing about whether
// this guest still wants this photo sent). Only `retryItem` clears it.
if (entry.cancelled) continue;
// Parked waiting on a host action. Only the matching signal releases it, so a plain
// reconnect leaves it alone instead of re-pushing the photo at a server whose answer
// cannot have changed. A manual "Erneut" bypasses this via `retryItem`.
if (entry.parkedFor) {
if (entry.parkedFor !== options.release) continue;
entry.parkedFor = undefined;
}
if (options.resetAttempts) {
entry.attempts = 0;
entry.nextAttemptAt = undefined;
@@ -612,6 +654,31 @@ class AuthError extends Error {}
*/
class LockedError extends Error {}
/**
* The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every
* blob-preserving code path below keeps treating it as a reversible lock (the host *can* still
* reopen, and losing a photo is the worst outcome).
*
* What differs is the retry policy. A closed event is a pause the host means to undo, so
* auto-resuming on reconnect is right. A released gallery is the end of the event, and in the
* normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over
* cellular on every budget refill, forever, for an answer that will not change, while the guest
* is told to tap a camera button that 403s. Items parked this way sit still (see
* `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest
* retries by hand.
*/
class ReleasedError extends LockedError {}
/**
* The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives:
* `unban_user` exists and the host's confirm copy promises the photos come back, which the old
* generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row
* moved to `blocked`, and `blocked` has no retry button).
*
* Parks still like `ReleasedError` and waits for `user-shown`.
*/
class BannedError extends LockedError {}
/** Retry policy for an upload response status. */
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
@@ -656,11 +723,36 @@ export function classifyUploadStatus(status: number): UploadOutcome {
export function isReversibleLock(status: number, errorCode: unknown): boolean {
return (
errorCode === 'uploads_locked' ||
errorCode === 'gallery_released' ||
// A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging
// the blob here made that promise impossible to keep for anything mid-flight.
errorCode === 'user_banned' ||
errorCode === 'quota_exceeded' ||
(status === 403 && errorCode !== 'forbidden')
);
}
/**
* Within the reversible-lock bucket, is this the END of the event rather than a pause?
*
* `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host
* reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure +
* exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets
* re-pushed over cellular all night.
*/
export function isGalleryReleased(errorCode: unknown): boolean {
return errorCode === 'gallery_released';
}
/**
* Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on
* its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the
* photo on every reconnect at a guest who is currently not allowed to upload.
*/
export function isUserBanned(errorCode: unknown): boolean {
return errorCode === 'user_banned';
}
/**
* Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the
* field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried
@@ -716,6 +808,35 @@ export async function loadQueue(): Promise<void> {
})();
}
/**
* Release parked items whose blocking condition is already over, using the authoritative state
* the app fetches at boot.
*
* `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE
* `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the
* host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they
* close the app at the end of the night, and the host lifts the ban or reopens uploads the next
* morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast
* had promised "wird gesendet, sobald die Sperre aufgehoben ist".
*
* Called once per boot with what `/me/context` and the event state actually say, so a park can
* never outlive the condition it was waiting on. Cheap: a no-op unless something is parked.
*/
export async function releaseResolvedParks(state: {
banned: boolean;
uploadsOpen: boolean;
}): Promise<void> {
// Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says
// nothing about whether the gallery reopened, and vice versa.
if (!state.banned) {
await requeueRetriable({ resetAttempts: true, release: 'unban' });
}
if (state.uploadsOpen) {
await requeueRetriable({ resetAttempts: true, release: 'reopen' });
}
await processQueue();
}
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
@@ -765,7 +886,7 @@ export async function addToQueue(
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
// exactly ONCE per file here and reused by every retry — see uploadItem.
const id = crypto.randomUUID();
const id = uuid();
const entry: QueueEntry = {
id,
userId,
@@ -813,6 +934,10 @@ export async function retryItem(id: string): Promise<void> {
// And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped
// themselves is them changing their mind.
entry.cancelled = false;
// Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may
// have reopened or unbanned without this device seeing the SSE). If the condition still
// holds the next response re-parks it, so this cannot become a loop.
entry.parkedFor = undefined;
await storePut(entry);
queueItems.update((items) =>
@@ -1003,6 +1128,17 @@ async function uploadItem(id: string): Promise<void> {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/v1/upload');
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
// The SAME idempotency key as the multipart field above, in a header.
//
// The field alone cannot be read until the body is being parsed, which is after the
// release/lock pre-flight — so a retry of a photo that was already stored got
// `gallery_released` instead of its original row, and the guest was told a photo that
// IS in the gallery had not been sent. The only remedy on offer (ask the hosts to
// reopen) bumps the export epoch and destroys the released keepsake.
//
// A header arrives with the request line, so the server can replay before it decides
// anything about locks. The field stays for the concurrent case and as the fallback.
xhr.setRequestHeader('X-Client-Upload-Id', entry.id);
// Wall-clock backstop only — generous enough that a slow-but-alive LTE upload is
// never killed by it. See MIN/MAX_UPLOAD_TIMEOUT_MS.
xhr.timeout = Math.min(
@@ -1124,7 +1260,16 @@ async function uploadItem(id: string): Promise<void> {
// portal) must NOT purge the blob — losing a photo is the worst outcome, and
// 403 is the reversible-lock status here.
if (isReversibleLock(xhr.status, body?.error)) {
settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.')));
const msg = body?.message || 'Event ist geschlossen.';
settle(() =>
reject(
isGalleryReleased(body?.error)
? new ReleasedError(msg)
: isUserBanned(body?.error)
? new BannedError(msg)
: new LockedError(msg)
)
);
break;
}
// Any other 4xx the server will keep rejecting (banned, too large, wrong
@@ -1190,20 +1335,31 @@ async function uploadItem(id: string): Promise<void> {
// the hourly media reclaim puts them back under it). KEEP the blob and park the item
// as retryable so it survives until then. `event-opened` and the `feed-delta`
// reconnect both auto-resume it; a manual "Erneut" also works. Never purge here.
const exhausted = chargeAttempt(entry);
// Neither a release nor a ban lifts on its own, so charging an attempt — and with it
// the backoff ladder and budget refill that drive automatic re-pushes — buys nothing
// but bandwidth. Park those still and wait for the host action.
const parkedFor: 'reopen' | 'unban' | undefined =
e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined;
const exhausted = parkedFor ? false : chargeAttempt(entry);
entry.status = 'error';
entry.error = withRetryHint(e.message, exhausted);
entry.parkedFor = parkedFor;
entry.error = parkedFor ? e.message : withRetryHint(e.message, exhausted);
await storePut(entry);
updateItemStatus(id, 'error', entry.error);
// Say it out loud. The queue list is only mounted on /upload and the composer sends
// the guest straight to /feed, so this message otherwise lands in a store that
// nothing on screen renders — the photo just never appears and the guest, with no
// operator to ask, assumes it worked.
toast(
`${entry.fileName}: ${e.message} Du findest den Upload über den Kamera-Button.`,
'warning',
6000
);
//
// For a release, be explicit that the photo is NOT lost and NOT coming back on its
// own — that is the whole difference the guest needs to act on.
const parkedHint =
parkedFor === 'reopen'
? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.'
: parkedFor === 'unban'
? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.'
: ' Du findest den Upload über den Kamera-Button.';
toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000);
throw e;
}
if (e instanceof AuthError) {

View File

@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { uuid } from './uuid';
const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('uuid', () => {
it('uses crypto.randomUUID when it is available', () => {
const spy = vi.spyOn(crypto, 'randomUUID');
expect(uuid()).toMatch(V4);
expect(spy).toHaveBeenCalled();
});
// The case that matters: an idempotency key is now minted on the JOIN path, and a TypeError
// there surfaces as a generic error the guest cannot get past — no account yet, so /recover is
// no help either. `crypto.randomUUID` needs a secure context and Safari >= 15.4.
it('falls back to getRandomValues when randomUUID is missing', () => {
const real = crypto.getRandomValues.bind(crypto);
vi.stubGlobal('crypto', {
getRandomValues: real
// randomUUID deliberately absent
});
const id = uuid();
expect(id, 'the fallback must still produce a well-formed v4 UUID').toMatch(V4);
});
it('the fallback sets the version and variant bits, not just random hex', () => {
// Every byte 0x00 — so version/variant nibbles can only be right if they are set explicitly.
vi.stubGlobal('crypto', {
getRandomValues: (a: Uint8Array) => a.fill(0)
});
expect(uuid()).toBe('00000000-0000-4000-8000-000000000000');
});
it('produces distinct values through the fallback', () => {
const real = crypto.getRandomValues.bind(crypto);
vi.stubGlobal('crypto', { getRandomValues: real });
const ids = new Set(Array.from({ length: 200 }, () => uuid()));
expect(ids.size, 'a collision would replay one guest join or upload onto another').toBe(200);
});
});

37
frontend/src/lib/uuid.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* A v4 UUID, with a fallback for environments where `crypto.randomUUID` is missing.
*
* `crypto.randomUUID` needs Safari ≥ 15.4 / Chrome ≥ 92 **and a secure context**. Production is
* HTTPS (Caddy terminates TLS for `{$DOMAIN}`), so the realistic gap is an old phone — but the
* consequence changed when idempotency keys moved onto the join path. Previously such a device
* joined and browsed fine and only failed at upload, a degraded but survivable experience. Now the
* `TypeError` is thrown inside `handleJoin`'s `try` and surfaces as the generic
* "Ein Fehler ist aufgetreten.", identically on every retry: the guest cannot join, cannot browse,
* and cannot use `/recover` either, because they have no account yet. It is the one screen in the
* app where a hard failure leaves no way out at all.
*
* The fallback is `crypto.getRandomValues` (universally available, no secure-context requirement)
* with the version and variant bits set per RFC 4122 §4.4. `Math.random` is deliberately NOT a
* further fallback: these values are idempotency keys, and a collision between two guests would
* replay one guest's join or upload to another. If there is no CSPRNG at all, throwing is correct.
*/
export function uuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
const hex: string[] = [];
for (const b of bytes) hex.push(b.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join('')
].join('-');
}

View File

@@ -9,11 +9,17 @@
import Toaster from '$lib/components/Toaster.svelte';
import { showBottomNav } from '$lib/ui-store';
import { isAuthenticated } from '$lib/auth';
import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue';
import {
queueItems,
isProcessing,
loadQueue,
rateLimitRetryAt,
releaseResolvedParks
} from '$lib/upload-queue';
import { privacyNote } from '$lib/privacy-note-store';
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import { api, ApiError } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
import { setRole } from '$lib/role-store';
@@ -88,9 +94,72 @@
galleryReleased: ctx.gallery_released
});
isBanned.set(ctx.is_banned);
} catch {
// Now that we know the AUTHORITATIVE state, release anything the queue parked
// waiting on a host action that has already happened. 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: the guest closes the app, the host
// lifts the ban or reopens uploads later. Without this the photo stays parked
// forever, which is exactly the "my pictures never sent" the host gets asked about.
void releaseResolvedParks({
banned: ctx.is_banned,
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
});
} catch (err) {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
//
// But ONE consequence is not recoverable by a per-page mount: the park release
// above. A parked photo's other two release paths are the live `user-shown` /
// `event-opened` SSE events, and the routes that open a stream are /feed, /diashow,
// /export, /host and /admin — NOT /upload, which is exactly where the toast sends
// the guest to watch their queue. So on venue wifi, the condition this branch
// exists for, one failed request could strand the photo for the whole session with
// the queue row still reading "Du bist gesperrt.". Retry once, briefly.
//
// NOT on a 401. `api.get` already answered that one by calling `clearAuth()` and
// redirecting to /join, so there is no session left to hydrate and a second attempt
// can only fire a SECOND redirect — two seconds later, by which time the guest may
// have navigated somewhere else. Retry the transient case this exists for (offline,
// 5xx, a dropped request) and nothing else.
//
// A guard, not an early `return`: everything below this block — `refreshQuota`
// and, outside it, every SSE listener registration — still has to run.
const worthRetrying = !(err instanceof ApiError && err.status === 401);
if (worthRetrying) {
// DETACHED, not awaited. Everything below — including every SSE listener
// registered outside this block — used to sit behind it, and the worst case is
// a 20 s request timeout + 2 s backoff + a second 20 s timeout: ~42 s during
// which `pin-reset`, `user-hidden`/`user-shown`, `event-closed`/`event-opened`
// and `event-updated` are dispatched to an empty handler list. On `main` the
// exposure was one timeout; awaiting the retry doubled it, on exactly the wifi
// the retry exists for.
//
// Five of the six self-heal (`/feed`'s mount re-reads them, and the upload
// queue binds `event-opened`/`user-shown` at module scope, so parked photos
// still release). `pin-reset` does NOT: nothing else clears the cached
// plaintext PIN, so a missed one leaves a dead PIN displayed in "Mein Konto"
// and pre-filling /recover.
//
// Detaching costs nothing — the retry only writes stores and releases parks,
// and no code below reads its result.
void (async () => {
try {
await new Promise((r) => setTimeout(r, 2000));
const ctx = await api.get<MeContextDto>('/me/context');
isBanned.set(ctx.is_banned);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
void releaseResolvedParks({
banned: ctx.is_banned,
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
});
} catch {
// Still down. The "Erneut" button on the parked row remains the way back.
}
})();
}
}
void refreshQuota();
}
@@ -115,8 +184,7 @@
// `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards
// from every feed), so only OUR id means us. Without this, `isBanned` was seeded once
// on boot and never moved — a guest banned mid-party kept the full UI and learned
// about it one 403 toast at a time, which reads as the app being broken. The ban is
// one-way here on purpose: an unban has no SSE, and the next `/me/context` clears it.
// about it one 403 toast at a time, which reads as the app being broken.
onSseEvent('user-hidden', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
@@ -125,6 +193,18 @@
// Malformed payload — discard; nothing actionable for the user.
}
}),
// And the mirror. This used to be one-way ("an unban has no SSE, and the next
// `/me/context` clears it") because `unban_user` broadcast nothing at all — so a
// guest whose ban was lifted kept the banned UI until they happened to reload,
// which for a PWA with no URL bar is not a thing they can easily do.
onSseEvent('user-shown', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) isBanned.set(false);
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
}),
// Reflect a host closing/reopening uploads live, so the composer switches to a
// locked state immediately instead of a guest finding out via a rejected upload.
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),

View File

@@ -3,7 +3,7 @@
import { getToken, getDisplayName, getExpiry, clearAuth, currentPin } from '$lib/auth';
import { role, setRole } from '$lib/role-store';
import { clearQueue } from '$lib/upload-queue';
import { api } from '$lib/api';
import { api, ApiError } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { dataMode } from '$lib/data-mode-store';
import { themePreference, type ThemePreference } from '$lib/theme-store';
@@ -14,6 +14,7 @@
import { avatarPalette, initials } from '$lib/avatar';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import { vibrate } from '$lib/haptics';
import { toast } from '$lib/toast-store';
import type { MeContextDto } from '$lib/types';
let displayName = $state<string | null>(null);
@@ -124,6 +125,40 @@
goto('/join');
}
let deleteConfirmOpen = $state(false);
let deleting = $state(false);
/**
* Erase this account. Unlike `handleLogout`, the server call is NOT best-effort — if it fails
* we must keep the guest signed in and say so, because clearing local auth on a failed delete
* would leave them believing their photos were gone while every one of them is still live.
*/
async function handleDeleteAccount() {
if (deleting) return;
deleting = true;
try {
await api.delete('/me');
} catch (e) {
deleteConfirmOpen = false;
deleting = false;
toast(
e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen. Bitte versuch es erneut.',
'error',
6000
);
return;
}
// Only now is it safe to tear down locally. The queue must go too: its blobs are this
// guest's photos, and leaving them would re-upload everything on the next session.
try {
await clearQueue();
} catch {
/* best-effort cleanup */
}
clearAuth();
goto('/join');
}
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
@@ -517,7 +552,13 @@
</a>
<!-- Leave / logout (this device) -->
<!-- `data-testid` rather than an accessible-name locator: this trigger and the
confirm sheet's button are BOTH labelled "Abmelden", so `getByRole('button',
{ name: /abmelden/i })` is ambiguous the moment the sheet opens. The rename
away from "Event verlassen" silently broke three e2e locators — including the
one behind the smoke spec — precisely because they keyed on visible copy. -->
<button
data-testid="account-logout"
onclick={() => {
leaveEverywhere = false;
leaveConfirmOpen = true;
@@ -570,6 +611,32 @@
>Auf allen Geräten abmelden</span
>
</button>
<!-- Erasure. The join page's data notice promises this exists, and until now nothing
did: there was no user-deletion route at any role, so honouring "please remove my
photos" meant hand-written SQL against production. -->
<button
data-testid="account-delete"
onclick={() => (deleteConfirmOpen = true)}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg
class="h-5 w-5 text-red-500 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400"
>Konto und alle Fotos löschen</span
>
</button>
</div>
</div>
</div>
@@ -624,3 +691,15 @@
onConfirm={() => handleLogout(leaveEverywhere)}
onCancel={() => (leaveConfirmOpen = false)}
/>
<!-- Erasure confirm. Separate sheet from the logout one because the copy has to be unambiguous
about the difference: logging out keeps everything, this destroys it. -->
<ConfirmSheet
open={deleteConfirmOpen}
title="Konto und alle Fotos löschen?"
message="Alle deine Fotos, Bildtexte, Kommentare und Likes werden endgültig gelöscht — auch aus der Diashow und aus dem Erinnerungs-Archiv. Das lässt sich nicht rückgängig machen."
confirmLabel="Endgültig löschen"
tone="danger"
onConfirm={handleDeleteAccount}
onCancel={() => (deleteConfirmOpen = false)}
/>

View File

@@ -195,12 +195,12 @@
clearTimeout(timer);
action();
};
// Always strictly less than the dwell — see PRELOAD_HEADROOM_MS.
const preloadBudget = Math.max(
250,
Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS)
);
timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget);
// Always strictly less than the dwell — see PRELOAD_HEADROOM_MS.
const preloadBudget = Math.max(
250,
Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS)
);
timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget);
const pre = new Image();
pre.src = candidates[i];
pre.decode().then(
@@ -445,6 +445,12 @@
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
// An unban restores that guest's photos to the eligible set. The periodic `reconcile`
// below would pick this up on its own, but this is the projector nobody is standing at —
// waiting a whole interval to un-hide photos the host has just decided are fine again is
// needlessly visible. A full reconcile is the only correct response anyway: the hidden
// cards were dropped from local state, so there is nothing to restore in place.
unsubs.push(onSseEvent('user-shown', () => void reconcile()));
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
// Open the stream ourselves — a kiosk/projector loads /diashow directly (never
// via /feed), so we can't rely on another page having opened the singleton
@@ -515,8 +521,8 @@
<div
class="pointer-events-none absolute bottom-4 left-4 max-w-xs rounded-md bg-black/60 px-3 py-2 text-left text-xs text-white/70 backdrop-blur"
>
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische
Bildschirmsperre am Gerät deaktivieren.
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische Bildschirmsperre
am Gerät deaktivieren.
</div>
{/if}

View File

@@ -126,9 +126,7 @@
// they did, the guest got a green success toast, a consumed single-use ticket, one
// of three daily slots gone, and nothing in their Downloads. Now the mint fails
// honestly and lands in the `catch` below.
const { ticket } = await api.post<{ ticket: string }>(
`/export/ticket?kind=${kind}`
);
const { ticket } = await api.post<{ ticket: string }>(`/export/ticket?kind=${kind}`);
downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
// An iframe download produces NO visible change: no spinner, no navigation, and
// on mobile often no browser chrome either. Without a word here the guest cannot

View File

@@ -308,7 +308,11 @@
unsubscribers.push(
onSseEvent('new-upload', (data) => {
try {
const upload: FeedUpload = JSON.parse(data);
// The `new-upload` payload is the backend's `UploadDto`, which carries `hashtags`
// on top of what `FeedUpload` declares — needed for the filter check below.
// Typed explicitly rather than widening `FeedUpload`, because the feed's own
// rows (from `/feed`) genuinely do not include them.
const upload: FeedUpload & { hashtags?: string[] } = JSON.parse(data);
// GRID view must NOT prepend live. Its rows are POSITIONAL windows
// (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts
// every tile by one slot: each row's keyed `{#each}` then sees a different set of
@@ -329,6 +333,12 @@
// row, and a duplicate id in a keyed `{#each}` is a thrown error, not a
// cosmetic glitch.
if (uploads.some((u) => u.id === upload.id)) return;
// Respect the active filter (H13). `uploads` IS the filtered set — the server
// applied `filterParams()` — but this handler prepended unconditionally, so a
// guest who filtered to #tanzflaeche had that filter quietly destroyed within
// minutes by everyone else's uploads, with no indication and no way back short
// of toggling the chip. The payload carries `hashtags`, so we can just check.
if (selectedHashtag && !upload.hashtags?.includes(selectedHashtag)) return;
uploads = [upload, ...uploads];
} catch {
/* ignore */
@@ -372,6 +382,17 @@
/* ignore */
}
}),
// The ban was lifted — their cards must come back. Unlike a hide we cannot do this in
// place: the cards were filtered out of `uploads`, so there is nothing left to restore
// from. Flag the feed stale and let the existing tap-to-refresh pill resync, which is
// the same treatment a truncated delta gets and keeps the guest's scroll position.
//
// Without this the unban was invisible to every open feed and to the unattended
// projector until somebody reloaded by hand — while the host's confirm copy promised
// the photos were back.
onSseEvent('user-shown', () => {
feedStale = true;
}),
// Patch the single affected card in place from the SSE payload instead of
// refetching page 1 — a busy event fires these constantly and a full reload
// would yank every scrolled-down user back to the top on each reaction.
@@ -406,9 +427,49 @@
return;
}
if (delta.uploads.length) {
// `/feed/delta` takes NO filter parameters, so its rows are the unfiltered
// event. Merging them into a filtered view is the same defect as the
// `new-upload` prepend above (H13) — and here we cannot check hashtags,
// because the delta rows do not carry them.
//
// So when a filter is active, don't merge: flag the feed stale and let the
// existing tap-to-refresh pill re-run page 1 under `filterParams()`. Same
// treatment a truncated delta gets, and it keeps the guest's scroll.
// Dedupe FIRST, in both branches. `delta.uploads.length > 0` is not
// evidence that anything NEW arrived: the delta cursor boundary is
// inclusive and `sse.ts` deliberately rewinds `lastEventTime` to an
// upload's `created_at`, so a delta routinely re-returns rows the
// stream already delivered. The filtered branch skipped this check
// entirely and set `feedStale` on EVERY delta — and the backstop
// polls every 60-120s, so a guest who tapped a hashtag got a "Neue
// Beiträge" pill they could never clear, each tap costing a full
// filtered refetch. That trains guests to ignore the one control
// that means something. It also fired for any other guest's
// non-matching upload, which by construction is never in the
// filtered `uploads`.
const seen = new Set(uploads.map((u) => u.id));
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
if (fresh.length) {
// Test the EFFECTIVE filter, not the raw chip state. `filterParams()`
// is what actually decides which rows the server returns, and in list
// view it reads `selectedHashtag` alone — `activeFilters` is ignored.
// `switchView('list')` deliberately keeps a chip there (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
// `activeFilters.length` was truthy on a list view that is genuinely
// unfiltered, and every delta raised the pill instead of merging —
// the same "a pill for rows that should have merged" this block was
// written to stop, one branch further in.
if (filterParams().toString() !== '') {
// Still cannot MERGE under a filter — `/feed/delta` takes no
// filter params and its rows carry no hashtags, so we cannot
// tell which belong in this view. Flagging stale is right;
// doing it for rows already on screen was not.
feedStale = true;
} else {
uploads = [...fresh, ...uploads];
}
}
}
// A delta reconciles new uploads and deletions, but not like/comment
// counts that changed on already-visible cards while we were

View File

@@ -3,15 +3,20 @@
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth } from '$lib/auth';
import { uuid } from '$lib/uuid';
import { markGuideSeen } from '$lib/onboarding';
import { focusTrap } from '$lib/actions/focus-trap';
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
let eventName = $state('');
// The operator's own data notice, if they set one. Usually empty — see the notice block below.
let privacyNote = $state('');
let noticeOpen = $state(false);
onMount(async () => {
try {
const ev = await api.get<{ name: string; slug: string }>('/event');
const ev = await api.get<{ name: string; slug: string; privacy_note?: string }>('/event');
eventName = ev.name;
privacyNote = ev.privacy_note?.trim() ?? '';
} catch {
// Non-fatal — fall back to the generic heading if the lookup fails.
}
@@ -34,6 +39,39 @@
let pinRequestSent = $state(false);
let pinRequestLoading = $state(false);
/**
* Stable idempotency key for THIS join attempt, surviving a reload or a PWA relaunch.
*
* The failure it closes: `/join` commits the account and the PIN hash, but the plaintext PIN
* only ever exists in the response body. Lose that response — the 5G-to-nothing handoff every
* venue car park has — and the retry used to 409 on the guest's own name, leaving them staring
* at a PIN prompt for a PIN nobody had ever seen. With a key the server recognises the retry
* and answers with a working PIN (it rotates it; see migration 027).
*
* Kept in localStorage rather than component state because the guest's instinctive response to
* a hung request is to reload the page, which would otherwise mint a fresh key and re-create
* the exact bug. Cleared once the join has demonstrably landed.
*/
const JOIN_KEY_STORAGE = 'eventsnap:join-attempt-id';
function joinAttemptId(): string {
let id: string | null = null;
try {
id = localStorage.getItem(JOIN_KEY_STORAGE);
} catch {
// Private mode / storage disabled. A per-call UUID is still better than none: it makes
// a retry within this page view idempotent, which is the common case.
}
if (!id) {
id = uuid();
try {
localStorage.setItem(JOIN_KEY_STORAGE, id);
} catch {
/* best effort */
}
}
return id;
}
async function handleJoin() {
if (!displayName.trim()) return;
loading = true;
@@ -44,9 +82,19 @@
pin: string;
user_id: string;
is_new: boolean;
}>('/join', { display_name: displayName.trim() });
}>('/join', {
display_name: displayName.trim(),
client_join_id: joinAttemptId()
});
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
// The join landed and we hold the PIN — retire the key so a later deliberate join (a
// second guest on a shared device) is a new attempt rather than a retry of this one.
try {
localStorage.removeItem(JOIN_KEY_STORAGE);
} catch {
/* best effort */
}
pin = res.pin;
showPinModal = true;
} catch (e) {
@@ -311,6 +359,70 @@
</button>
</form>
<!--
Data notice AT THE POINT OF COLLECTION.
There was none at all — not on this page, not anywhere pre-auth — while
PROJECT.md:407 claimed there was. For ~100 EU guests uploading photos of
identifiable people, including children, that was the most consequential gap in
the whole audit, and the least work to close.
The baseline text below is hardcoded rather than read from `privacy_note`,
because `privacy_note` defaults to '' (migration 009) — a notice an operator can
leave blank is not a notice. The operator's own text is shown IN ADDITION when
they have set one.
Summary visible without a tap (that is the part that has to be unmissable);
detail behind a disclosure so it does not bury the one field on the page.
-->
<div class="mt-5 border-t border-gray-200 pt-4 dark:border-gray-700">
<p class="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
Mit dem Beitreten legst du ein Konto mit deinem Namen an. Deine Fotos, Kommentare und
dein Name sind für alle Gäste dieses Events sichtbar.
</p>
<button
type="button"
onclick={() => (noticeOpen = !noticeOpen)}
data-testid="join-privacy-toggle"
aria-expanded={noticeOpen}
class="mt-1 text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{noticeOpen ? 'Weniger anzeigen' : 'Was passiert mit meinen Daten?'}
</button>
{#if noticeOpen}
<div
class="mt-2 space-y-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400"
data-testid="join-privacy-note"
>
<p>
<strong class="text-gray-800 dark:text-gray-200">Was gespeichert wird:</strong> dein angezeigter
Name, deine hochgeladenen Fotos und Videos samt Aufnahmezeitpunkt, deine Bildtexte, Kommentare
und Likes. Dazu ein verschlüsselter Prüfwert deines PINs — der PIN selbst wird nicht gespeichert.
</p>
<p>
<strong class="text-gray-800 dark:text-gray-200">Wer es sehen kann:</strong> alle Gäste
dieses Events. Die Gastgeber können außerdem Beiträge und Kommentare entfernen. Am Ende
erhalten die Gastgeber ein Archiv mit allen Fotos.
</p>
<p>
<strong class="text-gray-800 dark:text-gray-200">Wie lange:</strong> bis die Gastgeber
das Event abschließen und die Installation abbauen. Du kannst eigene Fotos jederzeit selbst
löschen, und über „Mein Konto“ dein Konto samt aller Inhalte entfernen lassen.
</p>
<p>
Lade bitte keine Fotos von Personen hoch, die damit nicht einverstanden sind — bei
Kindern brauchst du das Einverständnis der Eltern.
</p>
{#if privacyNote}
<p class="border-t border-gray-200 pt-2 dark:border-gray-700">
<strong class="text-gray-800 dark:text-gray-200">Hinweis der Gastgeber:</strong>
{privacyNote}
</p>
{/if}
</div>
{/if}
</div>
<p class="mt-4 text-center text-sm">
<a
href="/recover"

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto, afterNavigate } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin, getToken, clearPin } from '$lib/auth';
import { setAuth, getPin, getToken, clearPin, getPinOwner } from '$lib/auth';
import { markGuideSeen } from '$lib/onboarding';
import { browser } from '$app/environment';
import IconButton from '$lib/components/IconButton.svelte';
@@ -80,10 +80,31 @@
} catch (e) {
if (e instanceof ApiError) {
error = e.message;
// A wrong PIN here often means the locally-cached PIN is stale (a host reset it
// while this device was offline and missed the `pin-reset` SSE). Drop the cached
// value so it doesn't keep pre-filling the field with the dead PIN.
if (e.status === 401) clearPin();
// A wrong PIN CAN mean the locally-cached PIN is stale (a host reset it while this
// device was offline and missed the `pin-reset` SSE), and then dropping it stops the
// field pre-filling with a dead value. `+layout.svelte` already handles the online
// case; this is the offline backstop.
//
// But it must be narrow, because the backend returns the SAME 401 for a wrong PIN
// and an UNKNOWN NAME (deliberately — it closes an enumeration and timing oracle).
// Clearing on any 401 meant a guest who mistyped their own name lost the only copy
// of their PIN: localStorage is where it lives, the server keeps only the bcrypt,
// and rejoining under the same name 409s. One typo, permanently locked out of their
// own account, needing a host with a dashboard open.
//
// So clear only when the evidence actually points at a stale cache: the name they
// submitted is the one this device belongs to, AND the PIN that was rejected is the
// cached one. Any other 401 leaves stored state untouched.
// `getPinOwner()`, NOT `getDisplayName()`. A host PIN reset also revokes the guest's
// sessions, so by the time they reach this screen `clearAuth` has already deleted the
// display name — and the guard could never fire again on that device. The dead PIN
// then pre-fills this field forever, and since a 4-digit value auto-submits, every
// correction burns another of the four wrong-PIN attempts the shared venue IP allows
// per 15 minutes. The PIN's owner is stored with the PIN and survives with it.
const submittedOwnName =
getPinOwner()?.trim().toLowerCase() === displayName.trim().toLowerCase();
const submittedCachedPin = getPin() !== null && pin.trim() === getPin();
if (e.status === 401 && submittedOwnName && submittedCachedPin) clearPin();
} else {
error = 'Ein Fehler ist aufgetreten.';
}