A deep investigative review of the export concurrency (four parallel adversarial
reviewers) found that three rounds of race fixes had been chasing the wrong bug, and
that the model itself was the root cause. This replaces the model and fixes the real
data loss.
THE ACTUAL BUG (upload path, not the state machine)
`upload` checked `uploads_locked_at` BEFORE streaming the body — minutes, for a large
video — then committed the row without re-checking. A release landing mid-upload let the
row commit AFTER the export snapshot: the photo appeared in the live feed and was
PERMANENTLY missing from the keepsake, with nothing to regenerate it. release_gallery's
comment claims to prevent exactly this; it never did. Every prior round fixed the DB
state machine, and the leak was upstream of it.
Fix: re-assert the lock under `SELECT ... FOR SHARE` INSIDE the commit tx. That row lock
conflicts with release_gallery's `UPDATE event`, so either the upload commits first (and
the release — hence the snapshot — is ordered after it) or the release wins and the
upload is rejected as `uploads_locked` (reversible; the client keeps the blob).
Regression test verified NON-VACUOUS: with the fix reverted it fails, reporting accepted
uploads missing from the keepsake.
THE MODEL (migration 014)
"Which generation is current?" had no single answer: it was assembled from three
separately-written pieces across two tables (`export_released_at`, a per-job
`release_seq`, and cached `export_{zip,html}_ready` flags). Keeping them in agreement
took ~10 hand-written guards; each review round found one more path that slipped through.
Now: ONE monotonic `event.export_epoch`, bumped in the same UPDATE as any change to
`export_released_at`. Each job carries a copy. Downloadable IFF
`released AND job.epoch = event.export_epoch AND status = 'done'` — readiness DERIVED,
never stored. A retired-epoch worker is INERT BY CONSTRUCTION: losing a race can no
longer corrupt state, only waste work.
Deleted: both ready-flag columns, both ready-flip blocks, `if ready { continue }`, the
reopen seq-bump, open_event's transaction, and the cross-table claim guard.
That last one was also UNSOUND: under READ COMMITTED, a blocked UPDATE re-evaluates its
WHERE against the updated target row but answers subqueries on OTHER tables from the
original snapshot — so the previous `EXISTS (SELECT ... FROM event ...)` claim guard
could admit a claim against an already-reopened event while RETURNING the post-bump seq.
Every guard is now same-row (`epoch = $n`), which EPQ re-evaluates correctly.
OTHER REAL DEFECTS FIXED
- release_gallery committed the release and THEN awaited the enqueue inside the handler
future. Axum drops that future on client disconnect → event released, uploads locked,
ZERO export_job rows, host unable to retry ("bereits freigegeben"), restart-only
recovery. Now one tx; workers spawn (detached) after commit.
- No flush/fsync before the tmp→final rename. async_zip's close() never flushes the inner
file and tokio's File drop DISCARDS write errors — so a full disk silently produced a
truncated archive that was then marked done and published, after which the last good
generation was pruned. Now flush + sync_all, which also surfaces ENOSPC.
- Download read the ready flag and the file path in TWO queries; a reopen between them
served a keepsake that should 404. Now one read through the `export_current` view.
- `if !src.exists() { continue }` then `open()?` — a TOCTOU that turned a tolerated gap
into a hard failure of the ENTIRE keepsake (unretryable while released). Now open-first,
warn, and skip.
- Recovery was flag-driven and never checked the disk: a `done` row whose archive was gone
was a permanent dead end needing manual DB surgery. Now verifies the file and re-arms.
- Temp files/dirs leaked on every error path (the leak is what fills the disk that
corrupts the next archive). Now cleaned up.
- Export archives were not event-scoped (`viewer_tmp_` already was), so a second event
would collide on paths and one event's prune would delete another's live keepsake.
- Host deletes after release never regenerated the keepsake — a photo removed on request
lived on in the archive forever. Now bumps the epoch and rebuilds without it.
- claim_job swallowed DB errors as "someone else won", stranding a job pending at 0%.
- export_status reported retired-epoch rows (a progress bar that never moves).
- The startup sweep's single-instance assumption is now documented, not hidden.
Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop (incl. 2 new regressions; the
upload-acceptance one confirmed to fail without the fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follows the perf + security + user-flow work with a role/persona audit (guest, host,
admin, projector) and fixes across three review rounds. Highlights:
HIGH
- Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has
no replay, so a client that missed it (esp. the unattended diashow) kept cycling a
banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in
/feed/delta; feed + diashow evict those users. Applied even on a truncated delta.
MEDIUM
- Locked-upload data loss: a photo staged offline during a lock/release was purged as a
terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code;
the queue keeps the blob and auto-resumes on the `event-opened` SSE.
- Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake.
- Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never
in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host
race can't hand out a conflicting PIN.
- Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren
hidden on peer-host rows for non-admins (they always 403'd).
- Host dashboard shows live keepsake generation progress / ready state + link to /export.
- Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices.
Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq`
(migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation
temp/final paths, download follows `file_path`, prune only strictly-older generations.
LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles
galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401;
delta `>=` tie-break + 429 retry; misc copy/labels.
Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a
data-completeness test. Reconciles USER_JOURNEYS §9/§11.
Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit
tests, 155 e2e passing on chromium-desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Security audit follow-up. No Critical/High existed; these close the
Medium/Low findings.
F1 [Med] Host privilege boundary: a host could demote a peer host to
guest and then ban / PIN-reset (→ account takeover via /recover) them,
because the ban/pin-reset peer guards key off the target's *current*
role. set_role now blocks a non-admin from demoting a host.
F2 [Med] Moderation now revokes preview/thumbnail access: they are served
through visibility-checked aliases (/api/v1/upload/{id}/{preview,
thumbnail}) that filter soft-deleted / ban-hidden uploads, and direct
/media/previews|thumbnails is 404-blocked. Feed emits the gated URLs;
Caddy keeps them privately cacheable (max-age=300, short so moderation
revokes promptly — the live feed already evicts cards via SSE). Uses a
lean find_visible_media() (paths+mime only) on the media hot path.
F3 [Med/Low] npm audit fix: svelte 5.55.1→5.56.4 (SSR-XSS advisories),
devalue 5.6.4→5.8.1 (DoS). Prod deps: 0 vulnerabilities.
F4 [Low] /recover no longer leaks account existence: unknown display name
returns the same 401 as a wrong PIN, and runs a dummy bcrypt verify so
timing matches — closing the enumeration + timing oracle.
F5 [Low] SSE streams re-validate the session every ~60s and close once it
is logged out / expired, instead of running until client disconnect.
F6 [Info] X-Content-Type-Options: nosniff set at the app layer on all
media responses (defense-in-depth alongside the edge).
Tests: new e2e specs for F1 (host cannot demote peer host), F2 (preview
gated + 404 after delete + direct /media blocked), F4 (uniform 401).
40 backend unit tests + 182 e2e passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Performance:
- Cache the runtime `config` table in-memory (ConfigCache) with synchronous
invalidation on every write (admin PATCH + test reseed). Was re-reading each
key from Postgres on every request (~8 round-trips per upload).
- Stream uploads chunk-by-chunk to a temp file instead of buffering the whole
body in RAM (peak was up to the per-class cap, e.g. 500 MB/video); only 512
sniff-bytes are kept for magic-byte detection, then atomic rename into place.
- Cache the media-filesystem disk snapshot (DiskCache, 15s TTL) shared by the
quota check and admin stats; drop the discarded System::refresh_all().
- HTML export streams video (and small-image) originals straight into the ZIP
via a manifest instead of copying them to a temp dir first (removed the
transient 2x disk usage) and drops the double directory scan.
- Auth extractor resolves session -> live user in one JOIN (was two queries),
touching last_seen_at by token hash.
Stability:
- SSE: on broadcast lag, emit a `resync` event so the client runs a delta
fetch instead of silently losing events; frontend reconciles adds, deletions,
and (via an in-place refresh) like/comment counts on visible cards.
- Storage quota fails OPEN when the disk can't be read (was a 0-byte limit that
locked out all uploads).
- Graceful shutdown drains in-flight requests on SIGTERM/SIGINT, bounded by a
10s backstop so open SSE streams can't stall a deploy.
- Upload removes the persisted file if the DB transaction fails (no orphaned
bytes with no row to reclaim them).
Tests:
- New pure select_disk() with 5 unit tests (longest-prefix, fallbacks, fail-open).
- New e2e export-video spec covering the HTML export's video-streaming branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A comprehensive role-based E2E audit (guest/host/admin, across browser
sessions) surfaced one critical and several smaller issues; this addresses
them and hardens the tests that missed them.
Critical
- The client upload pipeline was fully broken: the IndexedDB v1->v2 upgrade
opened a *new* transaction inside the upgrade callback, which throws during
a version-change transaction and aborted the whole upgrade, leaving the
queue object store uncreated -- so no UI upload ever fired. Reuse the
version-change transaction the callback provides, and bump the DB to v3 with
a contains() guard so installs already corrupted by the shipped bug
self-heal on next load. Re-enabled the previously-fixme'd UI upload E2E test.
High / Medium
- Event lock is uploads-only again: likes, comments and browsing stay open
while the event is locked (USER_JOURNEYS 9.3 / FEATURES) -- it was wrongly
freezing social interaction. Updated the event-lock spec accordingly.
- get_original now excludes soft-deleted and ban-hidden uploads, and direct
/media/originals/** serving is blocked, so a hidden user's originals can no
longer be pulled by UUID (all originals go through the checked alias).
- The upload handler reads the file field with an early-abort size cap chosen
from the declared content-type, instead of buffering the entire body before
the size check.
Low
- unban_user mirrors the ban role guard (a host can no longer unban a
host/admin banned by an admin).
- reset_user_pin's UPDATE is event-scoped.
- Admin login returns and stores a real identity (user_id + display name)
instead of a blank session.
- The host user list no longer renders target-actions (ban/promote/demote/PIN)
on the caller's own row, where the backend always rejected them.
- /diashow gains a client-side auth guard like the other protected routes.
- The join page shows the event name via a new public GET /api/v1/event.
Verified: backend cargo build clean, frontend svelte-check 0 errors, full
Playwright E2E suite 144 passed / 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-2 re-review (full suite green) found two real defects the passing
tests missed, both now fixed and covered:
B1 — banned users could still edit/delete their OWN content: edit_upload,
delete_upload, delete_comment skipped the is_banned guard that upload/
like/comment got (round-2 removed the global extractor block and missed
these three). Added the guard (using auth.is_banned — no extra query).
The moderation ban test now asserts 403 on DELETE upload, PATCH upload,
and DELETE comment for a banned owner (+ upload survives) — it previously
only exercised POST /upload, which is why the gap shipped green.
B2 — H3 live-eviction was dead code: 'user-hidden' and 'comment-deleted' were
missing from KNOWN_EVENTS, so EventSource never listened and the handlers
never fired. Added both. New browser-driven sse-eviction test asserts a
hidden user's card actually evicts from an open feed with no reload — the
backend-only SseListener checks couldn't catch this.
Minor items folded in:
- admin dashboard bounces a mid-session-demoted admin to /feed via live
/me/context (mirrors the host page) instead of a dead error screen.
- feed "new posts" pill cleared on any full refresh (pull-to-refresh no longer
strands it).
- corrected the stale sse.rs comment (banned users may hold a read-only stream).
Verified: backend 35 unit tests; svelte-check 0 errors; e2e 04-host + comment-ui
+ export-leak 13/13 on chromium (incl. the two new regression tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
H1: corrected a round-1 over-reach. Bans are read-only per USER_JOURNEYS §10:
the base AuthUser extractor no longer rejects banned users (they keep read
access); Require{Host,Admin} and write handlers enforce the ban via
auth.is_banned → 403 (incl. self-unban). Demoted hosts still lose powers
immediately (live DB role) and are bounced off the dashboard. Also fixed the
login/recover redirect regression on unauthenticated 401.
H2: failed compression now self-heals instead of leaving a permanent broken
card — refund the quota, delete the orphaned original, soft-delete the row;
the frontend toasts the uploader and evicts the card on upload-error.
H3: self-delete, ban-with-hide (user-hidden), and comment-deletes now broadcast
SSE; feed, diashow, and lightbox evict the affected content live instead of
waiting for a reload. sse-eviction.spec.ts covers it.
Riders: feed/+page.svelte also carries the medium truncated-delta pill; host.rs
also carries the low atomic release_gallery claim; admin/+page.svelte also drops
the dead compression_concurrency control (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
H1: auth extractor re-reads the live user row — trusts the DB role (not the
JWT claim) and rejects banned users mid-session. e2e proves a demoted
host loses powers and a banned host is locked out and can't self-unban.
H2: client_ip takes the right-most XFF hop (the one Caddy appends); spoofed
left-most entries are ignored, restoring IP-based throttles.
H3: Caddy excludes /api/v1/stream from `encode` so SSE isn't buffered.
H4: upload — quota increment + row insert + hashtag links now one txn.
H5: feed keyset pagination tiebroken on (created_at, id) + composite index
(migration 010); feed_delta bounded with LIMIT.
H7: LightboxModal keys its comment load off upload.id, ending the
SSE-driven refetch storm.
H8: CSP via SvelteKit kit.csp (svelte.config.js) hardens the localStorage
token model against injection.
Migration 010 also adds the latent comment/comment_hashtag indexes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the two items from the adversarial re-review of batch-2.
- upload: remove image/heic + image/heif from ALLOWED_MEDIA. Neither the
`image` crate nor the bundled ffmpeg 6.1 (Alpine 3.21 — HEIF demuxer
only landed in ffmpeg 7.0) can decode them, so accepting them stored
posts that never got a thumbnail. iOS Safari transcodes HEIC->JPEG on
file-input selection, so this rejects only the rare HEIC-preserving
path, now with a clear error instead of a silently broken post.
- data-mode-store: correct the stale "auth-gated" comment — the
/original route is intentionally unauthenticated (UUID-as-capability)
so it works from plain <img src> / <video src>.
Re-verified: cargo build (only the pre-existing middleware.rs warning).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the seven findings from the security & deployment review.
High:
- upload: make infer authoritative — reject files it can't identify
(SVG/HTML/JS) and require the detected MIME to be on an ALLOWED_MEDIA
allowlist; derive the stored MIME and on-disk extension from the
detected type, ignoring client filename/Content-Type. Closes the
stored-XSS vector via media served on-origin.
- deploy: rename docker-compose.override.yml -> docker-compose.dev.yml
so the default `docker compose up -d` no longer publishes Postgres
5432 to the host; the port map is now opt-in via -f. README updated.
Medium:
- upload: DefaultBodyLimit::disable() -> max(576 MiB) as an HTTP-level
OOM backstop; handler still enforces precise per-class size limits.
- docker: run backend and frontend as non-root users.
Low:
- social/upload: event-scope toggle_like, list_comments, add_comment,
delete_comment, edit_upload, delete_upload via find_by_id_and_event /
soft_delete_in_event — cross-event IDs now resolve to 404.
- Caddy: site-wide HSTS / nosniff / X-Frame-Options / Referrer-Policy,
plus Content-Disposition: attachment on /media/originals/*.
- .env.example: replace default Postgres password with a CHANGE_ME hint.
Out of scope: localStorage JWT (root cause fixed; httpOnly cookies are a
larger change tracked separately).
Verified: cargo build (no new warnings), cargo test (3 passed),
caddy validate, docker compose config (no 5432 published by default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the comprehensive code review. Five batches:
1. Cross-event authorization: host_delete_upload, unban_user, and
host_delete_comment now scope by auth.event_id. Adds
Upload::find_by_id_and_event / soft_delete_in_event and a
Comment::soft_delete_in_event variant that joins through upload.
2. Token exposure: SSE auth no longer puts the JWT in the URL.
New /api/v1/stream/ticket endpoint mints a short-lived single-use
ticket bound to the session; the EventSource passes ?ticket=...
instead. Refuse to start in APP_ENV=production with the dev JWT
sentinel; warn loudly otherwise.
3. Account hardening: per-IP+name rate limit on /recover (mitigates
targeted lockout DoS), per-IP rate limit on /admin/login, random
32-char admin recovery PIN (replaces "0000"), structured tracing
events for wrong PIN, lockout, failed admin login, ban/unban/role
change/pin-reset/host-delete.
4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before=
cursor), hashtag extraction whitelisted to ASCII alnum+underscore
(≤40 chars) with unit tests, display_name / caption / comment body
length validated in chars rather than bytes.
5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS
env var (default 10).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
+ quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
(live used / limit / active uploaders / free disk).
- handlers/upload.rs:
- quota enforcement via the dynamic formula
floor((free_disk * tolerance) / max(active_uploaders, 1)),
gated by quota_enabled + storage_quota_enabled toggles
- new GET /api/v1/upload/{id}/original — unauthed by design
(matches /media/previews/* — URL is the secret) so it works as
<img src> / <video src> / window.open
- rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
- POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
Admin may reset guest + host PINs (never another admin or self).
Returns the freshly-generated plaintext PIN once; emits a global
pin-reset SSE so the affected user's device can clear its localStorage.
- set_role guard expanded so hosts can demote other hosts (not self,
never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
reset failed_pin_attempts to zero before the bcrypt check — without
this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
new /me/context, /me/quota, /upload/{id}/original, and
/host/users/{id}/pin-reset routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend: rate limiter gains check_with_retry() returning seconds until
the next slot opens. Upload 429 responses include retry_after_secs in
JSON and a Retry-After header.
Frontend: upload queue catches 429 as RateLimitError, resets affected
item to pending, schedules processQueue() for the server-reported delay,
and shows a live countdown banner in the queue UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Disable Axum's 2 MB default body limit on the upload route so large
photos/videos are accepted without HTTP 400
- Serialize UserRole as lowercase in JWT so the frontend role checks
('guest'/'host'/'admin') match correctly
- Drain multipart body before returning early upload errors (rate-limit,
ban, event-lock) to keep the HTTP keep-alive connection clean and
prevent cascading Netzwerkfehler / empty-500 responses
- Add TraceLayer for request logging and Vite dev proxy config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- POST /api/v1/upload: multipart file upload with caption + hashtags
- Validates file size against DB config limits (image/video separate)
- Checks user ban status and event upload lock
- Saves original to disk under {media_path}/originals/{slug}/
- Tracks user total_upload_bytes for quota enforcement
- Extracts hashtags from caption text and explicit CSV field
- Upserts hashtags and links them to uploads
- PATCH /api/v1/upload/{id}: edit caption and hashtags (owner only)
- DELETE /api/v1/upload/{id}: soft-delete (owner only)
- GET /api/v1/stream: SSE endpoint with 30s keepalive
- Broadcasts new-upload events to all connected clients
- Uses tokio broadcast channel for fan-out
Services:
- CompressionWorker: Tokio semaphore-bounded (concurrency=2) background processor
- Images: resize to 800px wide JPEG preview via image crate
- PNG originals: lossless compression via oxipng
- Videos: ffmpeg thumbnail extraction (1 frame at 1s, scaled to 800px)
- Updates upload record with preview_path/thumbnail_path on completion
Models:
- Upload with full CRUD (create, find, update caption, soft delete, set paths)
- Hashtag with upsert, link/unlink, extract_hashtags() text parser
- UploadDto for API serialization with like/comment counts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>