49 Commits

Author SHA1 Message Date
MechaCat02
641174717c fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m52s
E2E / Cross-UA smoke matrix (push) Failing after 3m50s
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:

- Offline upload queue auto-resumes on reconnect: network errors keep items
  pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
  returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
  re-release regenerates the keepsake; workers claim their job atomically so a
  reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
  exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
  revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
  is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
  not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
  live; feed_delta rate-limited; like returns {liked, like_count} to fix
  multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
  quota increment is transactional; operator floor.

Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:05:37 +02:00
fabi
16d1f356be Merge branch 'security/audit-2026-07-07'
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 5m57s
E2E / Cross-UA smoke matrix (push) Failing after 2m22s
Security audit fixes: host privilege-boundary (demote-then-act), gated
preview/thumbnail access for moderation, /recover enumeration + timing,
periodic SSE session revalidation, app-layer nosniff, and svelte/devalue
CVE bumps. 40 backend unit tests + 182 e2e passing.
2026-07-08 06:14:07 +02:00
fabi
a4b2c5bd1c fix(security): harden authz, media gating, recover, SSE, deps
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>
2026-07-08 06:14:00 +02:00
fabi
683b1b6f65 Merge branch 'perf/stability-2026-07-07'
Performance & stability batch: config cache, streaming uploads, disk-snapshot
cache, export video streaming, single-query auth, SSE resync-on-lag, quota
fail-open, graceful shutdown, and tx-failure cleanup. 40 backend unit tests +
179 e2e passing.
2026-07-07 20:26:52 +02:00
fabi
d6c91974eb perf(backend): close config/upload/export hot paths + stability fixes
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>
2026-07-07 20:26:45 +02:00
fabi
4cdb3ae14a Merge branch 'fix/audit-2026-07-07' 2026-07-07 07:28:32 +02:00
fabi
faf7a2504a fix(audit): restore broken upload pipeline + role-based E2E audit fixes
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>
2026-07-07 07:28:27 +02:00
fabi
14c667c694 Merge branch 'fix/review-2026-07-02-stack'
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m9s
E2E / Cross-UA smoke matrix (push) Failing after 2m27s
Whole-stack review round 2: repair the broken comment button (CR1) and close
the unauthenticated export-archive leak (CR2); read-only ban model + failed-
compression cleanup + live SSE eviction (H1/H2/H3); config validation, a11y,
and hygiene (medium/low). Includes re-review follow-ups: ban guard on
edit/delete handlers, KNOWN_EVENTS registration for user-hidden/comment-deleted,
and a strengthened real-export leak test (+ truncate export-dir isolation).

Verified: backend 35 unit tests; svelte-check 0 errors; full chromium e2e
143 passed / 2 skipped / 0 failed.
2026-07-03 07:22:18 +02:00
fabi
3f6dafba05 test(review-2): strengthen CR2 export-leak test to drive a real export
The prior test asserted 404 on /media/exports/Gallery.zip against an empty
stack — it would pass even if exports were still written under /media, because
no archive was ever produced. Now it:
  1. seeds an upload, releases the gallery, and polls until the real zip job
     writes Gallery.zip to disk;
  2. asserts the archive is NOT served from public /media (the CR2 leak); and
  3. asserts it IS retrievable via the gated ticket endpoint (200 + PK zip
     magic) — proving the 404 means "not public", not "no file".

Also fixes a test-isolation gap the CR2 relocation introduced: __truncate wiped
media_path but not export_path, so a real export would leave Gallery.zip on disk
and break export.spec's "ready-but-file-missing → 404" test. truncate_all now
purges export_path too. Full 06-export dir: 5/5 green, no contamination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:19:09 +02:00
fabi
0ed97f45cf fix(review-2): address re-review — close two blocker regressions + harden tests
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>
2026-07-02 22:45:57 +02:00
fabi
b3d876fa85 fix(review-2): medium/low + docs — config validation, hygiene, doc sync
- compression_concurrency wired to boot config (state.rs) and removed as a
  dead admin control.
- patch_config gains per-key integer/range validation so numerically-valid-but-
  nonsensical values (-1/NaN/3.5) are rejected instead of silently reverting to
  default at read time.
- clearAuth now also clears the display name (shared-device residual).
- e2e/Caddyfile.test mirrors prod security headers + the SSE encode-exclusion so
  the test stack is representative and can catch header regressions.
- .gitignore: ignore root-level Playwright artifacts (test-results/, report).
- docs: USER_JOURNEYS §10 and SECURITY-BACKLOG updated to match the implemented
  read-only-ban behavior and the export-path fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:56 +02:00
fabi
80179357a7 fix(review-2): high — read-only ban model, failed-compression cleanup, live SSE eviction
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>
2026-07-02 22:19:43 +02:00
fabi
dba4d3f932 fix(review-2): critical — repair comment posting + close export data leak
CR1: the lightbox comment button POSTed to /upload/{id}/comment (singular);
     no such route exists, so every UI-submitted comment 404'd and was lost.
     Fixed to /comments (plural). The prior e2e passed because its seed helper
     POSTs the API directly — added comment-ui.spec.ts which drives the real
     component so this can't regress silently again.

CR2: export archives (Gallery.zip / Memories.zip / HTML viewer) were written
     under media_path, which is a public ServeDir — so GET /media/exports/
     Gallery.zip served the entire event (every photo, caption, comment,
     uploader name) to any anonymous visitor at a guessable URL, bypassing the
     ticket + release gate. Moved exports to a separate EXPORT_PATH (=/exports)
     on its own volume (Dockerfile chown, compose volume, gated handler reads
     the new path). export-leak.spec.ts asserts /media/exports/Gallery.zip → 404.

Riders (git can't split hunks): LightboxModal also gains H3 live-eviction on
comment-deleted/upload-deleted; config.rs also wires compression_concurrency
from boot config (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:32 +02:00
fabi
239459bb91 Merge branch 'fix/review-2026-07-01-hardening'
Whole-project review hardening: repair broken PIN reset + enforce real prod
secrets (Critical); live-role/ban authz, XFF fix, un-buffered SSE, atomic
writes, tiebroken pagination, CSP (High); event-lock, atomic config, a11y
inert, diashow, hygiene (Medium/Low); plus re-review follow-ups (CSP nonce,
feed_delta truncation signal, tightened H1 assertions, edge-case tests, docs).

Verified: backend 35 unit tests, frontend svelte-check + build, e2e 04-host
12/13 + 03-feed 8/8 on chromium-desktop.
2026-07-02 20:20:26 +02:00
fabi
41ac742af8 test+docs: tighten H1 assertions, cover secret/XFF edges, document boot & SSE-ban
Re-review follow-ups (non-blocking quality items):

Tests:
- moderation H1 specs now assert exact `→ 403` instead of bare .rejects.toThrow(),
  so a spurious 500 can no longer masquerade as "revocation worked".
- config: added case-insensitivity (upper/mixed-case placeholder) and the
  len==32/31 boundary cases for validate_secrets.
- rate_limiter: added the trailing-comma empty-entry case for client_ip (must
  fall back, not return "").
  (Backend unit tests: 35 pass.)

Docs:
- README: note that with APP_ENV=production a placeholder .env makes the app
  refuse to boot and Caddy wait unhealthy — reason is in `docker compose logs app`.
- SECURITY-BACKLOG + sse.rs comment: document that a mid-session ban does not
  tear down an already-open SSE stream (new tickets are blocked; low blast radius).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 07:14:33 +02:00
fabi
a084ba86c5 fix(review): CSP nonce for FOUC script + feed_delta truncation signal
Two items surfaced by the branch re-review:

CSP regression (blocking): script-src 'self' blocked the template-authored
anti-FOUC theme script in app.html — SvelteKit's mode:'auto' only hashes
scripts it injects. Added nonce="%sveltekit.nonce%" so it's substituted per
request and included in the CSP (survives future edits, unlike a pinned hash).
Verified: emitted CSP nonce matches the script tag; no violation, no flash.

feed_delta silent truncation: the LIMIT 200 (added earlier to kill the
stale-`since` DoS) returned only the newest slice with no signal, so a client
that missed 200+ uploads during a reconnect could not tell it should full-
refresh — the older missed uploads were dropped and unrecoverable (the next
delta advances `since` past them). DeltaResponse now carries `truncated`; the
feed's feed-delta handler calls loadFeed(true) to resync from page 1 instead
of merging a partial slice.

Verified: cargo check clean, svelte-check 0 errors, production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:51:08 +02:00
fabi
91ff961850 fix(security): medium/low — event-lock, atomic config, a11y, diashow, hygiene
- social: likes/comments rejected on a closed event (e2e proven).
- admin: patch_config validates-then-writes atomically; char-based length.
- hashtag/comment models: atomic tag writes in edit + comment paths.
- Modal: inert the background (modal-inert action) for screen readers.
- sse.ts: idempotent visibilitychange listener (no duplicate reconnects).
- diashow: videos advance on `ended` (transitions + page wiring).
- docs/hygiene: README clone-case fix; removed stale committed .env.test and
  gitignored it; docker-compose.dev.yml tidy.

Left documented as accepted-risk per plan: PIN-persist-after-logout,
UUID-reachable hidden uploads, DECISION-media-auth.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:58 +02:00
fabi
f08d858281 fix(security): high — live authz, XFF, SSE buffering, atomicity, pagination
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>
2026-07-01 21:25:50 +02:00
fabi
498b4e256b fix(security): critical — repair PIN reset + enforce real prod secrets
C1: reset_user_pin wrote to a non-existent column (pin_failed_attempts);
    the real column is failed_pin_attempts, so every PIN reset 500'd. Fixed
    the column name; new e2e (pin-reset.spec.ts) proves a reset returns a
    usable PIN and the target can recover with it.

C2: config.rs::validate_secrets now rejects placeholder-ish secrets
    (change_me/dev_secret/placeholder), enforces len>=32 in prod, and
    requires a real ADMIN_PASSWORD_HASH. docker-compose.yml sets
    APP_ENV=production so the guard actually runs. Corrected the false
    "fixed" claim in SECURITY-BACKLOG.md. .env.example documents the rule.

Riders in these files (documented here since git can't split hunks):
- host.rs also carries the event-scoped ban_user fix and the
  close_event/open_event no-op broadcast guard (medium).
- docker-compose.yml also adds ORIGIN (H6), app/frontend healthchecks with
  Caddy waiting on health, and per-service memory limits (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:39 +02:00
fabi
5d21b3eefd Merge test/filter-search: grid filter OR/AND coverage (pure fn + unit + e2e)
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m10s
E2E / Cross-UA smoke matrix (push) Failing after 2m25s
2026-07-01 20:08:28 +02:00
fabi
74849c8d50 test: cover grid filter OR/AND (extract pure fn + unit + real e2e)
The two filter-search cases were test.fixme placeholders (expect(true).toBe(true)),
so the OR/AND chip rules were untested.

- Extract the grid-filter predicate from feed/+page.svelte into a pure
  filterUploads(uploads, filters) in $lib/feed-filter (behavior-preserving) and
  unit-test it (9 cases): tag OR, user OR, tag+user AND, AND-excludes-partial,
  case-insensitive caption match, null caption.
- Replace the e2e fixmes with real tests that seed known captions/uploaders, switch
  to grid view, activate chips via the search suggestions, and count grid tiles:
  OR widens 1→2, AND narrows 2→1.

Frontend unit: 27 passing. filter-search e2e: 3 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:08:28 +02:00
fabi
6ed0aaf0d7 Merge test/broaden-unit-coverage: quota formula + auth JWT decode unit tests 2026-07-01 19:53:03 +02:00
fabi
226e455328 test(unit): broaden coverage — quota formula + auth token/JWT decode
Backend: extract the per-user quota formula from compute_storage_quota into a
pure `quota_limit_bytes(free_disk, tolerance, active)` (behavior-preserving) and
unit-test it: tolerance scaling, floor of fractional results, active<1 clamped to
1 (divide-by-zero guard), zero free disk, single-uploader identity.

Frontend (jsdom + browser:true mock): auth.ts token storage and JWT claim decode
— setAuth/getToken/getPin round-trip, clearAuth keeps the PIN (for recovery),
clearPin, getExpiry (exp seconds→ms Date; null on missing/malformed), getRole
(role claim; null on absent/malformed). Adds jsdom devDependency.

Backend: 25 passing. Frontend: 18 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:53:03 +02:00
fabi
2c44006392 Merge test/review-followups: dedup helpers + XSS render guard + SSE hardening 2026-07-01 19:49:44 +02:00
fabi
b1e2e66305 test(e2e): address self-review follow-ups (dedup, XSS render guard, SSE hardening)
Follow-ups from the code review of the test-quality batches:

- Consolidate duplicated helpers into e2e/helpers/: seed.ts (seedUpload,
  seedComment, listComments, findFeedRow) and sse.ts (mintSseTicket, openStream,
  trackStreamOpens). Refactor authorization-deep, xss-injection, like-comment,
  sse-ticket-abuse, ddos, sse-realtime, multi-tab, and SseListener to use them —
  the upload/comment/ticket-flow contracts now live in one place each instead of
  being re-inlined across 3–7 specs.
- xss-injection display-name loop: it navigated to /feed (which renders uploader
  names, not the viewer's) so "nothing fired" passed vacuously — the payload was
  never rendered. Now navigate to /account (the actual sink) and add a render
  guard asserting the payload reached the DOM as escaped text before checking
  __xssFired.
- sse-realtime reconnect: snapshot the stream-open count AFTER backgrounding, so
  the "new connection" assertion is attributable to the foreground event and can't
  be satisfied by a spurious native/error reconnect before the toggle.
- recover-page: correct the comment (auto-submit is the onPinInput handler, not an
  $effect).

44 affected specs verified green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:49:44 +02:00
fabi
ec64fc361b Merge test/fix-vacuous-429-test: 429 retry test now actually fires 2026-07-01 19:33:28 +02:00
fabi
5f523d55fe test(e2e): fix vacuous 429 retry test (route never matched the feed)
The "429 from server is surfaced (no infinite retry storm)" test routed
`**/api/v1/feed`, but the app requests `/api/v1/feed?limit=20` — a plain glob
without a trailing wildcard doesn't match a URL with a query string, so the
route never fired: `attempts` stayed 0 and `expect(attempts).toBeLessThan(15)`
passed trivially. The test never forced a 429 or exercised any retry behavior.

Fix: match with a regex `/\/api\/v1\/feed(\?|$)/` (catches the query-string URL,
excludes /feed/delta), and gate on `attempts >= 1` before judging retry
behavior so it can't pass again without actually hitting the throttled endpoint.

Found during self-review of the test-quality batches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:33:28 +02:00
fabi
bbdb45155e Merge test/robustness-cleanup: stable locators + deterministic assertions
Stable data-testid for ContextSheet (drops animation-class coupling), aria-label
like-button locator, exact stats count, and poll-until-stable instead of a fixed
3s sleep in the 429 retry test.
2026-07-01 19:24:03 +02:00
fabi
564104ae23 test(e2e): robustness — stable locators, exact assertions, no fixed sleep
- ContextSheet: add data-testid="context-sheet". longpress tests targeted the
  open sheet via the `.translate-y-0` animation class (breaks on any animation
  refactor) — now target `[data-testid="context-sheet"][aria-modal="true"]`,
  which is stable and unambiguous vs. the centered LightboxModal (also aria-modal).
- toast-on-failure: the like button was `button.filter(hasText:/\d+/).first()`,
  which could match any digit-bearing button (e.g. the comment count) → use the
  stable aria-label "Gefällt mir".
- config stats: assert the exact user_count (4 = 3 seeded guests + admin) instead
  of `>= 3` — deterministic after the per-test truncate, catches under/overcount.
- offline-network 429 test: replace the fixed 3s waitForTimeout with a
  poll-until-the-retry-count-stabilizes (faster, and a real storm never stabilizes
  → the poll fails, which is the intended outcome).

Note: reviewed the "config restore not in try/finally" finding — it's a non-issue.
The truncate auto-fixture wipes+reseeds the whole config table (and clears the
rate limiter) before every test, so config state cannot leak between tests.

All affected specs verified green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:24:03 +02:00
fabi
104c3dde16 Merge test/unit-layer: backend cargo + frontend vitest unit tests
Add a fast, DB-free unit layer: backend #[cfg(test)] modules for the rate
limiter, SSE ticket store, and hashtag boundaries; frontend Vitest (with an
$app/environment stub) for avatar and pickMediaUrl logic. 20 backend + 11
frontend tests, all green.
2026-07-01 19:14:33 +02:00
fabi
723a492d44 test(unit): add a unit-test layer (backend cargo + frontend vitest)
The suite was almost entirely e2e; pure logic had no direct coverage. Add fast,
DB-free unit tests on both sides.

Backend (cargo test — inline #[cfg(test)] modules):
- rate_limiter: allow-up-to-max-then-block, per-key independence, sliding-window
  expiry, retry-after bounds, clear(), and client_ip X-Forwarded-For parsing
  (first entry / whitespace-trim / fallback).
- sse_tickets: single-use consume (replay → None), unknown ticket, uniqueness +
  hex shape, prune keeps fresh tickets, and an expired ticket (injected past-TTL
  entry) consumes to None.
- hashtag: fill the boundary gaps the 3 existing tests missed — stop-at-non-word,
  the 40-char cap (drops, doesn't truncate), no-dedup contract, and the German
  umlaut truncation limitation (pinned so a future Unicode fix is deliberate).

Frontend (Vitest — new, standalone config that stubs $app/environment so
server-safe module paths import cleanly under node):
- avatar: avatarPalette (neutral for empty, deterministic, real palette entry) and
  initials (?, single word, two words, whitespace collapse).
- data-mode-store: pickMediaUrl across original/saver modes and the
  preview→thumbnail→original fallback chain.
- `npm run test:unit` script added.

Backend: 20 passing. Frontend: 11 passing. svelte-check: 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:14:33 +02:00
fabi
fabc6af656 Merge test/deflake-vacuous-tests: make vacuous feed/SSE tests real
Fix the SSE listener (ticket flow) and replace assert-nothing tests with real
coverage: like toggle/count semantics, comment→SSE delivery, SSE reconnect on
visibility change, per-tab SSE connection, and remove a safe-area no-op.
2026-07-01 19:05:53 +02:00
fabi
136417d6b4 test(e2e): make vacuous feed/SSE tests assert real behavior
Several tests ran green while asserting nothing. Replace them with real
assertions, and fix the SSE helper they depend on.

sse-listener: exchange the JWT for a single-use ticket (POST /stream/ticket) and
connect via ?ticket= — the helper still used the dead ?token= scheme, so every
SSE-based assertion would have silently failed to receive events.

like-comment:
- "like is idempotent" asserted nothing (void feed; void b) → now seeds a real
  upload and pins the like contract: counted once per user, toggles off on repeat
  (guards double-count), and a second user's like is counted independently.
- "comment → SSE to B" asserted length >= 0 (always true) → B now subscribes to
  the stream, A comments, and B must receive the new-comment event for that upload
  (comment_count === 1). ~30s due to reverse-proxy SSE buffering; timeout raised.

sse-realtime: only checked a nav link was visible → now counts EventSource opens
and asserts a fresh stream connection after hidden→visible (also fixes the sim,
which set visibilityState but not document.hidden, so the close never fired).

multi-tab "SSE delivers to both": only checked nav links → now asserts each tab
opens its own stream connection (delivery isn't asserted — it hinges on the ~30s
proxy buffering; connection establishment is the reliable, honest signal).

safe-area: delete the /join probe whose only assertion was Array.isArray(x) ===
true (always true); the real sheet-level env() check already exists below it.

All verified green against the live backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:05:53 +02:00
fabi
d4aa7b4932 Merge test/stored-xss-sse-abuse: stored-XSS + SSE-ticket abuse coverage
Add stored-XSS coverage for captions and comments (inert-render assertions) and
a new SSE-ticket-abuse spec (auth-required mint, single-use replay rejection,
garbage-ticket rejection).
2026-07-01 07:34:56 +02:00
fabi
8f6e1d4ff7 test(e2e): stored-XSS (caption/comment) + SSE-ticket abuse coverage
Close two malicious-input gaps flagged in the suite review: XSS was only fuzzed
through display_name, and the SSE ticket flow had no security assertions.

xss-injection:
- Stored XSS in captions — upload with each XSS payload as the caption, mark it
  feed-visible, render /feed and assert window.__xssFired stays false, no dialog
  fires, and no live `img[onerror]`/`<script>` element is produced (Svelte escaping
  renders it as inert text). A trailing CAPMARK gates the assertion on the caption
  actually having rendered, so it can't pass vacuously.
- Stored XSS in comments — post the two render-executing payloads as a comment,
  open the lightbox (which loads comments) and assert the same inert-render props.

sse-ticket-abuse (new):
- Minting a ticket requires auth (POST /stream/ticket without Bearer → 401).
- Single-use: after the first open consumes the ticket, replaying it → 401 (a 200
  would be capability replay). The first open (→200) also proves a fresh ticket works.
- An unminted/garbage ticket → 401.

All verified green against the live backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:34:55 +02:00
fabi
da586d0978 Merge test/pin-security-assertions: pin security assertions + real IDOR
Turn accept-both-outcomes documentation tests into pinned secure-behavior
assertions (SVG stored-XSS→400, zero-byte→400, path-traversal→201, export→404),
and replace fake-UUID authz tests with real cross-user IDOR coverage (comment
delete, upload delete, caption edit all → 403 with no state change).
2026-07-01 07:28:23 +02:00
fabi
20125fb713 test(e2e): pin security assertions + real cross-user IDOR coverage
Turn "accept-both-outcomes" documentation tests — which pass whether the app is
secure or vulnerable — into assertions that pin the secure behavior, and replace
fake-UUID authz tests that 404'd before ever reaching the ownership guard with
real cross-user resources.

file-upload-attacks:
- SVG-with-<script> → pin 400 (infer returns None for text → stored-XSS defense);
  was [201,400], which accepted the vulnerable outcome.
- zero-byte → pin 400; path-traversal → pin 201 with UUID-derived storage and a
  no-path-echo check (both were [201,400]).
- Fix the application/octet-stream rationale (no "application bypass" exists — the
  handler ignores the declared type and keys off magic bytes).

authorization-deep (IDOR):
- B deleting A's comment: seed a real upload+comment as A, assert 403, assert the
  comment survives, and assert the owner (A) still gets 204 — proving the 403 is
  about identity, not a broken route. (Was a DELETE on the all-zeros UUID → 404
  before the user_id guard, so authorization was never exercised.)
- Add B-deletes-A's-upload (403, countUploads unchanged) and B-edits-A's-caption
  (403, caption intact) — the find_by_id_and_event + ownership guards.

export: pin the ZIP-download 404 (was [404,200] — a 200 is a data-exposure
regression) and split into the two real branches: not-yet-ready, and
ready-but-file-missing (new db.setExportZipReady helper).

All 21 assertions verified green against the live secure backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:27:22 +02:00
fabi
6ccf2d0011 Merge fix/sheet-aria-modal-gating: sheet a11y gating + test-suite hygiene
Gate always-mounted sheet dialog semantics on open state; fix focus-trap Escape
drop and deep-link back navigation; update the e2e suite for shipped magic-byte
validation, SSE ticket flow and PIN auto-submit; gate Chromium-only permissions.
2026-06-30 22:12:11 +02:00
fabi
a3c0082c4b test(e2e): update suite for shipped features + gate Chromium-only perms
The running test container had been built from an older tree; rebuilding it to
pick up current app code surfaced several tests that predated shipped
security/UX features:

- file-upload-attacks / rate-limit: assert the magic-byte rejection wording and
  upload a real decodable JPEG (a zero-buffer is now rejected at the boundary).
- ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=.
- recover-page / join: the PIN field auto-submits on the 4th digit, so don't
  race an explicit submit click against the ensuing navigation.
- gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and
  anchor the radio accessible-name match at the start (gated dialog semantics).

playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw
"Unknown permission: camera" on firefox/webkit and failed the test at context
creation); grant them per-Chromium-project. firefox-android drops the
unsupported isMobile flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:05 +02:00
fabi
792a4f0e4b fix(frontend): robust Escape handling + deep-link back navigation
focus-trap: focus the trapped container synchronously on mount instead of
only after a deferred rAF. The keydown listener is node-scoped, so an Escape
pressed before the rAF moved focus into the sheet landed on an element outside
the node and was silently dropped — a real keyboard-a11y gap (open a sheet,
immediately press Escape → nothing happened). The rAF still refines focus to
the first control once laid out.

recover: replace the `window.history.length > 1` back-chevron heuristic with
SvelteKit's afterNavigate `from` signal. history.length is 2 on a fresh-tab
deep link (about:blank + page), so history.back() landed on the blank entry.
`from` is null only on a full-page load, so deep-linked users now correctly
fall back to /join (or /feed when authed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:05 +02:00
fabi
b32231d4f4 fix(a11y): gate always-mounted sheet dialog semantics on open state
ContextSheet and UploadSheet stay mounted (slid off-screen via translate-y) when
closed, but declared role="dialog" + aria-modal="true" — and kept their buttons
in the a11y tree + tab order — unconditionally. So a screen reader always saw
2+ simultaneous modal dialogs, and their controls (e.g. each sheet's "Abbrechen")
collided with real open dialogs.

Surfaced by the e2e a11y suite: focus-trap asserted exactly one [role=dialog]
[aria-modal] and got 2; upload-cancel-confirm's getByRole('button',{name:
'Abbrechen'}) matched the composer's X *and* a closed sheet's button.

Fix: when closed, drop role/aria-modal and mark the subtree aria-hidden + inert
so it's out of the a11y tree and tab order entirely. Open sheets are unchanged.

Verified: chromium-mobile a11y/gesture suite now 21 passed / 5 skipped / 0 a11y
failures (focus-trap + upload-cancel-confirm green); svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:23:05 +02:00
fabi
1c7495e568 Merge fix/security-audit-port: image-decode DoS cap + audit backlog/media-auth docs
Ports the parts of the 2026-06-27 security audit that were not already absorbed
into main:
- compression.rs: image::Limits decode cap (decompression-bomb DoS guard)
- docs/SECURITY-BACKLOG.md: triaged backlog reconciled against main
- docs/DECISION-media-auth.md: unauthenticated-UUID vs signed-gateway writeup

Verified: cargo build clean.
2026-06-30 20:27:59 +02:00
fabi
d9025f783a fix(security): port image-decode DoS cap + audit backlog/media-auth docs
From the 2026-06-27 audit branch (fix/audit-2026-06-27-critical-medium), whose
work was ~80% absorbed into main via the batch branches. This ports the pieces
that were NOT in main:

- compression.rs: cap image decode with image::Limits (12000x12000, 256 MiB
  max_alloc) via ImageReader instead of image::open(). The upload body-size cap
  bounds the file on disk but not the *decoded* dimensions, so a small
  decompression-bomb image could OOM the box during decode/resize. Surgical port
  of the audit's decode guard only (not its image/video semaphore split).

- docs/SECURITY-BACKLOG.md: the audit's triaged backlog, reconciled against main
  (each item tagged done / open / contingent-on-signed-media). Records the still-
  open items: host moderation UI gap, owner-delete SSE broadcast, quota
  mount-detection (starts_with) + low-disk guard.

- docs/DECISION-media-auth.md: writes up the one real architectural divergence —
  main serves media unauthenticated (ServeDir + UUID-capability) while the audit
  built a signed/TTL'd gateway. Lays out the tradeoff (leaked URL = permanent vs
  ~24h access; banned-user access) and a recommendation, for a human decision.

Verified: cargo build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:26:57 +02:00
fabi
a490642f5f Merge fix/security-review-batch-2: cross-event authz + upload OOM backstop
Brings the batch-2 security hardening into main (forked from the same base as
the UX batch; auto-merged cleanly — verified both sides' edits to social.rs /
main.rs coexist):

- Cross-event authorization: toggle_like / list_comments / add_comment now
  event-scope the upload via Upload::find_by_id_and_event (404 on cross-event
  access); delete_comment uses Comment::soft_delete_in_event. Closes the gap
  where a guest could like/comment/list across events by upload UUID.
- Upload OOM backstop: the /upload route gets DefaultBodyLimit::max(576 MiB)
  instead of disable(), so a multi-GB body can't be buffered before the
  handler's per-class size checks run.
- upload.rs per-class size-limit refactor, XSS allowlist, deploy hardening
  (Caddyfile, Dockerfiles, docker-compose, .env.example), and a data-mode
  doc-comment clarifying the original-media route is capability-(UUID-)gated.

The event-scope checks sit before, and batch-3's best-effort count broadcasts
after, the like/comment mutations — both preserved.

Verified: cargo build clean, svelte-check 0 errors.
2026-06-30 20:02:47 +02:00
fabi
8f5afb9df7 Merge feat/ux-review-batch-3: feed virtualization + UX review batch fixes
Squash of the branch's two commits:
- feat(eventsnap): UX review batch-3 — feed DOM-windowing virtualization,
  global safe-area/PWA/lang fixes, in-place SSE feed patching (+ backend count
  broadcast), ConfirmSheet on destructive host/admin actions, streamed export
  download, shared IconButton + scrollLock primitives, and a11y/UX polish.
- fix(feed): width-change measurement invalidation + best-effort SSE counts.

Verified: svelte-check 0 errors, frontend build clean, cargo build clean.
2026-06-30 19:51:11 +02:00
fabi
0d7938aff5 fix(feed): width-change measurement invalidation + best-effort SSE counts
Post-commit review follow-ups for the batch-3 feed work.

Frontend — VirtualFeed: the guarded setOptions keyed only on (count, scrollMargin),
so a rotation (or the first-paint 0->real container width) changed colWidth / card
heights without invalidating the cached measurements, leaving getTotalSize and the
scrollbar stale until each row scrolled back through the window. Track appliedWidth
and call virtualizer.measure() on a width delta so heights re-estimate at the new
width (rendered rows re-measure immediately via their ResizeObserver). Covers list +
grid and the first-paint 120px-estimate case.

Backend — social.rs: the fresh like/comment count SELECT used `.await?`, so a
transient DB failure would 500 the request after the like/comment had already
committed. Make the count + broadcast best-effort (if let Ok { ... }); the mutation
succeeds regardless and the next event / pull-to-refresh reconciles the count.

Docs — FOLLOWUPS: record the review findings left as-is (grid-prepend tile reflow,
filtered-grid auto-load which is pre-existing, comment-delete stale count, and the
last-write-wins count ordering note).

Verified: svelte-check 0 errors, cargo build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:32:32 +02:00
fabi
e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00
fabi
1bb58b59ad fix(security): re-review follow-ups — drop HEIC/HEIF, fix stale comment
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>
2026-06-29 19:48:46 +02:00
fabi
edcef0258c fix(security): batch-2 hardening — XSS allowlist, event-scoping, deploy
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>
2026-06-29 19:39:29 +02:00
144 changed files with 7492 additions and 1242 deletions

View File

@@ -4,11 +4,17 @@ DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# Set to `production` in real deployments. This activates the secret guard that
# refuses to boot with placeholder JWT_SECRET / ADMIN_PASSWORD_HASH values.
# (docker-compose.yml already sets APP_ENV=production for the app service.)
APP_ENV=production
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
# Set a strong password and keep it in sync between DATABASE_URL and
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
@@ -26,6 +32,9 @@ EVENT_SLUG=max-maria-2026
# ── Storage ───────────────────────────────────────────────────────────────────
MEDIA_PATH=/media
# Export archives (Gallery.zip / Memories.zip). MUST be outside MEDIA_PATH —
# /media is publicly served, so exports here would be downloadable without auth.
EXPORT_PATH=/exports
# ── Upload limits ─────────────────────────────────────────────────────────────
DEFAULT_MAX_IMAGE_SIZE_MB=20

View File

@@ -1,45 +0,0 @@
# ── Domain ────────────────────────────────────────────────────────────────────
# Public domain Caddy will serve and obtain a TLS certificate for.
DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 64
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
SESSION_EXPIRY_DAYS=30
# Admin dashboard password (bcrypt hash).
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
# ── Event ─────────────────────────────────────────────────────────────────────
EVENT_NAME=Max & Maria's Wedding
EVENT_SLUG=max-maria-2026
# ── Storage ───────────────────────────────────────────────────────────────────
MEDIA_PATH=/media
# ── Upload limits ─────────────────────────────────────────────────────────────
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
# ── Rate limiting ─────────────────────────────────────────────────────────────
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
# ── Capacity ──────────────────────────────────────────────────────────────────
DEFAULT_ESTIMATED_GUEST_COUNT=100
# Fraction of total storage that triggers the "low storage" warning (0.01.0)
DEFAULT_QUOTA_TOLERANCE=0.75
# ── Workers ───────────────────────────────────────────────────────────────────
COMPRESSION_WORKER_CONCURRENCY=2

5
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# Environment secrets — never commit the real .env
.env
# Stale local scratch copy of .env.example; nothing in the test stack reads it.
.env.test
# Rust
backend/target/
@@ -20,6 +22,9 @@ e2e/playwright-report/
e2e/test-results/
e2e/.cache/
e2e/.env.test
# Playwright artifacts when run from the repo root instead of e2e/
/test-results/
/playwright-report/
# OS
.DS_Store

View File

@@ -1,26 +1,42 @@
{$DOMAIN} {
encode zstd gzip
# Compress everything EXCEPT the SSE stream — gzip buffering delays
# "real-time" likes/comments until the ~30s keep-alive tick.
@compressible not path /api/v1/stream
encode @compressible zstd gzip
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# Site-wide security headers (defense-in-depth). HSTS is free since Caddy
# already terminates TLS. nosniff also covers all of /media/*.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Media previews and thumbnails
@previews path /media/previews/* /media/thumbnails/*
header @previews Cache-Control "public, max-age=3600"
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# Original media files (private — only host can download)
@originals path /media/originals/*
header @originals Cache-Control "private, max-age=86400"
# Preview/thumbnail images. These are now served by the app through a
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation
# can revoke access; direct /media/previews|thumbnails is 404-blocked at the app.
# Privately cacheable for a short window (the app sets the same header; this is the
# edge carve-out from the blanket no-store below). Kept short so a moderated image
# stops being served to a direct-URL holder promptly.
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
header @media_api Cache-Control "private, max-age=300"
# API — never cache
@api path /api/*
header @api Cache-Control "no-store"
# API — never cache, EXCEPT the gated image routes above.
@api {
path /api/*
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
}
header @api Cache-Control "no-store"
# Route API and media requests to the Rust backend
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000
# Route API and media requests to the Rust backend
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000
# Everything else goes to SvelteKit frontend
reverse_proxy frontend:3001
# Everything else goes to SvelteKit frontend
reverse_proxy frontend:3001
}

View File

@@ -46,6 +46,90 @@ is the smallest patch.
- [frontend/src/lib/components/Toaster.svelte](frontend/src/lib/components/Toaster.svelte) — add passthrough marker (if approach 2) or move to a portal (if approach 1)
- [frontend/src/app.html](frontend/src/app.html) — add `<div id="modal-root">` (if approach 1)
## Feed — DOM-windowing virtualization (IMPLEMENTED — residual validation owed)
**Status.** Implemented in [frontend/src/lib/components/VirtualFeed.svelte](frontend/src/lib/components/VirtualFeed.svelte)
using `@tanstack/svelte-virtual`'s `createWindowVirtualizer`. Both the **list**
view (dynamic `measureElement` heights, keyed by upload id with `anchorTo:'start'`
so an SSE prepend doesn't yank a scrolled-down reader) and the **grid** view
(three measured square tiles per row) now keep only the on-screen window (+overscan)
in the DOM instead of one node per upload. The window virtualizer scrolls the
document, so the sticky header, pull-to-refresh, the infinite-scroll sentinel and
the bottom nav are untouched. The earlier `content-visibility` band-aid was removed
from `FeedListCard` (it interferes with real-height measurement), and the old
`FeedGrid.svelte` was deleted (its sole consumer migrated to `VirtualFeed`).
**Verified.** `svelte-check` 0 errors, production build clean. The integration
follows the library's documented window-virtualizer contract (confirmed against
`virtual-core` source: item `start` includes `scrollMargin`, `getTotalSize()`
excludes it; the SSR path is guarded by `getScrollElement()` returning null).
**Residual validation owed (needs the running app — could not be done headless).**
- Manual scroll testing on a ~1000-item event: confirm no jank, correct scrollbar
size, and that an SSE `new-upload` / `feed-delta` prepend while scrolled down does
not jump the viewport (the `anchorTo:'start'` + id-key path).
- `scrollMargin` re-measure when grid filter chips change the header height (handled
reactively via the `uploads`-length-driven effect, but unverified visually).
- A new e2e spec that scrolls far down, likes an item via SSE, and asserts scroll
position is retained — the existing suite only asserts a single card is visible,
so it cannot catch a scroll regression.
**Files.**
- [frontend/src/lib/components/VirtualFeed.svelte](frontend/src/lib/components/VirtualFeed.svelte) — new windowing component (list + grid)
- [frontend/src/routes/feed/+page.svelte](frontend/src/routes/feed/+page.svelte) — renders `VirtualFeed` for both views
- [frontend/src/lib/components/FeedListCard.svelte](frontend/src/lib/components/FeedListCard.svelte) — `content-visibility` removed
**Known limitations (surfaced in the post-commit review, left as-is — low impact).**
- **Grid prepend reflows tiles.** Grid rows are index-keyed and pack 3 tiles each, so
an SSE `new-upload` shifts every tile by one position; `anchorTo:'start'` can only
anchor a scrolled grid reader when the prepend crosses a 3-item boundary (it adds a
*row*). List view is unaffected (one id-keyed row per upload). A fix would key rows
by the first tile's id and accept partial-row churn; not worth it for the rarer
"new upload while browsing the grid" case.
- **Filtered grid can auto-load the whole feed.** When a grid filter matches few items
the `VirtualFeed` is short, so the infinite-scroll sentinel sits in-viewport and
`loadMore()` fires until `nextCursor` is null — pulling all pages to widen the
client-side search. This is pre-existing (the old `FeedGrid` had the same shape, and
the empty-filter copy even says "scrolle weiter"), not a virtualization regression.
If undesired, gate auto-load to list view or to actual user scroll.
## Feed — comment deletion leaves a stale live count
**Problem.** `add_comment` now broadcasts a fresh `comment_count` so feed clients patch
the card in place, but `delete_comment` and `host_delete_comment`
([backend/src/handlers/social.rs](backend/src/handlers/social.rs),
[host.rs](backend/src/handlers/host.rs)) soft-delete without broadcasting any
count/event. So a deletion leaves the count too high on every client until a full
refetch (pull-to-refresh or an unrelated `upload-processed` merge). `toggle_like`
already broadcasts on both add and remove, so likes are fine — the gap is
comments-on-delete. Pre-existing, but the in-place-patch scheme makes it observable.
**Fix.** Emit a `new-comment` (or a `comment-deleted`) event carrying the refreshed
`comment_count` from both delete paths, the same best-effort way `add_comment` does;
the frontend `patchCount(..., 'comment_count')` handler already consumes it.
**Note — count ordering.** The broadcast count is read just after the (auto-committed)
mutation, not inside it, so under concurrent likes/comments on one upload the SSE
messages are last-write-wins. The frontend *replaces* (not increments) the count, so
steady state is correct and self-healing; only document this if strict per-event
ordering is ever required (then compute the count in-tx with a monotonic sequence).
## Feed — per-image exact CLS reservation
**Problem.** `FeedListCard` now reserves a default `aspect-[4/5]` box for photos so
the card doesn't collapse to height 0 and reflow as images stream in (matching
`Skeleton`). But no image dimensions are stored anywhere (not on `FeedUpload`, the
`upload` table, or any migration), so the box is a uniform guess that crops to fit —
the original is one tap away in the lightbox.
**Acceptance criterion.** Extract image width/height during the compression worker,
store them on `upload`, expose them on `FeedUpload`, and have the card reserve the
*true* aspect ratio (no crop, zero shift).
**Files to touch.**
- backend: `services/compression.rs`, `models/upload.rs`, `handlers/feed.rs`, a migration
- [frontend/src/lib/types.ts](frontend/src/lib/types.ts), [FeedListCard.svelte](frontend/src/lib/components/FeedListCard.svelte)
## Smaller nits, optional
- **Auto-submit on retried 4th digit.** [recover/+page.svelte](frontend/src/routes/recover/+page.svelte), [join/+page.svelte](frontend/src/routes/join/+page.svelte) — after a wrong PIN, deleting one digit and retyping triggers an immediate submit. Backend's 3-attempts/15-min lockout makes this safe; could feel hair-trigger after a typo. Consider gating the second auto-submit per input session behind an explicit button press.

View File

@@ -77,6 +77,7 @@ eventsnap/
│ ├── svelte.config.js
│ └── Dockerfile
├── docker-compose.yml
├── docker-compose.dev.yml # opt-in dev overlay (publishes Postgres on the host)
├── Caddyfile
└── .env.example
```
@@ -93,9 +94,9 @@ eventsnap/
### Deploy on a fresh VPS
```bash
# 1. Clone the repository
git clone https://git.mc02.dev/fabi/EventSnap.git
cd EventSnap
# 1. Clone the repository (into a lowercase dir, matching the paths used below)
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
cd eventsnap
# 2. Configure environment
cp .env.example .env
@@ -107,6 +108,13 @@ docker compose up -d
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while `JWT_SECRET`/`ADMIN_PASSWORD_HASH` still hold the `.env.example` placeholders (this is deliberate — a publicly-known signing key is worse than downtime). Caddy then waits on the unhealthy `app` container and never serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line means you skipped step 2. Rotate the secrets (see below) and restart.
> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:
> ```bash
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
> ```
### Generate required secrets
```bash

View File

@@ -20,8 +20,18 @@ FROM alpine:3.21
RUN apk add --no-cache ca-certificates ffmpeg
# Run as a non-root user. Pre-create and chown the media + export mount paths so
# the fresh named volumes inherit the non-root ownership (Docker seeds an empty
# named volume from the image directory, preserving its uid/gid) and uploads +
# export archives can be written. Exports live OUTSIDE /media on purpose so the
# public media ServeDir can't reach them.
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/target/release/eventsnap-backend ./
RUN mkdir -p /media /exports && chown -R app:app /app /media /exports
USER app
EXPOSE 3000
CMD ["./eventsnap-backend"]

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_comment_hashtag_hashtag;
DROP INDEX IF EXISTS idx_comment_user;
DROP INDEX IF EXISTS idx_upload_event_created_id;

View File

@@ -0,0 +1,17 @@
-- Composite feed index with an id tiebreaker so keyset pagination
-- (ORDER BY created_at DESC, id DESC) stays index-covered and stable when
-- multiple uploads share a created_at timestamp.
CREATE INDEX idx_upload_event_created_id
ON upload(event_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
-- A user's own comments (moderation, "who commented"). The sibling
-- idx_upload_user already exists for uploads; comment(user_id) was missing.
CREATE INDEX idx_comment_user
ON comment(user_id)
WHERE deleted_at IS NULL;
-- Hashtag filtering over comments — mirrors idx_upload_hashtag_hashtag, which
-- only covered upload_hashtag.
CREATE INDEX idx_comment_hashtag_hashtag
ON comment_hashtag(hashtag_id);

View File

@@ -0,0 +1,25 @@
DROP TABLE IF EXISTS pin_reset_request;
-- Restore v_feed without the is_banned filter.
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.mime_type,
u.caption,
u.created_at,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;

View File

@@ -0,0 +1,39 @@
-- Ban now ALWAYS hides: exclude banned uploaders from the feed view. Previously ban and
-- hide were decoupled (a host could ban without hiding), leaving a banned user's photos on
-- the feed and baked into the export. `find_visible_media` and the export query get the
-- same `is_banned = FALSE` filter in code.
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.mime_type,
u.caption,
u.created_at,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
-- pin_reset_request: a guest who forgot their PIN (localStorage was the only copy) can ask
-- a host to reset it in-app, instead of being permanently orphaned. One pending request per
-- user; cleared when the host resets the PIN or dismisses it, and cascades if the user/event
-- is removed.
CREATE TABLE pin_reset_request (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id)
);

View File

@@ -37,8 +37,8 @@ pub async fn join(
Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
if rate_limits_on && join_rate_on
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
{
@@ -83,7 +83,19 @@ pub async fn join(
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
// 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 {
Ok(u) => u,
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
Err(e) => return Err(e.into()),
};
let token = jwt::create_token(
user.id,
@@ -121,6 +133,18 @@ pub struct RecoverResponse {
pub user_id: Uuid,
}
/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used
/// to run a constant-time-ish verify on the "unknown display name" branch of
/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't
/// be told apart by timing.
fn dummy_pin_hash() -> &'static str {
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
HASH.get_or_init(|| {
bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12)
.expect("bcrypt hashing of a static dummy value cannot fail")
})
}
pub async fn recover(
State(state): State<AppState>,
headers: HeaderMap,
@@ -134,8 +158,8 @@ pub async fn recover(
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
// softens that into a real cost.
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let recover_rate_on = config::get_bool(&state.pool, "recover_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
if rate_limits_on && recover_rate_on {
let name_key = display_name.to_lowercase();
if !state.rate_limiter.check(
@@ -158,9 +182,13 @@ pub async fn recover(
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
if users.is_empty() {
return Err(AppError::NotFound(
"Kein Benutzer mit diesem Namen gefunden.".into(),
));
// No user with this name. Run a throwaway bcrypt verify so this branch takes
// the same time as the user-exists path, and return the SAME error as a wrong
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
// timing. Display names are already public on the feed, but this still closes
// the /recover enumeration + timing oracle.
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
}
for user in &users {
@@ -238,6 +266,10 @@ pub struct AdminLoginRequest {
#[derive(Serialize)]
pub struct AdminLoginResponse {
pub jwt: String,
/// The admin's user id + display name, so the client can populate a real identity
/// (own-post affordances, a name on the Account page) instead of a blank session.
pub user_id: Uuid,
pub display_name: String,
}
pub async fn admin_login(
@@ -256,8 +288,8 @@ pub async fn admin_login(
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
// honest typos.
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let admin_rate_on = config::get_bool(&state.pool, "admin_login_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
if rate_limits_on && admin_rate_on
&& !state.rate_limiter.check(
format!("admin_login:{ip}"),
@@ -325,7 +357,11 @@ pub async fn admin_login(
let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse { jwt: token }))
Ok(Json(AdminLoginResponse {
jwt: token,
user_id: admin_user.id,
display_name: admin_user.display_name,
}))
}
pub async fn logout(
@@ -335,3 +371,77 @@ pub async fn logout(
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
Ok(StatusCode::NO_CONTENT)
}
/// "Sign out everywhere" — revoke every session for the caller, not just the current one.
/// A single leaked/kept-alive device (a shared phone, a laptop recovered via PIN) can then
/// be cut from any of the user's devices.
pub async fn logout_all(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<StatusCode, AppError> {
Session::delete_all_for_user(&state.pool, auth.user_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct PinResetRequestBody {
pub display_name: String,
}
/// A guest who forgot their PIN asks a host to reset it in-app. Unauthenticated (they
/// can't log in without the PIN) and rate-limited. Always returns 204 regardless of
/// whether the name exists, so it can't enumerate display names beyond what the public
/// feed already exposes.
pub async fn request_pin_reset(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PinResetRequestBody>,
) -> Result<StatusCode, AppError> {
let display_name = body.display_name.trim();
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
if rate_limits_on {
let name_key = display_name.to_lowercase();
if !state.rate_limiter.check(
format!("pin_reset_req:{ip}:{name_key}"),
3,
Duration::from_secs(15 * 60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
if display_name.is_empty() {
return Ok(StatusCode::NO_CONTENT);
}
// Single statement so the existing-name and unknown-name paths do IDENTICAL work
// (same event+user index scans, an INSERT that matches 0 rows for an unknown name) —
// no timing oracle despite the always-204 contract. Admins recover via password, so
// they're excluded from the join and never get a reset request queued.
let _ = sqlx::query(
"INSERT INTO pin_reset_request (event_id, user_id)
SELECT e.id, u.id
FROM event e
JOIN \"user\" u
ON u.event_id = e.id
AND lower(u.display_name) = lower($2)
AND u.role <> 'admin'
WHERE e.slug = $1
ON CONFLICT (user_id) DO NOTHING",
)
.bind(&state.config.event_slug)
.bind(display_name)
.execute(&state.pool)
.await;
// Broadcast unconditionally (carries no per-name info) so an online host's request
// badge updates without revealing whether the name existed.
let _ = state
.sse_tx
.send(crate::state::SseEvent::new("pin-reset-requested", "{}"));
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -46,10 +46,20 @@ pub fn create_token(
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
// We deliberately do NOT enforce the JWT's own `exp`. The authoritative session
// lifetime lives in the `session` row (`expires_at > NOW()`), which SLIDES forward on
// every authenticated request (see `Session::touch_and_renew`). Enforcing the JWT's
// fixed +Nd `exp` here would hard-log-out an actively-used client on day N+1 even
// though its session was renewed — the "30-day cliff" from the review. With server-
// side sliding sessions, the token is a signature-checked bearer credential and its
// lifetime is revocable (logout / expiry / ban all delete the row), which is strictly
// stronger than a stateless non-revocable exp.
let mut validation = Validation::default();
validation.validate_exp = false;
let data = jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
&validation,
)?;
Ok(data.claims)
}

View File

@@ -1,4 +1,4 @@
use axum::extract::{FromRequestParts, State};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use uuid::Uuid;
@@ -13,6 +13,10 @@ pub struct AuthUser {
pub user_id: Uuid,
pub event_id: Uuid,
pub role: UserRole,
/// Live ban flag. Banned users keep *read* access (per USER_JOURNEYS §10), so
/// the base extractor does NOT reject them — write handlers and the
/// Require{Host,Admin} extractors enforce the ban instead.
pub is_banned: bool,
pub token_hash: String,
}
@@ -33,31 +37,49 @@ impl FromRequestParts<AppState> for AuthUser {
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
let claims = jwt::verify_token(token, &state.config.jwt_secret)
// Verify the JWT's signature. Expiry is deliberately NOT enforced here (see
// `jwt::verify_token`) — the authoritative, sliding session lifetime lives in the
// `session` row read below. We also don't trust the token's role/ban claims; the
// live user row is authoritative, so the decoded claims aren't needed beyond this.
jwt::verify_token(token, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
let token_hash = jwt::hash_token(token);
let session = Session::find_by_token_hash(&state.pool, &token_hash)
// Single round-trip: resolve the session token to its *live* user row. A
// role/ban stored in the token would survive a demote/ban for the full session
// lifetime (up to 30d), so we always re-read the user (a demoted host loses host
// powers immediately). We do NOT reject banned users here — they retain read
// access by design; writes and host/admin actions enforce the ban downstream.
let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Update last_seen_at in the background (fire-and-forget). Failures are
// non-fatal but worth surfacing — silent swallowing hides DB connection
// pressure that would otherwise be the first symptom of a real problem.
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
// an active client's session renews instead of hitting the fixed 30-day cliff.
// Admin sessions keep their tighter 1-day window (they renew on activity but still
// lapse a day after the admin goes idle). Failures are non-fatal but worth
// surfacing — silent swallowing hides DB connection pressure that would otherwise
// be the first symptom of a real problem.
let pool = state.pool.clone();
let session_id = session.id;
let touch_hash = token_hash.clone();
let expiry_days = if user.role == UserRole::Admin {
1
} else {
state.config.session_expiry_days
};
tokio::spawn(async move {
if let Err(e) = Session::touch(&pool, session_id).await {
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await {
tracing::warn!(error = ?e, "session touch/renew failed");
}
});
Ok(Self {
user_id: claims.sub,
event_id: claims.event_id,
role: claims.role,
user_id: user.id,
event_id: user.event_id,
role: user.role,
is_banned: user.is_banned,
token_hash,
})
}
@@ -74,6 +96,9 @@ impl FromRequestParts<AppState> for RequireHost {
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
match auth.role {
UserRole::Host | UserRole::Admin => Ok(Self(auth)),
_ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())),
@@ -92,6 +117,9 @@ impl FromRequestParts<AppState> for RequireAdmin {
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
match auth.role {
UserRole::Admin => Ok(Self(auth)),
_ => Err(AppError::Forbidden("Nur für Admins.".into())),

View File

@@ -6,6 +6,47 @@ use anyhow::{anyhow, Context, Result};
/// we refuse to start with this value; otherwise we warn loudly.
const DEV_JWT_SECRET_SENTINEL: &str = "dev_secret_do_not_use_in_production_32byteslong_aaaa";
/// A secret is "placeholder-ish" if it's the shipped dev sentinel or still carries
/// the tell-tale scaffolding substrings from `.env.example`. Length alone is not
/// enough — the shipped `change_me_...` placeholder is >32 chars.
fn looks_placeholder(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
s == DEV_JWT_SECRET_SENTINEL
|| lower.contains("change_me")
|| lower.contains("dev_secret")
|| lower.contains("placeholder")
}
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
/// with a publicly-known signing key is worse than one that refuses to start.
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
fn validate_secrets(is_prod: bool, jwt_secret: &str, admin_password_hash: &str) -> Result<()> {
if is_prod {
if looks_placeholder(jwt_secret) {
return Err(anyhow!(
"Refusing to start in production with a placeholder JWT_SECRET — \
rotate it (openssl rand -hex 64)."
));
}
if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
return Err(anyhow!(
"Refusing to start in production without a real ADMIN_PASSWORD_HASH — \
generate one (htpasswd -bnBC 12 '' <password> | tr -d ':\\n')."
));
}
} else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
tracing::warn!(
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
);
} else if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct AppConfig {
pub database_url: String,
@@ -15,7 +56,13 @@ pub struct AppConfig {
pub event_name: String,
pub event_slug: String,
pub media_path: PathBuf,
/// Where export archives are written. MUST be outside `media_path` — that
/// directory is served by a public `ServeDir`, so a predictable archive name
/// under it would leak the whole gallery to anonymous visitors.
pub export_path: PathBuf,
pub app_port: u16,
/// Number of concurrent media compression workers (read once at boot).
pub compression_concurrency: usize,
}
impl AppConfig {
@@ -25,19 +72,9 @@ impl AppConfig {
let is_prod = app_env.eq_ignore_ascii_case("production");
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
if is_prod {
return Err(anyhow!(
"Refusing to start in production with the well-known dev JWT_SECRET — \
rotate it (openssl rand -hex 64)."
));
}
tracing::warn!(
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
);
} else if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
Ok(Self {
database_url: std::env::var("DATABASE_URL")
@@ -47,8 +84,7 @@ impl AppConfig {
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("SESSION_EXPIRY_DAYS must be a number")?,
admin_password_hash: std::env::var("ADMIN_PASSWORD_HASH")
.unwrap_or_default(),
admin_password_hash,
event_name: std::env::var("EVENT_NAME")
.unwrap_or_else(|_| "EventSnap".to_string()),
event_slug: std::env::var("EVENT_SLUG")
@@ -56,10 +92,80 @@ impl AppConfig {
media_path: PathBuf::from(
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
),
export_path: PathBuf::from(
std::env::var("EXPORT_PATH").unwrap_or_else(|_| "/exports".to_string()),
),
app_port: std::env::var("APP_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("APP_PORT must be a number")?,
compression_concurrency: std::env::var("COMPRESSION_WORKER_CONCURRENCY")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| n >= 1)
.unwrap_or(2),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
#[test]
fn prod_rejects_shipped_placeholder_secret() {
// The exact string shipped in `.env` — >32 chars, so it must be caught by
// the substring guard, not the length check.
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
assert!(err.is_err(), "placeholder JWT_SECRET must be rejected in prod");
}
#[test]
fn prod_rejects_dev_sentinel_and_short_secret() {
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH).is_err());
assert!(validate_secrets(true, "tooshort", REAL_HASH).is_err());
}
#[test]
fn prod_rejects_missing_or_placeholder_admin_hash() {
assert!(validate_secrets(true, REAL_SECRET, "").is_err());
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$placeholder_replace_me").is_err());
}
#[test]
fn prod_accepts_real_secrets() {
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH).is_ok());
}
#[test]
fn non_prod_tolerates_dev_sentinel() {
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "").is_ok());
}
#[test]
fn non_prod_still_rejects_short_non_sentinel_secret() {
assert!(validate_secrets(false, "tooshort", "").is_err());
}
#[test]
fn placeholder_detection_is_case_insensitive() {
// looks_placeholder lowercases before matching — an upper/mixed-case
// placeholder must still be rejected in prod.
assert!(validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err());
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
}
#[test]
fn prod_len_boundary_at_32() {
// Exactly 32 non-placeholder chars is the minimum accepted; 31 is rejected.
const LEN_32: &str = "abcdefghijklmnopqrstuvwxyz012345";
const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
assert_eq!(LEN_32.len(), 32);
assert_eq!(LEN_31.len(), 31);
assert!(validate_secrets(true, LEN_32, REAL_HASH).is_ok());
assert!(validate_secrets(true, LEN_31, REAL_HASH).is_err());
}
}

View File

@@ -10,6 +10,10 @@ pub enum AppError {
Conflict(String),
/// Second field: optional retry-after seconds to include in the response.
TooManyRequests(String, Option<u64>),
/// Per-user storage quota exhausted. Distinct from `TooManyRequests` (rate limit) so
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
/// retrying a permanently-failing upload forever.
QuotaExceeded(String),
Internal(anyhow::Error),
}
@@ -22,6 +26,7 @@ impl AppError {
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
}
}
@@ -34,6 +39,7 @@ impl AppError {
| Self::NotFound(msg)
| Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
Self::QuotaExceeded(msg) => msg.clone(),
Self::Internal(err) => {
tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string()

View File

@@ -1,11 +1,10 @@
use std::collections::HashMap;
use std::time::Duration;
use axum::extract::State;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use sysinfo::System;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
@@ -68,23 +67,12 @@ pub async fn get_stats(
.fetch_one(&state.pool)
.await?;
// Disk usage via sysinfo
let mut sys = System::new();
sys.refresh_all();
let media_path = state.config.media_path.to_string_lossy().to_string();
let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or_else(|| {
// Fall back to the root disk
sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| d.mount_point().to_string_lossy() == "/")
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or((0, 0))
});
// Disk usage from the shared cache (unknown mount → zeros, same as before).
let (disk_total, disk_free) = state
.disk_cache
.snapshot(&state.config.media_path)
.map(|d| (d.total, d.free))
.unwrap_or((0, 0));
let disk_used = disk_total.saturating_sub(disk_free);
@@ -121,15 +109,18 @@ pub async fn patch_config(
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
// privacy note is free text. Splitting these explicitly is verbose but makes the
// failure mode for typos obvious (`Unbekannter Schlüssel: ...`).
const NUMERIC_KEYS: &[&str] = &[
"max_image_size_mb",
"max_video_size_mb",
"upload_rate_per_hour",
"feed_rate_per_min",
"export_rate_per_day",
"quota_tolerance",
"estimated_guest_count",
"compression_concurrency",
// (key, integer_only, min, max). Ranges reject values that `parse::<f64>` would
// accept but that silently revert to the hardcoded default at read time
// (get_usize/get_i64 can't parse negatives/NaN/fractionals). `compression_concurrency`
// is intentionally absent — it's read once at boot, so a live edit was a no-op.
const NUMERIC_SPECS: &[(&str, bool, f64, f64)] = &[
("max_image_size_mb", true, 1.0, 1024.0),
("max_video_size_mb", true, 1.0, 10240.0),
("upload_rate_per_hour", true, 1.0, 100_000.0),
("feed_rate_per_min", true, 1.0, 100_000.0),
("export_rate_per_day", true, 1.0, 100_000.0),
("quota_tolerance", false, 0.0, 1.0),
("estimated_guest_count", true, 1.0, 1_000_000.0),
];
const BOOL_KEYS: &[&str] = &[
"rate_limits_enabled",
@@ -146,12 +137,30 @@ pub async fn patch_config(
let mut privacy_note_changed = false;
// Validate every key first so a bad value in the batch can't leave a partial
// update behind — validation must fully precede any write.
for (key, value) in &body {
let key_str = key.as_str();
if NUMERIC_KEYS.contains(&key_str) {
if value.parse::<f64>().is_err() {
if let Some(&(_, integer_only, min, max)) =
NUMERIC_SPECS.iter().find(|(k, ..)| *k == key_str)
{
let n = value.trim().parse::<f64>().ok().filter(|n| n.is_finite());
let n = match n {
Some(n) => n,
None => {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein."
)))
}
};
if integer_only && n.fract() != 0.0 {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein."
"Ungültiger Wert für {key}: muss eine ganze Zahl sein."
)));
}
if n < min || n > max {
return Err(AppError::BadRequest(format!(
"Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}{max})."
)));
}
} else if BOOL_KEYS.contains(&key_str) {
@@ -164,7 +173,9 @@ pub async fn patch_config(
}
}
} else if TEXT_KEYS.contains(&key_str) {
if value.len() > PRIVACY_NOTE_MAX_LEN {
// Count characters, not bytes — the message says "Zeichen" and a
// multi-byte grapheme shouldn't count against the limit multiple times.
if value.chars().count() > PRIVACY_NOTE_MAX_LEN {
return Err(AppError::BadRequest(format!(
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
)));
@@ -177,16 +188,26 @@ pub async fn patch_config(
"Unbekannter Konfigurationsschlüssel: {key}"
)));
}
}
// Apply all writes in one transaction — the batch is all-or-nothing.
let mut tx = state.pool.begin().await?;
for (key, value) in &body {
sqlx::query(
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
)
.bind(key)
.bind(value)
.execute(&state.pool)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// The config cache must reflect this write on the very next read (tests PATCH then
// immediately assert the new value takes effect). Invalidate synchronously here —
// the TTL is only a backstop and must not be relied on for correctness.
state.config_cache.invalidate();
// 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.
@@ -223,11 +244,48 @@ pub async fn get_export_jobs(
// ── Export download endpoints (authenticated guests) ─────────────────────────
#[derive(Deserialize)]
pub struct DownloadQuery {
pub ticket: String,
}
/// Mint a short-lived ticket for a browser-driven export download. The download
/// 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.
pub async fn export_ticket(
State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> {
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
// released — Spec design choice"). The export is read-only, so it stays available
// to them, consistent with the read-only-ban model.
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
}
/// Validate a download ticket (single-use) and confirm its session still exists.
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
let token_hash = state
.sse_tickets
.consume(ticket)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
Ok(())
}
pub async fn download_zip(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
Query(q): Query<DownloadQuery>,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -240,7 +298,7 @@ pub async fn download_zip(
));
}
let path = state.config.media_path.join("exports").join("Gallery.zip");
let path = state.config.export_path.join("Gallery.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
@@ -250,9 +308,10 @@ pub async fn download_zip(
pub async fn download_html(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
Query(q): Query<DownloadQuery>,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -265,7 +324,7 @@ pub async fn download_html(
));
}
let path = state.config.media_path.join("exports").join("Memories.zip");
let path = state.config.export_path.join("Memories.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
@@ -342,13 +401,13 @@ pub async fn export_status(
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
/// each request.
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
if !(rate_limits_on && export_rate_on) {
return Ok(());
}
let ip = client_ip(headers, "unknown");
let limit = config::get_usize(&state.pool, "export_rate_per_day", 3).await;
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
if !state
.rate_limiter
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))

View File

@@ -62,10 +62,10 @@ pub async fn feed(
Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> {
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
if !state
.rate_limiter
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
@@ -79,6 +79,17 @@ pub async fn feed(
let limit = q.limit.unwrap_or(20).min(100);
// Resolve the cursor to a (created_at, id) position. The pair is compared as a
// tuple so ties on created_at break on id — keyset pagination on created_at
// alone would silently drop rows sharing a timestamp across a page boundary.
let (cursor_time, cursor_id) = match q.cursor {
Some(c) => match get_cursor_pos(&state.pool, c).await {
Some((t, id)) => (Some(t), Some(id)),
None => (None, None),
},
None => (None, None),
};
let rows = if let Some(hashtag) = &q.hashtag {
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, FeedRow>(
@@ -88,19 +99,14 @@ pub async fn feed(
JOIN upload_hashtag uh ON uh.upload_id = v.id
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
WHERE v.event_id = $2
AND ($3::timestamptz IS NULL OR v.created_at < $3)
ORDER BY v.created_at DESC
LIMIT $4",
AND ($3::timestamptz IS NULL OR (v.created_at, v.id) < ($3, $4))
ORDER BY v.created_at DESC, v.id DESC
LIMIT $5",
)
.bind(&tag)
.bind(auth.event_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(cursor_time)
.bind(cursor_id)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
@@ -110,18 +116,13 @@ pub async fn feed(
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1
AND ($2::timestamptz IS NULL OR created_at < $2)
ORDER BY created_at DESC
LIMIT $3",
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
ORDER BY created_at DESC, id DESC
LIMIT $4",
)
.bind(auth.event_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(cursor_time)
.bind(cursor_id)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
@@ -138,8 +139,17 @@ pub async fn feed(
let uploads = rows
.into_iter()
.map(|r| {
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
// Gated media aliases (visibility-checked, direct /media blocked). Emit the
// URL only when the variant actually exists — the URL is what signals the
// client which variant to load.
let preview_url = r
.preview_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/preview", r.id));
let thumbnail_url = r
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
@@ -171,6 +181,16 @@ pub struct DeltaQuery {
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
/// True when the upload query hit `DELTA_LIMIT`: the response carries only the
/// newest slice of the gap, so the client must fall back to a full feed refresh
/// rather than merging (the older missed uploads are absent and unrecoverable
/// via a later delta, which advances `since` past them).
pub truncated: bool,
/// The server's clock at the moment this delta was computed. The client advances its
/// reconnect cursor from THIS, never `new Date()` — a browser clock even seconds fast
/// would otherwise silently skip uploads whose server `created_at` falls in the skew
/// window (they'd never reappear without a hard refresh).
pub server_time: DateTime<Utc>,
}
pub async fn feed_delta(
@@ -178,18 +198,55 @@ pub async fn feed_delta(
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
// Rate-limit the delta the same way as the paginated feed. Without this, ~100 clients
// reconnecting at once (post-outage, or a flapping network) each fire an unbounded
// delta fetch — a reconnect stampede. Keyed per-user so one client can't starve others
// behind a shared NAT.
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
if !state.rate_limiter.check(
format!("feed_delta:{}", auth.user_id),
rate_limit,
Duration::from_secs(60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
// Anchor the next cursor to the DB clock, captured *before* the queries so an upload
// committed during this handler is re-fetched next time rather than skipped (a
// duplicate id merges idempotently on the client; a miss is unrecoverable).
let server_time: DateTime<Utc> = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
// Bounded like the paginated feed: a stale `since` could otherwise pull the
// entire event's uploads in one response. If a client hits the cap it should
// fall back to a full feed refresh rather than another delta.
const DELTA_LIMIT: i64 = 200;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1 AND created_at > $2
ORDER BY created_at DESC",
ORDER BY created_at DESC, id DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(q.since)
.bind(DELTA_LIMIT)
.fetch_all(&state.pool)
.await?;
// Hit the cap => this is only the newest slice of a larger gap. Signal the
// client to full-refresh instead of merging a partial delta.
let truncated = rows.len() as i64 >= DELTA_LIMIT;
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
@@ -209,8 +266,14 @@ pub async fn feed_delta(
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
preview_url: r
.preview_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/preview", r.id)),
thumbnail_url: r
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
@@ -222,6 +285,8 @@ pub async fn feed_delta(
Ok(Json(DeltaResponse {
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
truncated,
server_time,
}))
}
@@ -249,14 +314,17 @@ pub async fn hashtags(
))
}
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
let row: Option<(DateTime<Utc>,)> =
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
/// Resolve a cursor id to its `(created_at, id)` position. Both are needed:
/// `created_at` alone isn't unique, so pagination must break ties on `id` to
/// avoid silently dropping rows that share a timestamp across a page boundary.
async fn get_cursor_pos(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<(DateTime<Utc>, Uuid)> {
let row: Option<(DateTime<Utc>, Uuid)> =
sqlx::query_as("SELECT created_at, id FROM upload WHERE id = $1")
.bind(cursor_id)
.fetch_optional(pool)
.await
.ok()?;
row.map(|r| r.0)
row
}
async fn get_liked_set(

View File

@@ -9,6 +9,7 @@ use crate::auth::middleware::RequireHost;
use crate::error::AppError;
use crate::models::comment::Comment;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::upload::Upload;
use crate::models::user::UserRole;
use crate::state::{AppState, SseEvent};
@@ -35,11 +36,27 @@ pub struct EventStatus {
pub export_released: bool,
}
#[derive(Deserialize)]
pub struct BanRequest {
pub hide_uploads: 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.
async fn remaining_operators(
state: &AppState,
event_id: Uuid,
excluding: Uuid,
) -> Result<i64, AppError> {
let count = 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(event_id)
.bind(excluding)
.fetch_one(&state.pool)
.await?;
Ok(count)
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub role: String,
@@ -93,8 +110,8 @@ pub async fn ban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> {
// The ban request carries no body — ban always hides (no per-request options).
// Cannot ban yourself or another host/admin
if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
@@ -112,19 +129,46 @@ pub async fn ban_user(
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
}
// 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
// opt-out checkbox is gone — `hide_uploads` in the request is ignored.
//
// We deliberately do NOT revoke the banned user's sessions. This is a *read-only ban*
// by design (USER_JOURNEYS §10.3): the user keeps read access to the feed and can still
// download the released export — writes and host/admin actions are what the ban blocks
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
// contradict that model, break the documented "banned guest can still download the
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
sqlx::query(
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1",
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = TRUE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(body.hide_uploads)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// Evict their content live from every feed + the diashow so it disappears without
// each viewer having to reload. (Their own SSE stream is separately dropped by the
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
// retaining plain read access.)
let _ = state.sse_tx.send(SseEvent::new(
"user-hidden",
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,
hide_uploads = body.hide_uploads,
"host: ban_user"
);
@@ -136,8 +180,30 @@ pub async fn unban_user(
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
// admins. Without this a host could override an admin's ban of another host,
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin"
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
{
return Err(AppError::Forbidden(
"Du kannst diesen Benutzer nicht entsperren.".into(),
));
}
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
// `is_banned` would leave their content invisible. Clear both.
let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
"UPDATE \"user\" SET is_banned = FALSE, uploads_hidden = FALSE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
@@ -189,9 +255,25 @@ pub async fn set_role(
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin" {
// Admins are untouchable by hosts. A plain host also may not demote another
// *host*: without this guard a host could demote a peer host to guest and then
// ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer
// guards key off the target's *current* role, so a prior demotion would launder
// past them. Only an admin may change a host's role.
if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) {
return Err(AppError::Forbidden(
"Admins können nicht geändert werden.".into(),
"Du darfst die Rolle dieses Benutzers nicht ändern.".into(),
));
}
// 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(),
));
}
@@ -263,15 +345,26 @@ pub async fn reset_user_pin(
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
pin_failed_attempts = 0,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
WHERE id = $2 AND event_id = $3",
)
.bind(&pin_hash)
.bind(user_id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// A PIN reset means the old credential is compromised/forgotten — revoke every
// existing session so old devices must re-authenticate with the new PIN.
let _ = Session::delete_all_for_user(&state.pool, user_id).await;
// Resolve any pending in-app "I forgot my PIN" request for this user.
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
.bind(user_id)
.execute(&state.pool)
.await;
// Notify the *recipient* device(s) if they happen to be online so they can clear
// their cached local PIN. They'll save the new one on the next /recover.
let _ = state.sse_tx.send(SseEvent::new(
@@ -289,6 +382,48 @@ pub async fn reset_user_pin(
Ok(Json(PinResetResponse { pin }))
}
#[derive(Serialize, sqlx::FromRow)]
pub struct PinResetRequestSummary {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: String,
pub created_at: DateTime<Utc>,
}
/// List pending in-app PIN-reset requests so a host can action them (via the existing
/// `reset_user_pin`, which also clears the request).
pub async fn list_pin_reset_requests(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<Json<Vec<PinResetRequestSummary>>, AppError> {
let rows = sqlx::query_as::<_, PinResetRequestSummary>(
"SELECT r.id, r.user_id, u.display_name, r.created_at
FROM pin_reset_request r
JOIN \"user\" u ON u.id = r.user_id
WHERE r.event_id = $1
ORDER BY r.created_at ASC",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
/// Dismiss a PIN-reset request without resetting (e.g. the host couldn't verify the
/// requester's identity).
pub async fn dismiss_pin_reset_request(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
sqlx::query("DELETE FROM pin_reset_request WHERE id = $1 AND event_id = $2")
.bind(id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn host_delete_upload(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
@@ -328,6 +463,10 @@ pub async fn host_delete_comment(
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
let _ = state.sse_tx.send(SseEvent::new(
"comment-deleted",
serde_json::json!({ "comment_id": comment_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
@@ -341,14 +480,18 @@ pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
sqlx::query(
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// Only broadcast when this call actually flipped the lock — closing an
// already-closed event is a no-op and shouldn't spam listeners.
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
}
Ok(StatusCode::NO_CONTENT)
}
@@ -357,14 +500,25 @@ pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1",
// Reopening also 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. Clearing `export_released_at` (and the readiness
// flags) lets the host re-release later to regenerate a correct, complete keepsake.
let result = sqlx::query(
"UPDATE event
SET uploads_locked_at = NULL,
export_released_at = NULL,
export_zip_ready = FALSE,
export_html_ready = FALSE
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}
Ok(StatusCode::NO_CONTENT)
}
@@ -373,39 +527,55 @@ pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
// release calls can't both pass a check-then-set and double-enqueue exports.
// rows_affected == 0 means someone already released.
//
// Releasing also locks uploads in the same statement (release ⇒ lock). Otherwise a
// guest whose offline upload reconnects *after* the export snapshot would land in the
// live feed but not in the downloaded keepsake — a silent, non-regenerable data loss.
// `COALESCE` preserves an earlier explicit lock time rather than overwriting it.
let result = sqlx::query(
"UPDATE event
SET export_released_at = NOW(),
uploads_locked_at = COALESCE(uploads_locked_at, NOW())
WHERE slug = $1 AND export_released_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
// Distinguish "no such event" from "already released" for a clean error.
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.is_some();
return Err(if exists {
AppError::BadRequest("Galerie wurde bereits freigegeben.".into())
} else {
AppError::NotFound("Event nicht gefunden.".into())
});
}
// Release locked uploads too — tell any open composer to flip to the locked UI live
// rather than discovering it via a rejected upload.
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// We won the claim — load the event for its id/name to enqueue export jobs.
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.export_released_at.is_some() {
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
}
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.await?;
}
// Spawn export workers
crate::services::export::spawn_export_jobs(
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
// cleanly and startup recovery uses identical logic.
crate::services::export::enqueue_and_spawn_exports(
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
state.sse_tx.clone(),
);
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -55,6 +55,12 @@ pub struct MeContextDto {
pub privacy_note: String,
pub quota_enabled: bool,
pub storage_quota_enabled: bool,
/// Uploads are locked (event closed) — the composer should show a locked state live
/// instead of letting a guest compose an upload only to eat a 403.
pub uploads_locked: bool,
/// The gallery has been released and the export snapshotted — uploads are permanently
/// closed for this run (release ⇒ lock, and reopening regenerates).
pub gallery_released: bool,
}
pub async fn get_context(
@@ -65,9 +71,14 @@ pub async fn get_context(
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_enabled = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?;
let uploads_locked = event.as_ref().map(|e| e.uploads_locked_at.is_some()).unwrap_or(false);
let gallery_released = event.as_ref().map(|e| e.export_released_at.is_some()).unwrap_or(false);
Ok(Json(MeContextDto {
user_id: user.id,
@@ -76,5 +87,7 @@ pub async fn get_context(
privacy_note,
quota_enabled,
storage_quota_enabled,
uploads_locked,
gallery_released,
}))
}

View File

@@ -2,6 +2,7 @@ pub mod admin;
pub mod feed;
pub mod host;
pub mod me;
pub mod public;
pub mod social;
pub mod sse;
pub mod test_admin;

View File

@@ -0,0 +1,24 @@
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::state::AppState;
#[derive(Serialize)]
pub struct PublicEventDto {
pub name: String,
pub slug: String,
}
/// Public event identity, used by the pre-auth join/recover screens so a guest can
/// see *which* event they're joining. Only the display name and slug are exposed —
/// nothing user-scoped — so this is safe without a token. Served straight from the
/// instance config (no DB round-trip needed).
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
Json(PublicEventDto {
name: state.config.event_name.clone(),
slug: state.config.event_slug.clone(),
})
}

View File

@@ -2,20 +2,32 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::comment::{Comment, CommentDto};
use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
#[derive(Serialize)]
pub struct LikeResponse {
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
/// this rather than blind-inverting local state — otherwise a second device (same
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
pub liked: bool,
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
/// keeps its current count when this is null rather than adopting a wrong number.
pub like_count: Option<i64>,
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
) -> Result<Json<LikeResponse>, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
@@ -24,7 +36,17 @@ pub async fn toggle_like(
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Try to insert; if conflict, delete (toggle)
// Event-scope: the upload must belong to the caller's event (404 otherwise),
// matching the host handlers' find_by_id_and_event pattern.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// NOTE: liking is intentionally allowed while the event is locked. Locking
// ("Event schließen") freezes *new uploads* only — likes, comments and
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
ON CONFLICT (upload_id, user_id) DO NOTHING",
@@ -34,7 +56,8 @@ pub async fn toggle_like(
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
let liked = result.rows_affected() > 0;
if !liked {
// Already liked — remove
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
.bind(upload_id)
@@ -43,13 +66,30 @@ pub async fn toggle_like(
.await?;
}
// Broadcast SSE
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The 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.
let like_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
.ok();
Ok(StatusCode::NO_CONTENT)
if let Some(count) = like_count {
// Broadcast the new count so other clients patch their card. Only `like_count` is
// shared — each client's own `liked_by_me` only changes via its own toggle (which
// now reads it straight from this response).
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
});
}
Ok(Json(LikeResponse { liked, like_count }))
}
#[derive(Deserialize, Default)]
@@ -64,10 +104,15 @@ const COMMENT_PAGE_SIZE: i64 = 50;
pub async fn list_comments(
State(state): State<AppState>,
_auth: AuthUser,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<CommentDto>>, AppError> {
// Event-scope: only list comments for an upload in the caller's event.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let comments =
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
Ok(Json(comments))
@@ -91,6 +136,15 @@ pub async fn add_comment(
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Event-scope: only comment on an upload that belongs to the caller's event.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// NOTE: commenting is intentionally allowed while the event is locked. Locking
// freezes *new uploads* only — likes, comments and browsing stay open
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
let text = body.body.trim();
let text_chars = text.chars().count();
if text_chars == 0 || text_chars > 500 {
@@ -99,26 +153,41 @@ pub async fn add_comment(
));
}
let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?;
// Process hashtags in comment body
// Insert the comment and link its hashtags atomically, so a crash mid-loop
// can't leave a committed comment with only some of its tags indexed.
let tags = hashtag::extract_hashtags(text);
let mut tx = state.pool.begin().await?;
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
for tag in &tags {
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
sqlx::query(
"INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(comment.id)
.bind(h.id)
.execute(&state.pool)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// Broadcast SSE
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-comment".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*)
// 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.
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
{
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-comment".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "comment_count": comment_count })
.to_string(),
});
}
let dto = CommentDto {
id: comment.id,
@@ -137,6 +206,10 @@ pub async fn delete_comment(
auth: AuthUser,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Banned users keep read access but cannot mutate (USER_JOURNEYS §10).
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let comment = Comment::find_by_id(&state.pool, comment_id)
.await?
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
@@ -145,6 +218,15 @@ pub async fn delete_comment(
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
}
Comment::soft_delete(&state.pool, comment_id).await?;
// Event-scope: soft_delete_in_event only matches comments whose upload is in
// the caller's event, so a cross-event comment_id resolves to a 404 here.
let deleted = Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"comment-deleted",
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -6,6 +6,7 @@ use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
@@ -22,6 +23,10 @@ pub struct SseQuery {
#[derive(Serialize)]
pub struct StreamTicketResponse {
pub ticket: String,
/// Server clock at mint time — the client seeds its SSE reconnect cursor from this
/// instead of `new Date()`, so a skewed browser clock can't drop uploads. See
/// `DeltaResponse::server_time`.
pub server_time: chrono::DateTime<chrono::Utc>,
}
/// Mint a short-lived single-use SSE ticket. The browser's `EventSource` cannot
@@ -32,9 +37,12 @@ pub struct StreamTicketResponse {
pub async fn issue_ticket(
State(state): State<AppState>,
auth: AuthUser,
) -> Json<StreamTicketResponse> {
) -> Result<Json<StreamTicketResponse>, AppError> {
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(StreamTicketResponse { ticket })
let server_time = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
Ok(Json(StreamTicketResponse { ticket, server_time }))
}
/// SSE stream endpoint. Authenticates via a single-use ticket (see
@@ -48,19 +56,57 @@ pub async fn stream(
.consume(&q.ticket)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
// NOTE: this authenticates via ticket→session, not the `AuthUser` extractor. The
// SSE stream is read-only, and under the read-only-ban model (USER_JOURNEYS §10)
// banned users retain read access — so both minting a ticket and holding a stream
// open are intentionally allowed for banned users; only writes are blocked.
Session::find_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
let rx = state.sse_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
Ok(sse_event) => Some(Ok(Event::default()
.event(sse_event.event_type)
.data(sse_event.data))),
Err(_) => None,
// A consumer that falls behind the broadcast buffer would otherwise silently
// lose events, leaving its feed permanently stale. Instead of dropping the
// gap, tell the client to resync — the frontend responds by running a full
// feed-delta fetch (which reconciles both new uploads and deletions).
Err(BroadcastStreamRecvError::Lagged(n)) => {
tracing::warn!("SSE consumer lagged, dropped {n} event(s); emitting resync");
Some(Ok(Event::default().event("resync").data(n.to_string())))
}
});
// The session is only checked once at open. Re-validate it periodically so a
// logged-out, expired, OR banned session's stream is closed rather than kept alive
// until the client happens to disconnect. Bounds a stale stream to ~60s. Banning
// already revokes the user's sessions (so the row is gone), but re-reading the live
// user row and dropping on `is_banned` is a cheap defense-in-depth. Only a
// *definitive* gone/expired/banned state ends the stream; a transient DB error just
// retries next tick.
let pool = state.pool.clone();
let session_hash = token_hash.clone();
let session_gone = async move {
let mut ticker = tokio::time::interval(Duration::from_secs(60));
ticker.tick().await; // consume the immediate first tick
loop {
ticker.tick().await;
match Session::find_user_by_token_hash(&pool, &session_hash).await {
Ok(Some(user)) if !user.is_banned => {} // still valid — keep streaming
Ok(Some(_)) => break, // banned — cut the stream
Ok(None) => break, // logged out or expired — stop
Err(e) => {
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
}
}
}
};
let stream = futures::StreamExt::take_until(events, Box::pin(session_gone));
Ok(Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(30))

View File

@@ -70,10 +70,21 @@ pub async fn truncate_all(
let _ = tokio::fs::remove_dir_all(&state.config.media_path).await;
let _ = tokio::fs::create_dir_all(&state.config.media_path).await;
// Wipe the export directory too. Exports moved OUT of media_path (CR2 fix), so
// the media wipe above no longer covers them — without this a real export in
// one test would leave Gallery.zip on disk and contaminate the next.
let _ = tokio::fs::remove_dir_all(&state.config.export_path).await;
let _ = tokio::fs::create_dir_all(&state.config.export_path).await;
// The rate limiter holds an in-memory HashMap; clear it so a previous test's
// counters don't leak into the next one.
state.rate_limiter.clear();
// The reseed above wrote the `config` table directly (bypassing patch_config), so
// the cache must be invalidated too — otherwise the first request after a truncate
// could serve the previous test's toggles.
state.config_cache.invalidate();
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -16,16 +16,37 @@ use crate::state::AppState;
const MAX_CAPTION_LENGTH: usize = 2000;
/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from
/// the file's magic bytes. The detected MIME (not the client-declared one) is what
/// we trust, store, and hand to the compression pipeline — so a text-based payload
/// (SVG/HTML/JS) can never be stored or served on-origin. Each entry maps to the
/// server-controlled file extension we persist the original under.
///
/// HEIC/HEIF are deliberately excluded: the preview pipeline (`image` crate, and
/// the bundled ffmpeg 6.1) cannot decode them, so accepting them would store files
/// that never get a thumbnail. iOS Safari already transcodes HEIC→JPEG when a photo
/// is selected via a file input, so this rejects only the rare HEIC-preserving
/// upload path — with a clear error rather than a silently broken post.
const ALLOWED_MEDIA: &[(&str, &str)] = &[
("image/jpeg", "jpg"),
("image/png", "png"),
("image/webp", "webp"),
("image/gif", "gif"),
("video/mp4", "mp4"),
("video/quicktime", "mov"),
("video/webm", "webm"),
];
pub async fn upload(
State(state): State<AppState>,
auth: AuthUser,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let upload_rate_on = config::get_bool(&state.pool, "upload_rate_enabled", true).await;
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;
if rate_limits_on && upload_rate_on {
let upload_rate = config::get_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
let upload_rate = config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("upload:{}", auth.user_id),
upload_rate,
@@ -56,54 +77,94 @@ pub async fn upload(
drain_multipart(multipart).await;
return Err(AppError::Forbidden("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.
if event.export_released_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Galerie wurde bereits freigegeben.".into()));
}
// Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.config_cache, "max_video_size_mb", 500).await;
let mut file_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut content_type: Option<String> = None;
// The uploaded file is streamed straight to a temp file on disk (never buffered
// whole in memory — a 500 MB video used to cost 500 MB of RAM per concurrent
// upload). We only keep the first ≤512 bytes in memory for magic-byte sniffing.
// On success the temp file is renamed into place under its detected extension.
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
let originals_dir = state.config.media_path.join(format!("originals/{event_slug}"));
let temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
let mut caption: Option<String> = None;
let mut hashtags_csv: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::BadRequest(e.to_string()))? {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
file_name = field.file_name().map(|s| s.to_string());
content_type = field.content_type().map(|s| s.to_string());
file_data = Some(
field.bytes().await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
.to_vec(),
);
// Wrap the multipart read so any error after the temp file is created still cleans
// it up (a mid-stream parse failure must not leave a stray `.tmp` on disk).
let parse_result: Result<(), AppError> = async {
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?
{
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
// The client-declared Content-Type does NOT determine the stored
// MIME/extension — those come from the file's magic bytes below. The
// declared type only picks the streaming cap so an oversized body is
// aborted early; a mislabelled type only makes the cap *stricter*
// (safe), and the authoritative per-class check still runs on the
// detected type.
let declared = field.content_type().unwrap_or("").to_string();
let cap_bytes = if declared.starts_with("video/") {
(max_video_mb * 1024 * 1024) as usize
} else if declared.starts_with("image/") {
(max_image_mb * 1024 * 1024) as usize
} else {
(max_image_mb.max(max_video_mb) * 1024 * 1024) as usize
};
tokio::fs::create_dir_all(&originals_dir)
.await
.map_err(|e| AppError::Internal(e.into()))?;
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
}
"caption" => {
caption =
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
"hashtags" => {
hashtags_csv =
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
_ => {}
}
"caption" => {
caption = Some(
field.text().await
.map_err(|e| AppError::BadRequest(e.to_string()))?,
);
}
"hashtags" => {
hashtags_csv = Some(
field.text().await
.map_err(|e| AppError::BadRequest(e.to_string()))?,
);
}
_ => {}
}
Ok(())
}
.await;
if let Err(e) = parse_result {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(e);
}
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?;
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
let size = data.len() as i64;
// From here on the temp file may exist; every validation failure removes it before
// returning so a rejected upload never leaves bytes behind.
let (size, head) = match streamed {
Some(s) => s,
None => return Err(AppError::BadRequest("Keine Datei hochgeladen.".into())),
};
// Validate caption length. Counted in chars (code points) to match the
// "Zeichen" wording in the error message — `.len()` would be bytes and
// reject perfectly valid German/emoji captions early.
if let Some(ref cap) = caption {
if cap.chars().count() > MAX_CAPTION_LENGTH {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(format!(
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
MAX_CAPTION_LENGTH
@@ -111,30 +172,43 @@ pub async fn upload(
}
}
// Validate file MIME type using magic bytes
let detected_mime = infer::get(&data);
if let Some(detected) = detected_mime {
let detected_type = detected.mime_type();
// Ensure detected type is compatible with declared MIME type
let declared_category = mime.split('/').next().unwrap_or("");
let detected_category = detected_type.split('/').next().unwrap_or("");
// Only reject if categories don't match (e.g., image vs video)
if declared_category != "application" && declared_category != detected_category {
// Determine the file type from its magic bytes and require it to be on the
// allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so
// those are rejected outright — closing the stored-XSS vector. Both the MIME
// we persist and the on-disk extension come from the detected type, never from
// client-supplied values.
let kind = match infer::get(&head) {
Some(k) => k,
None => {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(
"Dateityp nicht erkannt oder nicht unterstützt.".into(),
));
}
};
let (mime, ext) = match ALLOWED_MEDIA
.iter()
.find(|(allowed, _)| *allowed == kind.mime_type())
.map(|(m, e)| ((*m).to_string(), *e))
{
Some(v) => v,
None => {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(format!(
"Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}",
mime, detected_type
"Dateityp wird nicht unterstützt: {}.",
kind.mime_type()
)));
}
}
};
// Validate file size
// Validate file size against the authoritative per-detected-class limit.
let max_bytes = if mime.starts_with("video/") {
max_video_mb * 1024 * 1024
} else {
max_image_mb * 1024 * 1024
};
if size > max_bytes {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::BadRequest(format!(
"Datei ist zu groß. Maximum: {} MB.",
max_bytes / (1024 * 1024)
@@ -144,56 +218,35 @@ pub async fn upload(
// Per-user storage quota — dynamic formula based on available disk space and the
// number of active uploaders. Gated by master + per-area toggles so the admin can
// disable it on trusted instances.
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
// 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
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
// pre-check and both increment, blowing past the quota. The pre-check stays as a
// fast path that avoids the disk write when the user is already clearly over.
let mut quota_limit: Option<i64> = None;
if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes {
quota_limit = Some(limit);
let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit {
return Err(AppError::TooManyRequests(
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::QuotaExceeded(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
}
}
// Determine file extension
let ext = file_name
.as_deref()
.and_then(|n| n.rsplit('.').next())
.unwrap_or(if mime.starts_with("video/") { "mp4" } else { "jpg" });
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
// All checks passed — atomically move the temp file to its final, extension-correct
// path (same directory, so the rename is cheap and atomic).
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
let absolute_path = state.config.media_path.join(&relative_path);
// Ensure directory exists and write file
if let Some(parent) = absolute_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| AppError::Internal(e.into()))?;
}
tokio::fs::write(&absolute_path, &data).await.map_err(|e| AppError::Internal(e.into()))?;
// Update user's total upload bytes
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
.bind(auth.user_id)
.bind(size)
.execute(&state.pool)
.await?;
// Insert upload record
let upload = Upload::create(
&state.pool,
auth.event_id,
auth.user_id,
&relative_path,
&mime,
size,
caption.as_deref(),
)
.await?;
tokio::fs::rename(&temp_abs, &absolute_path)
.await
.map_err(|e| AppError::Internal(e.into()))?;
// Process hashtags from caption and explicit CSV
let mut tags: Vec<String> = Vec::new();
@@ -211,10 +264,66 @@ pub async fn upload(
tags.sort();
tags.dedup();
for tag in &tags {
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
Hashtag::link_to_upload(&state.pool, upload.id, h.id).await?;
// Quota accounting, the upload row, and its hashtag links must be atomic: a
// crash between the bytes increment and the insert would permanently charge
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
let tx_result: Result<Upload, AppError> = async {
let mut tx = state.pool.begin().await?;
// Increment the user's byte total. When a quota is in force, guard it atomically
// (`total + size <= limit`) so two concurrent uploads can't both slip past the
// stale pre-check — the loser's UPDATE matches 0 rows and we abort with the same
// terminal quota error (the tx rolls back on drop; the on-disk file is cleaned by
// the error path below).
let inc = if let Some(limit) = quota_limit {
sqlx::query(
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3",
)
.bind(auth.user_id)
.bind(size)
.bind(limit)
.execute(&mut *tx)
.await?
} else {
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
.bind(auth.user_id)
.bind(size)
.execute(&mut *tx)
.await?
};
if inc.rows_affected() == 0 {
return Err(AppError::QuotaExceeded(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
));
}
let upload = Upload::create(
&mut *tx,
auth.event_id,
auth.user_id,
&relative_path,
&mime,
size,
caption.as_deref(),
)
.await?;
for tag in &tags {
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?;
Ok(upload)
}
.await;
// The file is already on disk at `absolute_path`. If the transaction failed, no DB
// row will ever reference it, so remove it now rather than orphan bytes on disk.
let upload = match tx_result {
Ok(u) => u,
Err(e) => {
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e);
}
};
// Spawn compression task
state
@@ -257,7 +366,11 @@ pub async fn edit_upload(
Path(upload_id): Path<Uuid>,
Json(body): Json<EditUploadRequest>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
// Banned users keep read access but cannot mutate (USER_JOURNEYS §10).
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
@@ -265,17 +378,20 @@ pub async fn edit_upload(
return Err(AppError::Forbidden("Nur eigene Uploads bearbeiten.".into()));
}
// Caption update + hashtag wipe-then-relink in one transaction, so a crash
// mid-relink can't leave the upload with its hashtags stripped.
let mut tx = state.pool.begin().await?;
if let Some(ref caption) = body.caption {
Upload::update_caption(&state.pool, upload_id, Some(caption)).await?;
Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?;
}
if let Some(ref hashtags) = body.hashtags {
Hashtag::unlink_all_from_upload(&state.pool, upload_id).await?;
Hashtag::unlink_all_from_upload(&mut *tx, upload_id).await?;
for tag in hashtags {
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
Hashtag::link_to_upload(&state.pool, upload_id, h.id).await?;
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?;
Ok(StatusCode::OK)
}
@@ -285,7 +401,11 @@ pub async fn delete_upload(
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
// Banned users keep read access but cannot mutate (USER_JOURNEYS §10).
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
@@ -293,11 +413,83 @@ pub async fn delete_upload(
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
}
Upload::soft_delete(&state.pool, upload_id).await?;
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
// Evict the card live on every other feed + the projector diashow — otherwise
// a self-deleted post lingers until each viewer manually reloads. Same event
// the host-delete path already emits and the frontend already handles.
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}
/// Number of leading bytes retained in memory for magic-byte (`infer`) sniffing. Every
/// allowed type's signature sits well within this; 512 is comfortably generous.
const HEAD_SNIFF_BYTES: usize = 512;
/// Stream a multipart field straight to `dest`, aborting with a 400 the moment it
/// exceeds `max_bytes`. Only the first [`HEAD_SNIFF_BYTES`] bytes are kept in memory
/// (for type detection); the rest goes chunk-by-chunk to disk, so peak memory is a
/// single chunk rather than the whole file. Returns `(total_size, head_bytes)`. On any
/// error the partial temp file is removed so no stray `.tmp` is left behind.
async fn stream_field_to_file(
mut field: axum::extract::multipart::Field<'_>,
dest: &std::path::Path,
max_bytes: usize,
) -> Result<(i64, Vec<u8>), AppError> {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::File::create(dest)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let mut total: usize = 0;
let mut head: Vec<u8> = Vec::with_capacity(HEAD_SNIFF_BYTES);
loop {
let chunk = match field.chunk().await {
Ok(Some(c)) => c,
Ok(None) => break,
Err(e) => {
let _ = file.shutdown().await;
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::BadRequest(format!(
"Datei konnte nicht gelesen werden: {e}"
)));
}
};
total = total.saturating_add(chunk.len());
if total > max_bytes {
let _ = file.shutdown().await;
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::BadRequest(format!(
"Datei ist zu groß. Maximum: {} MB.",
max_bytes / (1024 * 1024)
)));
}
if head.len() < HEAD_SNIFF_BYTES {
let need = HEAD_SNIFF_BYTES - head.len();
head.extend_from_slice(&chunk[..need.min(chunk.len())]);
}
if let Err(e) = file.write_all(&chunk).await {
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::Internal(e.into()));
}
}
if let Err(e) = file.flush().await {
let _ = tokio::fs::remove_file(dest).await;
return Err(AppError::Internal(e.into()));
}
Ok((total as i64, head))
}
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
/// Without draining, the client may still be sending the body after we've sent our response,
/// which can corrupt the keep-alive connection for subsequent requests.
@@ -317,14 +509,21 @@ pub struct QuotaEstimate {
pub tolerance: f64,
}
/// Pure per-user quota formula: `floor((free_disk * tolerance) / max(active, 1))`.
/// Extracted from `compute_storage_quota` so it's unit-testable without a DB or disk.
fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i64 {
let active = active_uploaders.max(1);
((free_disk as f64 * tolerance) / active as f64).floor() as i64
}
/// Computes the per-user storage quota using
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. 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 {
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
let tolerance = config::get_f64(&state.pool, "quota_tolerance", 0.75).await;
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = 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",
@@ -334,21 +533,23 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
.unwrap_or((0,));
let active = active_count.max(1);
let media_path = state.config.media_path.to_string_lossy().to_string();
let free_disk = sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
.map(|d| d.available_space())
.unwrap_or_else(|| {
sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| d.mount_point().to_string_lossy() == "/")
.map(|d| d.available_space())
.unwrap_or(0)
}) as i64;
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
let disk = state.disk_cache.snapshot(&state.config.media_path);
let free_disk = disk.map(|d| d.free as i64).unwrap_or(0);
let limit_bytes = if quota_on && storage_quota_on {
Some(((free_disk as f64 * tolerance) / active as f64).floor() as i64)
match disk {
Some(d) => Some(quota_limit_bytes(d.free as i64, tolerance, active)),
// Fail OPEN, not closed: if the disk can't be read we don't know the real
// free space, and enforcing a 0-byte limit would reject every upload with a
// spurious "quota reached". Skip enforcement this round and warn instead.
None => {
tracing::warn!(
"disk snapshot unavailable; skipping storage-quota enforcement this round"
);
None
}
}
} else {
None
};
@@ -361,34 +562,26 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
}
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
/// and `window.open`, defeating the purpose of having the alias.
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
/// Stream a media file from disk into an HTTP response with a fixed set of security
/// headers. Every media response (original, preview, thumbnail) goes through here so
/// they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit
/// `Content-Disposition` and `Cache-Control`.
async fn stream_media_file(
absolute: &std::path::Path,
content_type: String,
disposition: &str,
cache_control: &str,
) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&upload.original_path);
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&absolute)
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
let file = tokio::fs::File::open(absolute)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
@@ -397,17 +590,118 @@ pub async fn get_original(
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.header(header::CACHE_CONTROL, cache_control)
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated so it works from
/// `<img src>` / `window.open`. The URL contains the upload's unguessable UUID. Unlike
/// raw `/media` files, this alias is the *only* way to fetch an original: direct
/// `/media/originals/**` access is blocked in the router, and this handler filters out
/// 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`]).
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&media.original_path);
let filename = absolute
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, upload.mime_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
// Full-res original: force download, never cache at the edge.
stream_media_file(&absolute, media.mime_type, &disposition, "no-store").await
}
/// Streaming access to an upload's compressed **preview** image. Gated exactly like
/// [`get_original`]: `find_visible_media` drops soft-deleted / ban-hidden uploads, so a
/// deleted or moderated post's preview 404s here even though the file may still be on
/// disk. Direct `/media/previews/**` is blocked in the router, making this the only path
/// to a preview.
///
/// Served inline (it's an `<img src>`) and privately cacheable for a short window. The
/// window is intentionally short (5 min) so a moderated image stops being served to a
/// direct-URL holder promptly; the live feed already evicts the card instantly via the
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
pub async fn get_preview(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.preview_path
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
}
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
/// [`get_preview`].
pub async fn get_thumbnail(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.thumbnail_path
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await
}
#[cfg(test)]
mod tests {
use super::quota_limit_bytes;
#[test]
fn divides_free_space_by_uploaders_with_tolerance() {
// 1000 * 0.75 / 3 = 250
assert_eq!(quota_limit_bytes(1000, 0.75, 3), 250);
}
#[test]
fn floors_fractional_results() {
// 1000 * 0.75 / 7 = 107.14… → 107
assert_eq!(quota_limit_bytes(1000, 0.75, 7), 107);
}
#[test]
fn active_uploaders_below_one_is_clamped_to_one() {
// Guards against divide-by-zero when no one has uploaded yet.
assert_eq!(quota_limit_bytes(1000, 1.0, 0), 1000);
assert_eq!(quota_limit_bytes(1000, 1.0, -5), 1000);
}
#[test]
fn zero_free_disk_yields_zero() {
assert_eq!(quota_limit_bytes(0, 0.75, 3), 0);
}
#[test]
fn full_tolerance_is_identity_for_a_single_uploader() {
assert_eq!(quota_limit_bytes(500, 1.0, 1), 500);
}
}

View File

@@ -18,6 +18,10 @@ mod state;
use config::AppConfig;
use state::AppState;
/// Hard HTTP body cap for the upload endpoint (576 MiB). Backstop against
/// memory-exhaustion; precise per-class size limits are enforced in the handler.
const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024;
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
@@ -40,6 +44,18 @@ async fn main() -> Result<()> {
let state = AppState::new(pool.clone(), config.clone());
// Re-spawn exports for events that were released but whose keepsake never finished
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
// background; the HTTP server can start accepting requests meanwhile.
services::export::recover_exports(
pool.clone(),
config.media_path.clone(),
config.export_path.clone(),
state.sse_tx.clone(),
)
.await;
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
services::maintenance::spawn_periodic_tasks(
@@ -53,13 +69,22 @@ async fn main() -> Result<()> {
let api = Router::new()
// Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover))
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
.route("/api/v1/recover/request", post(auth::handlers::request_pin_reset))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// Upload — body limit disabled; size validation is done inside the handler
// "Sign out everywhere" — revoke all of the caller's sessions.
.route("/api/v1/sessions", delete(auth::handlers::logout_all))
// Upload — HTTP-level body cap as an OOM backstop. The handler still enforces
// the precise per-class limits from DB config (max_image/video_size_mb); this
// layer just stops a multi-GB body from being buffered into memory before that
// check runs. Sized generously above the default 500 MB video limit + multipart
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
.route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::disable()))
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)))
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
@@ -68,6 +93,17 @@ async fn main() -> Result<()> {
"/api/v1/upload/{id}/original",
get(handlers::upload::get_original),
)
// Preview/thumbnail variants are gated the same way as originals (visibility
// check + direct /media block below) so moderation actually revokes access to
// the displayed images, not just the full-res download.
.route(
"/api/v1/upload/{id}/preview",
get(handlers::upload::get_preview),
)
.route(
"/api/v1/upload/{id}/thumbnail",
get(handlers::upload::get_thumbnail),
)
// 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))
@@ -98,10 +134,19 @@ async fn main() -> Result<()> {
"/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin),
)
.route(
"/api/v1/host/pin-reset-requests",
get(handlers::host::list_pin_reset_requests),
)
.route(
"/api/v1/host/pin-reset-requests/{id}",
delete(handlers::host::dismiss_pin_reset_request),
)
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
.route("/api/v1/export/ticket", post(handlers::admin::export_ticket))
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
@@ -135,13 +180,95 @@ async fn main() -> Result<()> {
let router = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
// Block direct HTTP access to ALL media subtrees. The files live under
// `media_path` (so the compression worker and export can read them off disk) but
// must NOT be pullable straight from `/media/**` — that bypasses the visibility
// checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch
// goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter
// via `find_visible_media`. The more specific nests take precedence over the
// `/media` ServeDir below (which, with all three subtrees blocked, now serves
// nothing — kept as a backstop).
.nest_service(
"/media/originals",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/previews",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service(
"/media/thumbnails",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
tracing::info!("listening on {}", listener.local_addr()?);
axum::serve(listener, router).await?;
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
/// drain — and thus the process — pending until the orchestrator force-kills it.
const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
/// Resolves on SIGINT (Ctrl-C) or SIGTERM (container stop / deploy). Letting
/// `axum::serve` drain in-flight requests on this signal means a redeploy no longer
/// truncates uploads mid-flight; any background compression/export half-states that a
/// hard kill would leave are already reconciled by `startup_recovery` on the next boot.
///
/// Once the signal fires we also arm a detached backstop that force-exits after
/// [`SHUTDOWN_GRACE`]. Without it, open SSE streams (which have no natural end) would
/// hold the graceful drain open indefinitely; the backstop bounds shutdown regardless
/// of the orchestrator's own kill timeout. If the drain completes first, `main` returns
/// and the process exits before the timer ever fires.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(error = ?e, "failed to install SIGTERM handler");
// Never resolve — fall back to ctrl_c only.
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
tracing::info!(
"shutdown signal received, draining in-flight requests (max {}s)",
SHUTDOWN_GRACE.as_secs()
);
// Backstop: if the graceful drain is still blocked after the grace window (almost
// always because SSE streams are still open), exit anyway so deploys aren't stalled.
tokio::spawn(async {
tokio::time::sleep(SHUTDOWN_GRACE).await;
tracing::warn!(
"graceful drain exceeded {}s (likely open SSE streams); forcing exit",
SHUTDOWN_GRACE.as_secs()
);
std::process::exit(0);
});
}

View File

@@ -24,19 +24,24 @@ pub struct CommentDto {
}
impl Comment {
pub async fn create(
pool: &PgPool,
/// Takes any executor so the caller can insert the comment and link its
/// hashtags inside a single transaction.
pub async fn create<'e, E>(
executor: E,
upload_id: Uuid,
user_id: Uuid,
body: &str,
) -> Result<Self, sqlx::Error> {
) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_as::<_, Self>(
"INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *",
)
.bind(upload_id)
.bind(user_id)
.bind(body)
.fetch_one(pool)
.fetch_one(executor)
.await
}

View File

@@ -10,7 +10,13 @@ pub struct Hashtag {
impl Hashtag {
/// Upsert a hashtag (insert if not exists, return existing if it does).
pub async fn upsert(pool: &PgPool, event_id: Uuid, tag: &str) -> Result<Self, sqlx::Error> {
///
/// Takes any executor so callers can run it inside a transaction (atomic
/// upload/comment writes) or standalone against the pool.
pub async fn upsert<'e, E>(executor: E, event_id: Uuid, tag: &str) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let normalized = tag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, Self>(
"INSERT INTO hashtag (event_id, tag) VALUES ($1, $2)
@@ -19,33 +25,39 @@ impl Hashtag {
)
.bind(event_id)
.bind(&normalized)
.fetch_one(pool)
.fetch_one(executor)
.await
}
pub async fn link_to_upload(
pool: &PgPool,
pub async fn link_to_upload<'e, E>(
executor: E,
upload_id: Uuid,
hashtag_id: Uuid,
) -> Result<(), sqlx::Error> {
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query(
"INSERT INTO upload_hashtag (upload_id, hashtag_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING",
)
.bind(upload_id)
.bind(hashtag_id)
.execute(pool)
.execute(executor)
.await?;
Ok(())
}
pub async fn unlink_all_from_upload(
pool: &PgPool,
pub async fn unlink_all_from_upload<'e, E>(
executor: E,
upload_id: Uuid,
) -> Result<(), sqlx::Error> {
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query("DELETE FROM upload_hashtag WHERE upload_id = $1")
.bind(upload_id)
.execute(pool)
.execute(executor)
.await?;
Ok(())
}
@@ -109,4 +121,38 @@ mod tests {
fn empty_or_bare_hash_skipped() {
assert_eq!(extract_hashtags("# #"), Vec::<String>::new());
}
#[test]
fn tag_stops_at_first_non_word_char() {
// A tag runs until the first char that isn't ascii-alphanumeric or '_'.
assert_eq!(extract_hashtags("#foo#bar"), vec!["foo"]);
assert_eq!(extract_hashtags("#foo-bar"), vec!["foo"]);
assert_eq!(extract_hashtags("##tag"), Vec::<String>::new()); // '#' after the strip is non-word
}
#[test]
fn tag_length_is_capped_at_40_chars() {
let ok = "a".repeat(40);
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
// 41+ chars → dropped entirely (not truncated).
let too_long = "a".repeat(41);
assert_eq!(extract_hashtags(&format!("#{too_long}")), Vec::<String>::new());
}
#[test]
fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
// Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
// occurrence so callers can count/link them independently. Case folds to lower.
assert_eq!(extract_hashtags("#fun #Fun #fun!"), vec!["fun", "fun", "fun"]);
}
#[test]
fn non_ascii_word_chars_truncate_the_tag() {
// KNOWN LIMITATION for a German app: `is_ascii_alphanumeric` excludes umlauts
// and ß, so a tag truncates at the first non-ASCII letter. Pinned here so a
// future Unicode-aware change is a deliberate, test-visible decision.
assert_eq!(extract_hashtags("#Grüße"), vec!["gr"]);
assert_eq!(extract_hashtags("#Straße"), vec!["stra"]);
assert_eq!(extract_hashtags("#café"), vec!["caf"]);
}
}

View File

@@ -43,11 +43,47 @@ impl Session {
.await
}
pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
/// Resolve a session token straight to its live user row in one round-trip.
///
/// The auth extractor runs on every authenticated request and used to do two
/// sequential queries (session lookup, then user lookup); this collapses them into
/// a single `session JOIN "user"`. The `expires_at` guard mirrors
/// [`Self::find_by_token_hash`], and the user row is read live (role/ban are never
/// trusted from the JWT). Returns `None` when the session is missing/expired.
pub async fn find_user_by_token_hash(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<crate::models::user::User>, sqlx::Error> {
sqlx::query_as::<_, crate::models::user::User>(
"SELECT u.*
FROM session s
JOIN \"user\" u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
/// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
/// an actively-used session never hits the fixed 30-day cliff (it renews on every
/// authenticated request). An idle session still expires `expiry_days` after its last
/// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
pub async fn touch_and_renew(
pool: &PgPool,
token_hash: &str,
expiry_days: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE session
SET last_seen_at = NOW(),
expires_at = NOW() + ($2 || ' days')::interval
WHERE token_hash = $1",
)
.bind(token_hash)
.bind(expiry_days.to_string())
.execute(pool)
.await?;
Ok(())
}
@@ -61,4 +97,14 @@ impl Session {
.await?;
Ok(())
}
/// Revoke every session for a user. Backs "sign out everywhere" and the forced
/// re-auth after a host PIN-reset or a ban. Returns the number of sessions cleared.
pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let r = sqlx::query("DELETE FROM session WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(r.rows_affected())
}
}

View File

@@ -35,16 +35,31 @@ pub struct UploadDto {
pub created_at: DateTime<Utc>,
}
/// Minimal projection of an upload's on-disk file paths, used by the visibility-gated
/// media aliases so they don't hydrate the entire `Upload` row per request.
#[derive(Debug, sqlx::FromRow)]
pub struct VisibleMedia {
pub original_path: String,
pub preview_path: Option<String>,
pub thumbnail_path: Option<String>,
pub mime_type: String,
}
impl Upload {
pub async fn create(
pool: &PgPool,
/// Takes any executor so the caller can run it inside a transaction (atomic
/// quota + insert) or standalone against the pool.
pub async fn create<'e, E>(
executor: E,
event_id: Uuid,
user_id: Uuid,
original_path: &str,
mime_type: &str,
original_size_bytes: i64,
caption: Option<&str>,
) -> Result<Self, sqlx::Error> {
) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_as::<_, Self>(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
VALUES ($1, $2, $3, $4, $5, $6)
@@ -56,7 +71,7 @@ impl Upload {
.bind(mime_type)
.bind(original_size_bytes)
.bind(caption)
.fetch_one(pool)
.fetch_one(executor)
.await
}
@@ -69,6 +84,31 @@ impl Upload {
.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
/// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
/// from the feed also stops its original/preview/thumbnail from being pulled by UUID.
///
/// Selects four columns instead of the whole `Upload` row: this runs once per image
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
/// never uses.
pub async fn find_visible_media(
pool: &PgPool,
id: Uuid,
) -> Result<Option<VisibleMedia>, sqlx::Error> {
sqlx::query_as::<_, VisibleMedia>(
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
FROM upload up
JOIN \"user\" u ON u.id = up.user_id
WHERE up.id = $1 AND up.deleted_at IS NULL
AND u.uploads_hidden = false AND u.is_banned = false",
)
.bind(id)
.fetch_optional(pool)
.await
}
/// Event-scoped lookup used by host endpoints so a host of event A cannot
/// reach uploads belonging to event B.
pub async fn find_by_id_and_event(
@@ -182,15 +222,18 @@ impl Upload {
Ok(deleted)
}
pub async fn update_caption(
pool: &PgPool,
pub async fn update_caption<'e, E>(
executor: E,
id: Uuid,
caption: Option<&str>,
) -> Result<(), sqlx::Error> {
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query("UPDATE upload SET caption = $2 WHERE id = $1")
.bind(id)
.bind(caption)
.execute(pool)
.execute(executor)
.await?;
Ok(())
}

View File

@@ -42,11 +42,28 @@ impl CompressionWorker {
}
Err(e) => {
tracing::error!("compression failed for upload {upload_id}: {e:#}");
// Auto-cleanup: a failed transcode would otherwise leave a
// permanently broken feed card, silently charge the uploader's
// quota, and orphan the original on disk. Refund + soft-delete
// (one tx, so v_feed excludes it), remove the orphan file, then
// tell the uploader (upload-error toast) and evict the card
// everywhere (upload-deleted, already handled by the feed).
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
}
let orphan = worker.media_path.join(&original_path);
if let Err(rm) = tokio::fs::remove_file(&orphan).await {
tracing::warn!(error = ?rm, path = %orphan.display(), "failed to remove orphaned original");
}
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-error".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-deleted".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
}
}
});
@@ -93,8 +110,21 @@ impl CompressionWorker {
// Run blocking image operations in a spawn_blocking task
tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&original)
.context("failed to open image")?;
// Reject decompression bombs *before* fully decoding: the upload body
// cap bounds the file size on disk, but a small file can still decode to
// enormous dimensions (e.g. a ~1 MB image expanding to 50k×50k px →
// gigabytes), OOM-ing the box during decode/resize. 12000×12000 covers
// any real phone photo; max_alloc hard-caps the decode allocation.
let mut reader = image::ImageReader::open(&original)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
let img = reader.decode().context("failed to decode image")?;
// Resize to max 800px wide, preserving aspect ratio
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);

View File

@@ -1,46 +1,129 @@
//! Reads of the runtime-tunable `config` table.
//! Reads of the runtime-tunable `config` table, fronted by an in-memory cache.
//!
//! Each handler used to keep a small local copy of these helpers; consolidating them
//! here means one place to add a parser, one place to mock for tests, and one place to
//! find when a key changes. New keys do not require code changes — they're picked up
//! the next time someone calls `get_*`.
//! the next time the cache reloads.
//!
//! ## Why a cache
//!
//! The `config` table is effectively static during an event, yet it was the busiest
//! query in the system: every request re-read each key with its own `SELECT`
//! (an upload touched it ~8 times). Against the small connection pool that was the
//! throughput ceiling. [`ConfigCache`] loads the whole table once and serves reads
//! from memory.
//!
//! ## Consistency contract
//!
//! Correctness comes from **synchronous invalidation on every write**, not from the
//! TTL. The two runtime write paths — the admin `PATCH /admin/config` handler and the
//! test-mode truncate/reseed — both call [`ConfigCache::invalidate`] after committing,
//! so the *next* read reloads from the DB and sees the new value immediately. The
//! [`RELOAD_TTL`] is only a safety net for out-of-band changes (e.g. a migration or a
//! manual DB edit); it is deliberately short but never the primary mechanism.
//!
//! Values are read with a default fallback so the app still starts if a key is missing
//! (e.g. during a migration window). Production seeds keys via migrations 005 and 009.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use sqlx::PgPool;
async fn fetch_raw(pool: &PgPool, key: &str) -> Option<String> {
sqlx::query_as::<_, (String,)>("SELECT value FROM config WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
.ok()
.flatten()
.map(|(v,)| v)
/// How long a loaded snapshot is trusted before the next read reloads it. This is a
/// backstop for out-of-band DB changes only — every in-process write invalidates the
/// cache synchronously, so tests that PATCH-then-assert never depend on this expiring.
const RELOAD_TTL: Duration = Duration::from_secs(30);
struct Snapshot {
values: HashMap<String, String>,
loaded_at: Instant,
}
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String {
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string())
/// In-memory cache of the entire `config` table. Cheap to `clone` (shares the pool and
/// the `Arc`), so it lives in `AppState` and every handler reads through it.
#[derive(Clone)]
pub struct ConfigCache {
pool: PgPool,
inner: Arc<RwLock<Option<Snapshot>>>,
}
pub async fn get_i64(pool: &PgPool, key: &str, default: i64) -> i64 {
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
impl ConfigCache {
pub fn new(pool: PgPool) -> Self {
Self {
pool,
inner: Arc::new(RwLock::new(None)),
}
}
/// Drop the cached snapshot so the next read reloads the whole table from the DB.
/// Call this after any write to the `config` table (admin PATCH, test reseed).
pub fn invalidate(&self) {
*self.inner.write().unwrap() = None;
}
/// Return the fresh snapshot if one is loaded and still within [`RELOAD_TTL`].
fn fresh_snapshot(&self) -> Option<HashMap<String, String>> {
let guard = self.inner.read().unwrap();
match guard.as_ref() {
Some(snap) if snap.loaded_at.elapsed() < RELOAD_TTL => Some(snap.values.clone()),
_ => None,
}
}
/// Read one key, loading the whole table into the cache on a miss/expiry. On a DB
/// error we return `None` (callers fall back to their default) without poisoning
/// the cache.
async fn get_raw(&self, key: &str) -> Option<String> {
if let Some(values) = self.fresh_snapshot() {
return values.get(key).cloned();
}
// Cache miss or stale — reload the entire table in one query.
let rows: Vec<(String, String)> =
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config")
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
return None;
}
};
let values: HashMap<String, String> = rows.into_iter().collect();
let result = values.get(key).cloned();
*self.inner.write().unwrap() = Some(Snapshot {
values,
loaded_at: Instant::now(),
});
result
}
}
pub async fn get_usize(pool: &PgPool, key: &str, default: usize) -> usize {
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
cache.get_raw(key).await.unwrap_or_else(|| default.to_string())
}
pub async fn get_f64(pool: &PgPool, key: &str, default: f64) -> f64 {
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
}
pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
}
pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// Parses common truthy spellings used by both the migration seeds and the admin form.
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
/// returns `default`.
pub async fn get_bool(pool: &PgPool, key: &str, default: bool) -> bool {
let Some(raw) = fetch_raw(pool, key).await else { return default };
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
let Some(raw) = cache.get_raw(key).await else { return default };
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,

View File

@@ -0,0 +1,145 @@
//! Cached view of the filesystem backing the media directory.
//!
//! Free/total disk space is needed on two hot paths — the per-user storage quota
//! (checked on every upload *and* every `GET /me/quota` poll) and the admin stats
//! endpoint. Reading it means `sysinfo::Disks::new_with_refreshed_list()`, which stats
//! every mounted filesystem; doing that per request is wasteful for a number that
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
//! rest from memory.
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// How long a disk reading is trusted before the next call re-stats the filesystem.
const TTL: Duration = Duration::from_secs(15);
#[derive(Clone, Copy)]
pub struct DiskInfo {
pub total: u64,
pub free: u64,
}
/// Cheap-to-clone cache of the media filesystem's total/free bytes. Lives in
/// `AppState`.
#[derive(Clone)]
pub struct DiskCache {
inner: Arc<RwLock<Option<(DiskInfo, Instant)>>>,
}
impl DiskCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
}
}
/// Cached `(total, free)` bytes for the filesystem that holds `media_path`.
///
/// Returns `None` when the mount can't be resolved — callers MUST treat that as
/// "unknown", never "zero free". (The quota path in particular fails *open* on
/// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
pub fn snapshot(&self, media_path: &Path) -> Option<DiskInfo> {
if let Some((info, at)) = *self.inner.read().unwrap() {
if at.elapsed() < TTL {
return Some(info);
}
}
let info = read_disk_for_path(media_path)?;
*self.inner.write().unwrap() = Some((info, Instant::now()));
Some(info)
}
}
impl Default for DiskCache {
fn default() -> Self {
Self::new()
}
}
/// Resolve the filesystem backing `media_path` and read its total/free bytes.
///
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
/// [`select_disk`] so the (fiddly, edge-case-prone) matching logic is unit-testable
/// without touching the real filesystem.
fn read_disk_for_path(media_path: &Path) -> Option<DiskInfo> {
let disks = sysinfo::Disks::new_with_refreshed_list();
let mounts: Vec<(String, u64, u64)> = disks
.iter()
.map(|d| {
(
d.mount_point().to_string_lossy().to_string(),
d.total_space(),
d.available_space(),
)
})
.collect();
select_disk(&mounts, &media_path.to_string_lossy())
}
/// Pick the filesystem for `media_path` from a `(mount_point, total, free)` table.
///
/// Chooses the **longest** mount point that is a prefix of `media_path` (the most
/// specific filesystem) rather than the first match — otherwise the root `/` mount,
/// which prefixes every absolute path, could shadow a dedicated `/media` volume. Falls
/// back to `/` when nothing prefixes the path (e.g. a relative media path), and to
/// `None` when even that is absent — the caller treats `None` as "unknown" and fails
/// open on quota.
fn select_disk(mounts: &[(String, u64, u64)], media_path: &str) -> Option<DiskInfo> {
mounts
.iter()
.filter(|(mp, _, _)| media_path.starts_with(mp.as_str()))
.max_by_key(|(mp, _, _)| mp.len())
.or_else(|| mounts.iter().find(|(mp, _, _)| mp == "/"))
.map(|(_, total, free)| DiskInfo {
total: *total,
free: *free,
})
}
#[cfg(test)]
mod tests {
use super::select_disk;
#[test]
fn picks_longest_matching_mount() {
// Both "/" and "/media" prefix the path; the dedicated volume must win.
let mounts = vec![
("/".to_string(), 100, 40),
("/media".to_string(), 200, 150),
];
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
assert_eq!((d.total, d.free), (200, 150));
}
#[test]
fn falls_back_to_root_when_no_specific_mount_matches() {
let mounts = vec![
("/".to_string(), 100, 40),
("/media".to_string(), 200, 150),
];
// "/var/lib" is only prefixed by "/".
let d = select_disk(&mounts, "/var/lib/data").unwrap();
assert_eq!((d.total, d.free), (100, 40));
}
#[test]
fn relative_path_uses_root_fallback() {
let mounts = vec![("/".to_string(), 100, 40)];
// A relative path prefixes nothing, so the explicit "/" fallback applies.
let d = select_disk(&mounts, "media/originals").unwrap();
assert_eq!((d.total, d.free), (100, 40));
}
#[test]
fn none_when_no_mount_matches_and_no_root() {
// No "/" present and nothing prefixes the relative path → unknown (fail-open).
let mounts = vec![("/data".to_string(), 100, 40)];
assert!(select_disk(&mounts, "relative/path").is_none());
}
#[test]
fn none_on_empty_mount_table() {
assert!(select_disk(&[], "/media/x").is_none());
}
}

View File

@@ -82,20 +82,107 @@ struct ViewerMedia {
// ── Entry point ──────────────────────────────────────────────────────────────
/// (Re)enqueue the export jobs for an event and spawn the workers. Safe to call on a
/// fresh release *and* on a re-release: the `export_job` rows are reset to a clean
/// `pending` state (clearing any prior `failed`/`done` from an earlier run) and the
/// event's `export_*_ready` flags are cleared so downloads reflect the regenerating
/// export rather than the stale one. This is the single path used by `release_gallery`
/// and by startup export recovery, so the two can't drift.
pub async fn enqueue_and_spawn_exports(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
export_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) -> Result<()> {
for export_type in ["zip", "html"] {
// Reset a prior 'done'/'failed' row to 'pending' so this (re)release regenerates —
// but DO NOT touch a row that's still 'running'. If a worker from an earlier
// release is mid-export, leaving it 'running' makes the worker we spawn below bail
// its claim (WHERE status='pending' → 0 rows), so the two never race the temp file.
sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct)
VALUES ($1, $2::export_type, 'pending', 0)
ON CONFLICT (event_id, type) DO UPDATE
SET status = 'pending', progress_pct = 0, file_path = NULL,
error_message = NULL, completed_at = NULL
WHERE export_job.status <> 'running'",
)
.bind(event_id)
.bind(export_type)
.execute(&pool)
.await?;
}
// Clear readiness so /export downloads 404 until the fresh run completes, instead of
// serving a stale keepsake that predates a reopen→re-release.
sqlx::query("UPDATE event SET export_zip_ready = FALSE, export_html_ready = FALSE WHERE id = $1")
.bind(event_id)
.execute(&pool)
.await?;
spawn_export_jobs(event_id, event_name, pool, media_path, export_path, sse_tx);
Ok(())
}
/// Startup export recovery: re-spawn exports for any released event whose keepsake is
/// not fully ready (a crash mid-export left `export_released_at` set but the ZIP/HTML
/// jobs `failed`). Without this, `release_gallery` would reject a retry with "bereits
/// freigegeben" and downloads would 404 forever. Runs once at boot, after `AppState`
/// exists (it needs the media/export paths + SSE sender).
pub async fn recover_exports(
pool: PgPool,
media_path: PathBuf,
export_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let rows = match sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, name FROM event
WHERE export_released_at IS NOT NULL
AND NOT (export_zip_ready AND export_html_ready)",
)
.fetch_all(&pool)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("export recovery: failed to query released events: {e:#}");
return;
}
};
for (event_id, event_name) in rows {
tracing::warn!("export recovery: re-spawning export jobs for event {event_id}");
if let Err(e) = enqueue_and_spawn_exports(
event_id,
event_name,
pool.clone(),
media_path.clone(),
export_path.clone(),
sse_tx.clone(),
)
.await
{
tracing::error!("export recovery: failed to re-spawn for event {event_id}: {e:#}");
}
}
}
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
export_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let pool2 = pool.clone();
let media_path2 = media_path.clone();
let export_path2 = export_path.clone();
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &export_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
@@ -104,7 +191,7 @@ pub fn spawn_export_jobs(
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
run_html_export(event_id, &event_name2, &pool2, &media_path2, &export_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
@@ -119,14 +206,19 @@ async fn run_zip_export(
event_id: Uuid,
pool: &PgPool,
media_path: &Path,
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
if !claim_job(pool, event_id, "zip").await {
// Another worker already owns this ZIP export — bail rather than race the temp file.
return Ok(());
}
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
// Written OUTSIDE media_path: the public /media ServeDir must never reach these.
let exports_dir = export_path.to_path_buf();
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
@@ -188,14 +280,35 @@ async fn run_zip_export(
// ── HTML viewer export ──────────────────────────────────────────────────────
/// Where a media entry's bytes come from at ZIP-writing time. Derived variants
/// (thumbnails, compressed images) are staged under the temp dir; original-fidelity
/// variants (videos, small images) are streamed straight from the source on disk so
/// the export never transiently doubles disk usage by copying large files to temp.
enum MediaSource {
Temp(PathBuf),
Original(PathBuf),
}
impl MediaSource {
fn path(&self) -> &Path {
match self {
MediaSource::Temp(p) | MediaSource::Original(p) => p,
}
}
}
async fn run_html_export(
event_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
if !claim_job(pool, event_id, "html").await {
// Another worker already owns this HTML export — bail rather than race the temp dir.
return Ok(());
}
// 1. Query data
let uploads = query_uploads(pool, event_id).await?;
@@ -205,7 +318,8 @@ async fn run_html_export(
update_progress(pool, event_id, "html", 5).await;
let exports_dir = media_path.join("exports");
// Written OUTSIDE media_path: the public /media ServeDir must never reach these.
let exports_dir = export_path.to_path_buf();
tokio::fs::create_dir_all(&exports_dir).await?;
// 2. Create temp directory for media processing
@@ -215,6 +329,9 @@ async fn run_html_export(
// 3. Process media and build post data
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();
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
@@ -225,8 +342,10 @@ async fn run_html_export(
let is_video = row.mime_type.starts_with("video/");
let id_str = row.id.to_string();
// Generate thumbnail and full variant
let (thumb_name, full_name) = if is_video {
// Generate thumbnail and full variant. `full_source` says where the full-res
// bytes come from at ZIP time — for videos and small images that's the original
// on disk (streamed directly, never copied to temp).
let (thumb_name, full_name, full_source) = if is_video {
let thumb = format!("{id_str}_thumb.jpg");
let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}");
@@ -253,14 +372,13 @@ async fn run_html_export(
Ok(output) if output.status.success() => {}
_ => {
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
// Create empty thumb entry — viewer handles missing thumbs gracefully
// Missing thumb entry — viewer handles missing thumbs gracefully.
}
}
// Copy video as-is
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
(thumb, full)
// Stream the video full-res straight from the original at ZIP time — no
// copy to temp (that used to transiently double disk usage per video).
(thumb, full, MediaSource::Original(src.clone()))
} else {
let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path);
@@ -285,13 +403,12 @@ async fn run_html_export(
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
}
// Full variant: compress if >5MB, otherwise copy original
// Full variant: compress to temp if >5MB, otherwise stream the original
// as-is (no temp copy).
let src_meta = tokio::fs::metadata(&src).await?;
let full_path = media_tmp.join(&full);
if src_meta.len() > 5_000_000 {
// Resize to max 2000px
let full_source = if src_meta.len() > 5_000_000 {
let src_clone = src.clone();
let full_path = media_tmp.join(&full);
let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
@@ -305,17 +422,31 @@ async fn run_html_export(
})
.await?;
if let Err(e) = compress_result {
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
tokio::fs::copy(&src, &full_path).await?;
match compress_result {
Ok(()) => MediaSource::Temp(full_path),
Err(e) => {
tracing::warn!(
"compression failed for upload {}, using original: {e:#}",
row.id
);
MediaSource::Original(src.clone())
}
}
} else {
tokio::fs::copy(&src, &full_path).await?;
}
MediaSource::Original(src.clone())
};
(thumb, full)
(thumb, full, full_source)
};
// Register this post's two media entries. Thumbnails always come from temp
// (they're freshly generated); the full variant's source was decided above.
media_manifest.push((
thumb_name.clone(),
MediaSource::Temp(media_tmp.join(&thumb_name)),
));
media_manifest.push((full_name.clone(), full_source));
// Build comments for this upload
let post_comments: Vec<ViewerComment> = comments
.iter()
@@ -403,27 +534,23 @@ async fn run_html_export(
update_progress(pool, event_id, "html", 78).await;
// Write media files from temp directory
let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
let mut file_count = 0u32;
// Write media files from the manifest built in step 3. Thumbnails and derived
// image variants stream from temp; video and small-image full variants stream
// straight from the original on disk. Sources that don't exist (e.g. a thumb
// 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;
// Count files first
{
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
while counter.next_entry().await?.is_some() {
file_count += 1;
for (name, source) in &media_manifest {
let path = source.path();
if !path.exists() {
continue;
}
}
let file_total = file_count.max(1) as f32;
while let Some(dir_entry) = media_entries.next_entry().await? {
let filename = dir_entry.file_name();
let entry_name = format!("media/{}", filename.to_string_lossy());
let entry_name = format!("media/{name}");
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut zip_entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
let mut f = tokio::fs::File::open(path).await?.compat();
fcopy(&mut f, &mut zip_entry).await?;
zip_entry.close().await?;
@@ -475,7 +602,8 @@ async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUpload
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.id
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
WHERE u.event_id = $1 AND u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
@@ -512,14 +640,24 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
Ok(rows)
}
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
/// Atomically claim a pending export job for this worker. Returns `true` only if we won
/// (status flipped `pending`→`running` in one statement). Returns `false` when another
/// worker already owns it — e.g. a reopen→re-release spawned a second worker while a run
/// from the first release is still in flight. Both write the SAME fixed temp path
/// (`Gallery.zip.tmp` / `viewer_tmp_*`), so a loser MUST NOT run or it would interleave
/// bytes and corrupt the archive. The row-level lock serializes the two UPDATEs, so
/// exactly one sees `status = 'pending'`.
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
sqlx::query(
"UPDATE export_job SET status = 'running'
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
.await
.map(|r| r.rows_affected() > 0)
.unwrap_or(false)
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {

View File

@@ -44,8 +44,11 @@ pub async fn startup_recovery(pool: &PgPool) {
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
}
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
// Export jobs interrupted mid-run are marked 'failed' here so they aren't left
// 'running' forever. The host CANNOT re-trigger a released export (release_gallery
// rejects an already-released event), so `export::recover_exports` re-spawns these
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
// paths + SSE sender this fn doesn't have).
match sqlx::query(
"UPDATE export_job
SET status = 'failed',

View File

@@ -1,5 +1,6 @@
pub mod compression;
pub mod config;
pub mod disk;
pub mod export;
pub mod jobs;
pub mod maintenance;

View File

@@ -71,13 +71,110 @@ impl RateLimiter {
}
}
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
/// to a provided socket address string.
/// Extract the client IP from X-Forwarded-For or fall back to a provided socket
/// address string.
///
/// We take the **right-most** entry, not the left-most. Caddy is the sole ingress
/// and the app port is only `expose`d (never published), so the last hop Caddy
/// appends is the real client. A client can prepend arbitrary spoofed values to
/// the left of XFF to dodge throttles — those are ignored here. This assumes
/// exactly one trusted proxy (Caddy); revisit if that changes.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.rsplit(',').next())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
const MIN: Duration = Duration::from_secs(60);
#[test]
fn allows_up_to_max_then_blocks() {
let rl = RateLimiter::new();
assert!(rl.check("k", 3, MIN));
assert!(rl.check("k", 3, MIN));
assert!(rl.check("k", 3, MIN));
assert!(!rl.check("k", 3, MIN), "the 4th request must be blocked");
}
#[test]
fn keys_are_independent() {
let rl = RateLimiter::new();
assert!(rl.check("a", 1, MIN));
assert!(!rl.check("a", 1, MIN));
assert!(rl.check("b", 1, MIN), "a different key has its own window");
}
#[test]
fn window_slides_and_allows_again_after_expiry() {
let rl = RateLimiter::new();
let w = Duration::from_millis(40);
assert!(rl.check("k", 1, w));
assert!(!rl.check("k", 1, w));
std::thread::sleep(Duration::from_millis(55));
assert!(rl.check("k", 1, w), "the slot should expire once the window passes");
}
#[test]
fn retry_after_is_between_one_and_window() {
let rl = RateLimiter::new();
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
let retry = rl.check_with_retry("k", 1, MIN).unwrap_err();
assert!((1..=60).contains(&retry), "retry_after {retry} out of range");
}
#[test]
fn clear_resets_every_window() {
let rl = RateLimiter::new();
assert!(rl.check("k", 1, MIN));
assert!(!rl.check("k", 1, MIN));
rl.clear();
assert!(rl.check("k", 1, MIN), "clear() must free the window");
}
#[test]
fn client_ip_takes_rightmost_forwarded_for_entry() {
// The right-most entry is the hop our trusted proxy (Caddy) appended.
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "10.0.0.1, 203.0.113.7".parse().unwrap());
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
}
#[test]
fn client_ip_ignores_spoofed_leftmost_entry() {
// A client prepending a fake IP to dodge throttles must not win.
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap());
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
}
#[test]
fn client_ip_trims_surrounding_whitespace() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", " 198.51.100.5 ".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "198.51.100.5");
}
#[test]
fn client_ip_falls_back_when_header_absent() {
assert_eq!(client_ip(&HeaderMap::new(), "127.0.0.1"), "127.0.0.1");
}
#[test]
fn client_ip_falls_back_on_trailing_comma_empty_entry() {
// A trailing comma leaves an empty right-most segment after trimming; the
// `.filter(!is_empty)` must reject it and fall through to the fallback
// rather than returning "" (which would collapse callers into one bucket).
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "203.0.113.7, ".parse().unwrap());
assert_eq!(client_ip(&h, "127.0.0.1"), "127.0.0.1");
}
}

View File

@@ -72,3 +72,58 @@ fn random_ticket() -> String {
rng.fill(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_then_consume_returns_the_hash_exactly_once() {
let store = SseTicketStore::new();
let ticket = store.issue("hash-1".into());
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
// Single-use: a replay of the same ticket is rejected.
assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable");
}
#[test]
fn unknown_ticket_consumes_to_none() {
let store = SseTicketStore::new();
assert_eq!(store.consume("never-issued"), None);
}
#[test]
fn issued_tickets_are_unique_and_hex() {
let store = SseTicketStore::new();
let a = store.issue("h".into());
let b = store.issue("h".into());
assert_ne!(a, b, "each ticket must be unique");
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn fresh_ticket_survives_prune() {
let store = SseTicketStore::new();
let ticket = store.issue("h".into());
store.prune(); // not expired → kept
assert_eq!(store.consume(&ticket).as_deref(), Some("h"));
}
#[test]
fn expired_ticket_consumes_to_none() {
// Construct an entry that is already past the TTL and confirm consume() rejects it.
let store = SseTicketStore::new();
let stale = "stale-ticket".to_string();
store.inner.lock().unwrap().insert(
stale.clone(),
Entry {
token_hash: "h".into(),
issued_at: Instant::now()
.checked_sub(TTL + Duration::from_secs(1))
.expect("host uptime should exceed the ticket TTL"),
},
);
assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate");
}
}

View File

@@ -3,6 +3,8 @@ use tokio::sync::broadcast;
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
use crate::services::config::ConfigCache;
use crate::services::disk::DiskCache;
use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore;
@@ -31,13 +33,27 @@ pub struct AppState {
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore,
/// In-memory cache in front of the `config` table. Reads go through here; the
/// admin PATCH handler and the test reseed invalidate it after committing.
pub config_cache: ConfigCache,
/// Cached total/free bytes for the media filesystem (quota + admin stats).
pub disk_cache: DiskCache,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256);
let compression =
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone());
// Broadcast buffer for live SSE fan-out. Sized to absorb a burst (e.g. many
// uploads landing at once during a busy moment) before a slow consumer lags and
// has to `resync`. The resync path is a correctness backstop, not the happy path —
// a roomier buffer keeps ~1000 concurrent clients from all resyncing at once.
let (sse_tx, _) = broadcast::channel(1024);
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),
config.compression_concurrency,
sse_tx.clone(),
);
let config_cache = ConfigCache::new(pool.clone());
Self {
pool,
config,
@@ -45,6 +61,8 @@ impl AppState {
compression,
rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(),
config_cache,
disk_cache: DiskCache::new(),
}
}
}

13
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,13 @@
# Dev-only overlay. NOT loaded automatically (unlike docker-compose.override.yml).
# Opt in explicitly for local development when you need host access to Postgres:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Never use this overlay in production — it publishes the database port on the host.
services:
db:
ports:
- "5432:5432"
app:
# Relax the production secret guard for local dev — the dev sentinel JWT_SECRET
# is tolerated (warned) rather than rejected.
environment:
APP_ENV: development

View File

@@ -1,4 +0,0 @@
services:
db:
ports:
- "5432:5432"

View File

@@ -14,6 +14,10 @@ services:
interval: 5s
timeout: 5s
retries: 10
deploy:
resources:
limits:
memory: 512M
app:
build:
@@ -21,13 +25,32 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
environment:
# Activates the production secret guard in config.rs — refuses to boot with
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
APP_ENV: production
depends_on:
db:
condition: service_healthy
volumes:
- media_data:/media
# Export archives live OUTSIDE /media so the public media ServeDir can't
# serve them — downloads go only through the ticket-gated handler.
- exports_data:/exports
expose:
- "3000"
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
# OOM the single box and take down Postgres.
memory: 1G
frontend:
build:
@@ -35,10 +58,24 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
environment:
# adapter-node behind Caddy TLS needs the public origin for CSRF checks on
# POST form actions — without it they fail only in production.
ORIGIN: "https://${DOMAIN}"
depends_on:
- app
expose:
- "3001"
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:3001/ >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
deploy:
resources:
limits:
memory: 256M
caddy:
image: caddy:2-alpine
@@ -50,10 +87,17 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- app
- frontend
app:
condition: service_healthy
frontend:
condition: service_healthy
deploy:
resources:
limits:
memory: 256M
volumes:
postgres_data:
media_data:
exports_data:
caddy_data:

View File

@@ -0,0 +1,79 @@
# Decision: media serving — unauthenticated UUID vs. signed gateway
**Status:** OPEN — needs a call. Written 2026-06-30 from the 2026-06-27 audit
(`fix/audit-2026-06-27-critical-medium`), which implemented the signed-gateway option that
`main` did not adopt.
**Scope:** how `main` serves uploaded photos/videos (originals + previews/thumbnails) to the
`<img>`/`<video>` tags in the feed, lightbox, and diashow.
---
## The two models
### A. Current `main` — unauthenticated, UUID-as-capability
- `/api/v1/upload/{id}/original`**no auth**; the unguessable upload UUID *is* the capability.
(Documented as intentional in `frontend/src/lib/data-mode-store.ts`.)
- `/media/*` — static `axum` `ServeDir`, **no auth layer**, serves preview/thumbnail files by path.
- No expiry, no signature, no per-request authorization on the bytes.
- **Works with** plain `<img src>` (no Authorization header needed) and browser caching, at zero
per-request backend cost.
### B. Audit branch — authenticated signed gateway
- `/media/{kind}/{id}?sig=…` via `handlers::media::serve`.
- HMAC signature (keyed off `jwt_secret`), **time-boxed** (~24 h, bucketed to a 1 h
URL-stability window so warm fetches stay cacheable).
- Authorizes by **signature + uploader visibility**, not requester identity.
- Still `<img>`-compatible (capability rides in the query string, not a header).
- Cost: token mint/verify, ~2 DB queries per *cold* fetch, and the HMAC key currently reuses
`jwt_secret` (see "Media HMAC domain separation" in [SECURITY-BACKLOG.md](SECURITY-BACKLOG.md)).
---
## What actually differs (the tradeoff)
| | A. UUID (main) | B. Signed gateway (audit) |
|---|---|---|
| Leaked URL/UUID (forwarded link, browser history, referer, logs) | **Permanent** full-res access | Access **expires** (~24 h); URL can't be re-minted |
| Banned / departed guest | Retains **permanent** access to every original whose UUID they hold | Can't mint new URLs; can replay **held** URLs ≤ TTL only |
| Revocation | None (UUID is forever) | Rotate the signing key → all outstanding URLs die |
| Enumeration | Mitigated by UUIDv4 unguessability | Same, plus signature |
| Per-request cost | Zero (static serve) | Token verify + ~2 DB queries (cold) |
| Plumbing / failure surface | Minimal | Token mint/verify, key mgmt, cache-bucket logic |
Neither model solves the fundamental `<img>`-can't-send-a-Bearer constraint: once a browser holds
a media URL, it is replayable for that URL's lifetime. Model B simply **bounds that lifetime** and
**adds revocability**; Model A's lifetime is *forever*.
## Threat model (this deployment)
Private single-box event app, ~100 guests, ~1 000 files, one event at a time. Media is **shared
with all guests by design** — it is personal but not secret *within* the event. The real risk is
**a URL escaping the event boundary** (a guest forwards a link; it lands in chat history, a public
post, or server/proxy logs) granting an outsider — or a removed guest — access.
## Recommendation
This is a judgment call, not a clear-cut bug, so it's yours to make. My leaning:
- **Adopt Model B (signed gateway)** if post-event link leakage or removed-guest access is a real
concern for you — it's the materially stronger posture (bounded exposure + key-rotation
revocation) and the implementation already exists on the audit branch. If adopted, also do the
cheap **HKDF domain-separation** for the HMAC key (backlog 🅱).
- **Keep Model A (UUID)** as an *explicitly accepted risk* if you're comfortable that a leaked
link = permanent access at this scale. If so, two cheap hardening steps are still worth doing:
1. **Log hygiene** — ensure the upload UUID never lands in access logs with enough context to
correlate (the `TraceLayer` logs the request *path*, and `/api/v1/upload/{id}/original`
puts the UUID *in the path*). Confirm logs aren't shipped/retained where that matters.
2. **Document the decision** in `README`/`PROJECT.md` so "unauthenticated media" is a recorded
choice, not an oversight.
**Sharpest single fact to decide on:** in Model A, a guest you *ban* keeps full-resolution access
to every original they ever loaded, forever. In Model B, that access dies within ~24 h. If that
asymmetry matters for your events, adopt the gateway.
## If you adopt B
The implementation lives on `origin/fix/audit-2026-06-27-critical-medium`:
`backend/src/handlers/media.rs` (+ `media_token`), the `/media/{kind}/{id}` route in `main.rs`, and
the feed/upload/host changes that emit signed URLs (`models/upload.rs`, `handlers/feed.rs`). It
would need re-basing onto current `main` (which has since diverged across ~80 files).

107
docs/SECURITY-BACKLOG.md Normal file
View File

@@ -0,0 +1,107 @@
# Security & Hardening Backlog
Tracks the deliberately-deferred items from the 2026-06-27 security audit and its review passes.
**Provenance & reconciliation.** This document was extracted from the
`fix/audit-2026-06-27-critical-medium` branch and reconciled against `main` on 2026-06-30.
That audit's Critical→Medium findings were **largely re-implemented into `main`** through the
later batch branches (security-review-followups, security-review-batch-2, the UX batches) rather
than by merging the audit branch — so `git` shows no merge, but the controls are present. Each
item below is tagged with its **current status in `main`**:
-**Done in main** — addressed (possibly via a different implementation).
-**Open** — still applies to `main`.
- 🔀 **Contingent** — only relevant if `main` adopts the audit's signed-media gateway (see
[DECISION-media-auth.md](DECISION-media-auth.md)).
> The audit branch additionally implemented an **authenticated, signed media gateway** that `main`
> did **not** adopt — `main` serves media unauthenticated (static `ServeDir` + UUID-capability
> `/api/v1/upload/{id}/original`). That architectural choice is written up separately in
> [DECISION-media-auth.md](DECISION-media-auth.md); the "by-design notes" at the bottom of this
> file describe the *audit branch's* model and apply to `main` only if that gateway is adopted.
---
## 🅱 Worth a tracked ticket (real, not one-liners)
-**Moderation UI gap** — the backend `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}`
endpoints have **no frontend caller**, so a host cannot remove a *guest's* content from the UI
(the feed `ContextSheet` only offers delete for the viewer's *own* uploads —
`target.user_id === myUserId`). Still a functional hole in `main`. Needs a host-facing "remove"
action wired to those endpoints, gated on host/admin role.
- ✅/⬜ **Feed reactivity***Mostly fixed in `main`.* The full-reload-on-every-SSE-event problem
(a single `like-update`/`new-comment`/`upload-processed` calling `loadFeed(true)` and collapsing
a scrolled feed) was fixed in the UX batch: `main` now patches the affected card in place
(`patchCount`) and debounces processing (`scheduleInPlaceRefresh`). ⬜ **Remaining sub-item:**
owner-deleted uploads are still not broadcast — `delete_upload` returns `204` with no
`upload-deleted` SSE, so other clients keep showing a deleted post until refresh (only host
deletes broadcast). Emit `upload-deleted` from the owner delete path too.
- 🔀 **Media HMAC domain separation***Only applies if the signed-media gateway is adopted.* The
audit's signed-media tokens reuse `jwt_secret` as the HMAC key; a dedicated derived key
(`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't
weaken the other. N/A to `main` as it stands (no signed media).
-**Quota mount-detection + low-disk guard***Still applies to `main`.* `compute_storage_quota`
(`backend/src/handlers/upload.rs`) and `admin.rs` pick the disk via `starts_with`, so root (`/`)
is a wildcard prefix that can match the wrong device; and there's no hard min-free-space
precheck when the quota is disabled. Use longest-prefix match; add an unconditional 507/429 when
free space is critically low.
## ✅ Fixed in main since the audit (for the record)
These audit findings are present in `main` today (verified 2026-06-30): server-side MIME/ext
allowlist on upload, recovery-PIN lockout backoff, DB port no longer publicly exposed, bcrypt
offloaded via `spawn_blocking`, bounded compression concurrency (semaphore), bounded feed queries
(`LIMIT ≤ 100`), and the viewport-fit / reduced-motion / aria a11y pass. An image-decode
decompression-bomb cap (`image::Limits` 12000×12000 / 256 MiB) lives in
`backend/src/services/compression.rs`.
**Fixed in the 2026-07 review pass** (previously *claimed* fixed here but were not — corrected):
- **Effective JWT production-secret guard** — `APP_ENV=production` is now set for the app service,
and `config.rs::validate_secrets` rejects any placeholder-ish `JWT_SECRET`/`ADMIN_PASSWORD_HASH`
(not just the exact dev sentinel) and enforces `len ≥ 32` unconditionally in prod.
- **Unspoofable client IP in the rate limiter** — `client_ip` now takes the right-most
`X-Forwarded-For` entry (the hop Caddy appends), so a client-supplied left-most value is ignored.
- **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB `role`
and `is_banned` flag rather than the JWT claim, so a demote/ban takes effect immediately (no
30-day token window). Bans are enforced on write handlers + the host/admin extractors, preserving
the documented read-only access for banned guests (USER_JOURNEYS §10); demoted hosts lose host
powers at once.
- **Container healthchecks** — `app` and `frontend` now have healthchecks and Caddy waits on
`service_healthy`.
- **Event-scoped `ban_user`** — the ban UPDATE is now scoped by `event_id` like its siblings.
## 🅲 Consciously won't-fix at ~100-guest single-box scale
Diminishing returns vs. the deployment's actual threat model. Revisit only if the scale or
tenancy model changes.
- Rate-limiter `HashMap` key LRU/cap (attacker-chosen `recover:{ip}:{name}` keys accumulate up to
the 24h prune ceiling) — bounded and pruned; not worth an LRU.
- "Last host" / host↔host role-churn guard — operational, low blast radius.
- Performance micro-indexes (`idx_like_user_upload`, comment pagination index) — current queries
are sub-ms at this row count.
- Optimistic-like in-flight guard, ownership-snapshot-at-mount, assorted copy tweaks — UX polish.
- **Mid-session ban does not tear down an already-open SSE stream.** The live-ban check lives in the
`AuthUser` extractor, but the stream authenticates via ticket→session (`handlers/sse.rs::stream`),
so a user banned while connected keeps receiving broadcast events until their stream drops. New
tickets *are* blocked (`issue_ticket` uses `AuthUser`), so they cannot reconnect. Low blast radius
(read-only feed events, no re-subscribe); tearing live streams down would need a per-session
broadcast filter. Revisit only if bans must take effect within seconds.
## By-design notes (audit branch's signed-media model — see DECISION-media-auth.md)
> These describe the **audit branch's** signed-gateway model. `main` does **not** serve media this
> way; for `main`'s actual media-access posture and the tradeoff, see
> [DECISION-media-auth.md](DECISION-media-auth.md).
- **Banned user retains ≤24h media access via already-held signed URLs.** The audit's media gateway
authorizes by *signature + uploader visibility*, not requester identity, and signed URLs are
time-boxed (24h, bucketed). A banned/revoked-session user cannot mint new URLs (every API call
401/403s) but can replay URLs they already hold until expiry — only for content they already saw.
Accepted there: tightening would require per-request identity on every `<img>` load, which the
`<img>`-can't-send-a-Bearer constraint precludes.
- **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser
caching and the stable bucketed URL. Could be one JOIN if it ever shows up in profiling.

View File

@@ -129,9 +129,9 @@ the Host can clean up later).
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
`export-progress` keeps it live; `export-available` notifies all guests when ready.
5. **Nutzerverwaltung** — search users; per-user controls:
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
calls `POST /host/users/{id}/ban` (no body).
- **Entsperren** lifts the ban.
- **Host** promotes a guest to host.
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
@@ -147,12 +147,22 @@ the Host can clean up later).
## 10. Banned-guest experience
1. The banned user's next authenticated request returns HTTP 403 with a clear message
("Du bist gesperrt.").
2. They can still browse the read-only feed (and download the export once it's released).
3. They cannot upload, like, or comment.
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
feed for everyone (the `v_feed` view already enforces this).
1. The ban takes effect immediately on the banned user's existing session — the auth
layer re-reads the live `is_banned` flag on every request rather than trusting the
JWT, so there's no 30-day token-lifetime window.
2. Their next authenticated *write* request returns HTTP 403 with a clear message
("Du bist gesperrt."). Ban enforcement lives on the write handlers and the
host/admin extractors, not the base auth extractor.
3. They can still browse the read-only feed (and download the export once it's released).
Their sessions are **not** revoked — this is a read-only ban, not a logout. Their own
live SSE stream is dropped by the `is_banned` revalidation (no live pushes), but plain
feed reads still work.
4. They cannot upload, like, or comment; a banned host also loses all host/admin
actions (including unbanning themselves).
5. Banning **always hides**: the user's existing uploads are filtered out of the feed for
everyone (`v_feed`, `find_visible_media`, and the export query all enforce
`is_banned = FALSE`), and a live `user-hidden` SSE event evicts their cards from every
open feed + the diashow without a reload.
## 11. Admin — instance configuration

View File

@@ -4,7 +4,17 @@
# of HTTPS/Let's Encrypt.
:3101 {
encode zstd gzip
# Mirror prod: exclude the SSE stream from compression so buffering doesn't
# delay real-time events (and so the test stack exercises the real behavior).
@compressible not path /api/v1/stream
encode @compressible zstd gzip
# Mirror prod's security headers (minus HSTS, which is HTTPS-only).
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000

View File

@@ -117,6 +117,25 @@ export class ApiClient {
});
}
async unbanUser(token: string, userId: string, opts: { expectedStatus?: number | number[] } = {}) {
return this.request<void>('POST', `/host/users/${userId}/unban`, {
token,
expectedStatus: opts.expectedStatus ?? [200, 204],
});
}
/** Reset another user's PIN. Returns the plaintext PIN the host must relay once. */
async resetUserPin(
token: string,
userId: string,
opts: { expectedStatus?: number | number[] } = {}
): Promise<{ status: number; body: { pin?: string } }> {
return this.request<{ pin?: string }>('POST', `/host/users/${userId}/pin-reset`, {
token,
expectedStatus: opts.expectedStatus ?? [200],
});
}
async closeEvent(token: string) {
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
}

View File

@@ -68,6 +68,17 @@ export const db = {
);
},
/**
* Flip the `export_zip_ready` gate directly. The download handler serves bytes
* only when this boolean is true AND the file exists on disk, so setting it true
* without a file lets tests exercise the "ready but file missing" 404 branch.
*/
async setExportZipReady(slug: string, ready: boolean) {
await withClient((c) =>
c.query(`UPDATE event SET export_zip_ready = $2 WHERE slug = $1`, [slug, ready])
);
},
/** Insert a pre-baked export job row to skip the (slow) real compression path. */
async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') {
await withClient(async (c) => {

68
e2e/helpers/seed.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Shared seed helpers so specs don't each hand-roll the upload/comment create
* flow. Centralising the API contract (routes, field names, expected statuses)
* means an API change is a one-file edit, not a 7-file hunt.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { uploadRaw } from './upload-client';
import { db } from '../fixtures/db';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the
// backend when compression fails (a failed transcode refunds quota + deletes the
// row), which would delete the seeded upload out from under the test. Read lazily
// at call time (cwd is the e2e dir at runtime, like the gallery-path spec) so test
// collection can't trip on a module-load read.
let sampleJpg: Buffer | null = null;
function sampleImage(): Buffer {
if (!sampleJpg) {
sampleJpg = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
}
return sampleJpg;
}
export type SeedUploadOptions = {
caption?: string;
/** Mark compression done so the card is fully rendered in the feed. Default true. */
visible?: boolean;
};
/** Seed a real, accepted upload owned by `jwt` and return its id. */
export async function seedUpload(jwt: string, opts: SeedUploadOptions = {}): Promise<string> {
const res = await uploadRaw(jwt, sampleImage(), {
filename: 'a.jpg',
contentType: 'image/jpeg',
caption: opts.caption,
});
if (res.status !== 201) throw new Error(`seedUpload failed: ${res.status} ${await res.text()}`);
const { id } = await res.json();
if (opts.visible !== false) await db.setUploadCompressionStatus(id, 'done');
return id;
}
/** Seed a comment on `uploadId` authored by `jwt`; return its id. */
export async function seedComment(jwt: string, uploadId: string, body: string): Promise<string> {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (res.status !== 201) throw new Error(`seedComment failed: ${res.status} ${await res.text()}`);
return (await res.json()).id;
}
/** Read the comments for an upload as `jwt`. */
export async function listComments(jwt: string, uploadId: string): Promise<any[]> {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
headers: { Authorization: `Bearer ${jwt}` },
});
return res.json();
}
/** Resolve a single upload row from a feed response whose envelope shape isn't pinned. */
export function findFeedRow(feed: any, id: string): any {
const list: any[] = feed.uploads ?? feed.items ?? feed;
return Array.isArray(list) ? list.find((u: any) => u.id === id) : undefined;
}

View File

@@ -3,10 +3,13 @@
* `like-update` arrived for upload X within 5 seconds" without driving a
* second browser tab.
*
* The backend authenticates the SSE endpoint via `?token=` query param
* (the EventSource API can't set headers).
* The backend authenticates the SSE endpoint via a single-use `?ticket=` minted
* at POST /api/v1/stream/ticket (the raw JWT is never put in the URL). This helper
* does that exchange internally, so callers still just pass a JWT to `start()`.
*/
import { mintSseTicket } from './sse';
export type SseEvent = { type: string; data: any; receivedAt: number };
export class SseListener {
@@ -17,7 +20,10 @@ export class SseListener {
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
async start(token: string): Promise<void> {
const url = `${this.baseUrl}/api/v1/stream?token=${encodeURIComponent(token)}`;
// Exchange the JWT for a single-use SSE ticket (the stream endpoint no longer
// accepts ?token=).
const ticket = await mintSseTicket(token);
const url = `${this.baseUrl}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`;
// Use fetch with streaming since Node has no EventSource by default.
const res = await fetch(url, { signal: this.controller.signal });
if (!res.body) throw new Error('SSE response has no body');

43
e2e/helpers/sse.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Shared SSE-flow helpers. The stream auth flow (mint a single-use ticket, open
* with `?ticket=`) is security-sensitive and recently changed from `?token=`, so
* it lives in one place instead of being re-inlined per spec.
*/
import type { Page } from '@playwright/test';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
export async function mintSseTicket(jwt: string): Promise<string> {
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
if (res.status !== 200) throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
return (await res.json()).ticket;
}
/** Open the SSE stream with a ticket, return the HTTP status, and tear the stream down. */
export async function openStream(ticket: string): Promise<number> {
const c = new AbortController();
try {
const res = await fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
signal: c.signal,
});
return res.status;
} finally {
c.abort();
}
}
/**
* Count EventSource opens (GET /api/v1/stream?ticket=…) on a page — NOT the ticket
* POST. Returns a getter for the running count.
*/
export function trackStreamOpens(page: Page): () => number {
let n = 0;
page.on('request', (req) => {
if (req.method() === 'GET' && req.url().includes('/api/v1/stream?')) n++;
});
return () => n;
}

View File

@@ -21,7 +21,15 @@ export class RecoverPage {
async recover(name: string, pin: string) {
await this.nameInput.fill(name);
// Filling the 4th digit fires the form's auto-submit (the onPinInput handler
// calls handleRecover once pin.length === 4, see pin-auto-submit.spec). An explicit
// submit click would race the ensuing navigation and detach mid-click, so only click
// as a fallback if the button is still around (e.g. a partial / failed PIN).
await this.pinInput.fill(pin);
await this.submitButton.click();
if (await this.submitButton.isEnabled().catch(() => false)) {
await this.submitButton.click().catch(() => {
/* auto-submit already navigated — nothing to click */
});
}
}
}

View File

@@ -12,6 +12,12 @@ import { defineConfig, devices } from '@playwright/test';
* engine-level divergences. The rest of the suite only runs against
* `chromium-desktop` to keep the wall-clock reasonable.
*/
// camera/microphone/clipboard are Chromium-only permissions; passing them to
// firefox/webkit projects throws "Unknown permission: camera" and fails the
// whole test before it runs. Granted per-Chromium-project below instead of in
// the global `use` block.
const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboard-write'];
export default defineConfig({
testDir: './specs',
outputDir: './test-results',
@@ -35,9 +41,8 @@ export default defineConfig({
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
// Camera/mic permissions granted by default; the fake-media launch args
// (set per-project below for Chromium) supply the actual stream.
permissions: ['camera', 'microphone', 'clipboard-read', 'clipboard-write'],
// No camera/mic/clipboard here — those are Chromium-only and are granted on
// the Chromium projects below (see CHROMIUM_PERMISSIONS).
},
projects: [
@@ -49,6 +54,7 @@ export default defineConfig({
testIgnore: ['**/09-mobile/**'],
use: {
...devices['Desktop Chrome'],
permissions: CHROMIUM_PERMISSIONS,
launchOptions: {
args: [
'--use-fake-ui-for-media-stream',
@@ -68,13 +74,13 @@ export default defineConfig({
{
name: 'chromium-mobile',
testMatch: ['**/09-mobile/**/*.spec.ts'],
use: { ...devices['Pixel 7'] },
use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS },
},
// ── Mobile UA smoke matrix (runs only @smoke specs in CI) ────────────
{
name: 'chromium-pixel7',
use: { ...devices['Pixel 7'] },
use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS },
grep: /@smoke/,
},
{
@@ -84,6 +90,7 @@ export default defineConfig({
viewport: { width: 360, height: 780 },
userAgent:
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
permissions: CHROMIUM_PERMISSIONS,
},
grep: /@smoke/,
},
@@ -94,6 +101,7 @@ export default defineConfig({
viewport: { width: 360, height: 780 },
userAgent:
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
permissions: CHROMIUM_PERMISSIONS,
},
grep: /@smoke/,
},
@@ -103,6 +111,7 @@ export default defineConfig({
...devices['Pixel 7'],
userAgent:
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0',
permissions: CHROMIUM_PERMISSIONS,
},
grep: /@smoke/,
},
@@ -125,6 +134,9 @@ export default defineConfig({
use: {
...devices['Pixel 7'],
defaultBrowserType: 'firefox',
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
// Android UA for coverage, but drop the unsupported flag.
isMobile: false,
userAgent:
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
},

View File

@@ -54,9 +54,13 @@ test.describe('Auth — join flow', () => {
await expect(join.recoveryPinInput).toBeVisible();
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
// Type correct PIN → land on /feed with a new JWT
// Type correct PIN → land on /feed with a new JWT. Filling the 4th digit
// auto-submits (see pin-auto-submit.spec), so an explicit submit click would
// race the navigation; click only as a fallback if the button is still around.
await join.recoveryPinInput.fill(original.pin);
await join.recoverySubmit.click();
if (await join.recoverySubmit.isEnabled().catch(() => false)) {
await join.recoverySubmit.click().catch(() => {});
}
await page.waitForURL('**/feed');
const storage = await readStorage(page);

View File

@@ -0,0 +1,26 @@
/**
* Security fix F4: /recover no longer leaks whether a display name exists. Previously an
* unknown name returned 404 ("Kein Benutzer …") while an existing name + wrong PIN
* returned 401 ("PIN ist falsch") — a response (and bcrypt-timing) oracle for account
* enumeration. Now both return an identical 401.
*/
import { test, expect } from '../../fixtures/test';
test.describe('Auth — recover does not leak account existence (F4)', () => {
test('unknown name and wrong PIN both return an identical 401', async ({ api, guest }) => {
// Establish the event + a real user first (a truncate wipes the event row; the join
// recreates it — otherwise recover 404s on "event not found" before name resolution).
const g = await guest('RealPerson');
// Unknown display name → 401 "PIN ist falsch" (NOT a 404 revealing non-existence).
const unknown = await api.recover('NoSuchPersonXYZ', '0000', { expectedStatus: [401] });
expect(unknown.status).toBe(401);
expect(JSON.stringify(unknown.body)).toContain('PIN');
// A real user with a wrong PIN returns the same 401 shape.
const wrongPin = g.pin === '0001' ? '0002' : '0001';
const wrong = await api.recover('RealPerson', wrongPin, { expectedStatus: [401] });
expect(wrong.status).toBe(401);
expect(JSON.stringify(wrong.body)).toContain('PIN');
});
});

View File

@@ -38,15 +38,14 @@ test.describe('Upload — gallery path', () => {
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
});
test.fixme('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
// The full UI flow (BottomNav FAB → UploadSheet → /upload page → handleSubmit →
// upload-queue.ts XHR) does not currently complete within the test window in
// Playwright. The XHR doesn't appear in backend logs. Suspected cause: the
// queue worker fires after the page navigates from /upload to /feed via
// SvelteKit's goto(), but the blob/IDB chain may not survive the unmount/
// remount cycle in Playwright's headless Chromium. Needs deeper
// investigation; tracked as a fixme for now. API-driven tests above cover
// the data contract.
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
// `upgrade` callback opened a *new* transaction, which throws during a
// version-change transaction and aborted the whole upgrade, so the `queue`
// object store was never created and the worker could never persist an item.
// Fixed in upload-queue.ts by reusing the callback's version-change
// transaction. This test guards against regressing that.
const h = await guest('UploaderUI');
await signIn(page, h);
const feed = new FeedPage(page);

View File

@@ -6,8 +6,12 @@
*/
import { test, expect } from '../../fixtures/test';
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
// A real, decodable JPEG — the upload handler validates magic bytes, so a
// zero-filled buffer would be rejected with 400 before the rate limiter is reached.
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
test.describe('Upload — rate limit', () => {
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
@@ -24,7 +28,7 @@ test.describe('Upload — rate limit', () => {
// Hit the API directly for speed — UI behavior is asserted in gallery-path.spec.
const upload = async (n: number) => {
const form = new FormData();
const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' });
const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' });
form.append('file', blob, `file${n}.jpg`);
form.append('content_type', 'image/jpeg');
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
@@ -47,7 +51,6 @@ test.describe('Upload — rate limit', () => {
// The 429 response carries Retry-After.
const limited = responses.find((r) => r.status === 429)!;
expect(limited.headers.get('retry-after')).toBeTruthy();
void SAMPLE_JPG;
});
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
@@ -56,7 +59,7 @@ test.describe('Upload — rate limit', () => {
const h = await guest('NoQuota');
const upload = async (n: number) => {
const form = new FormData();
const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' });
const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' });
form.append('file', blob, `file${n}.jpg`);
form.append('content_type', 'image/jpeg');
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {

View File

@@ -0,0 +1,53 @@
/**
* Regression for the review's CR1: the LightboxModal posted comments to
* `/upload/{id}/comment` (singular) while the only route is `/comments` (plural),
* so every comment submitted through the UI 404'd and was silently lost. The
* earlier "comment → SSE" spec passed by posting via a fetch helper, bypassing
* the component — a false green. This drives the real component end-to-end.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Comments — UI round-trip (CR1)', () => {
test('a comment typed in the lightbox persists to the backend', async ({
page,
guest,
signIn,
}) => {
const author = await guest('CommentAuthor');
const commenter = await guest('Commenter');
const uploadId = await seedUpload(author.jwt, { caption: 'Comment target' });
await signIn(page, commenter);
await page.goto('/feed');
// Open the lightbox. Only one upload exists, so the first open-button is it.
const imageButton = page.getByRole('button', { name: 'Bild vergrößern' }).first();
await expect(imageButton).toBeVisible({ timeout: 15_000 });
await imageButton.click();
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
await expect(lightbox).toBeVisible();
const text = `Wunderschönes Foto ${Date.now()}`;
await lightbox.getByPlaceholder(/kommentar/i).fill(text);
await lightbox.getByRole('button', { name: /senden/i }).click();
// The component appends the comment only on a 2xx — with the old singular path
// it threw and nothing appeared. Assert it's visible in the panel...
await expect(lightbox.getByText(text)).toBeVisible();
// ...and that it actually persisted server-side (the crux CR1 broke).
await expect
.poll(async () => {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
headers: { Authorization: `Bearer ${commenter.jwt}` },
});
const body = await res.json();
return Array.isArray(body) && body.some((c: { body: string }) => c.body === text);
})
.toBe(true);
});
});

View File

@@ -1,29 +1,68 @@
/**
* USER_JOURNEYS.md §8 — search and filter chips. Asserts the OR / AND
* combination rules described in the journey.
* USER_JOURNEYS.md §8 — grid-view search & filter chips. Verifies the OR / AND
* combination rules end-to-end by seeding uploads with known captions/uploaders,
* activating chips, and counting the resulting grid tiles.
*
* Most of this test currently drives the UI; the data-seeding happens
* via API once a Node-side upload helper lands. For now we ship the
* structure and the UI assertions, marked with `test.fixme` where they
* depend on seeded data we can't yet create.
* (The pure combination logic is also unit-tested in
* frontend/src/lib/feed-filter.test.ts.)
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import type { Page } from '@playwright/test';
/** Grid tiles render as buttons labelled "Upload anzeigen". */
const tiles = (page: Page) => page.getByRole('button', { name: 'Upload anzeigen' });
async function switchToGrid(page: Page) {
await page.getByRole('button', { name: 'Rasteransicht' }).click();
}
/** Type a query into the grid search box and click the matching suggestion. */
async function addFilter(page: Page, query: string, suggestionName: string | RegExp) {
const search = page.getByPlaceholder('Nutzer oder #Tag suchen…');
await search.click();
await search.fill(query);
await page.getByRole('button', { name: suggestionName }).click();
}
test.describe('Feed — filter & search', () => {
test.fixme('two hashtag chips combine with OR', async ({ page, guest, signIn }) => {
const h = await guest('Searcher');
await signIn(page, h);
// TODO: seed 2 uploads with different hashtags, then activate two chips
// and assert both cards remain visible.
await page.goto('/feed');
expect(true).toBe(true);
test('two hashtag chips combine with OR', async ({ page, guest, signIn }) => {
const a = await guest('OrUser');
await seedUpload(a.jwt, { caption: 'pic #wedding' });
await seedUpload(a.jwt, { caption: 'pic #party' });
await seedUpload(a.jwt, { caption: 'pic #other' });
await signIn(page, a); // lands on /feed
await switchToGrid(page);
await expect(tiles(page)).toHaveCount(3);
// One tag → only its card.
await addFilter(page, '#wedding', /wedding/i);
await expect(tiles(page)).toHaveCount(1);
// Adding a second tag widens the result (OR), not narrows it.
await addFilter(page, '#party', /party/i);
await expect(tiles(page)).toHaveCount(2);
});
test.fixme('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => {
const h = await guest('Searcher2');
await signIn(page, h);
await page.goto('/feed');
expect(true).toBe(true);
test('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => {
const alice = await guest('AndAlice');
const bob = await guest('AndBob');
await seedUpload(alice.jwt, { caption: 'pic #wedding' }); // Alice + wedding
await seedUpload(bob.jwt, { caption: 'pic #wedding' }); // Bob + wedding
await seedUpload(alice.jwt, { caption: 'pic #party' }); // Alice + party
await signIn(page, alice); // feed is event-wide → sees all three
await switchToGrid(page);
await expect(tiles(page)).toHaveCount(3);
// Filter by uploader → Alice's two uploads.
await addFilter(page, 'AndAlice', 'AndAlice');
await expect(tiles(page)).toHaveCount(2);
// Add a tag → must satisfy BOTH (Alice AND #wedding) → just the one.
await addFilter(page, '#wedding', /wedding/i);
await expect(tiles(page)).toHaveCount(1);
});
test('feed page renders without crashing for an authed user', async ({ page, guest, signIn }) => {

View File

@@ -1,36 +1,80 @@
/**
* USER_JOURNEYS.md §7 — liking and commenting. SSE round-trip is
* asserted by opening a second tab as a different user.
* USER_JOURNEYS.md §7 — liking and commenting.
*
* Like behavior is asserted deterministically via the feed snapshot; the comment
* SSE round-trip is asserted by subscribing to the stream as a second user and
* waiting for the `new-comment` event to arrive.
*/
import { test, expect } from '../../fixtures/test';
import { SseListener } from '../../helpers/sse-listener';
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
async function like(jwt: string, uploadId: string): Promise<number> {
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
return res.status;
}
test.describe('Feed — like + comment', () => {
test('like is idempotent against rapid double-click', async ({ api, guest }) => {
const a = await guest('Liker');
// Seed an upload from a second user so `a` has something to like.
const b = await guest('Author');
// Without a multipart helper in Node, we exercise the like endpoint directly
// and assert behavior via the public feed snapshot.
// (Spec is a placeholder until we add a Node-side upload helper or do
// the seed via UI.)
const feed = await api.getFeed(a.jwt);
void feed;
void b;
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => {
const author = await guest('Author');
const liker = await guest('Liker');
const uploadId = await seedUpload(author.jwt);
// Baseline: nobody has liked yet.
let row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(0);
expect(row.liked_by_me).toBe(false);
// First like → counted exactly once.
expect(await like(liker.jwt, uploadId)).toBe(200);
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(1);
expect(row.liked_by_me).toBe(true);
// Liking again is a toggle → back to zero (guards against a regression that
// double-counts a repeated like instead of removing it).
expect(await like(liker.jwt, uploadId)).toBe(200);
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(0);
expect(row.liked_by_me).toBe(false);
// A second distinct user's like is counted independently (per-user semantics).
expect(await like(liker.jwt, uploadId)).toBe(200); // liker likes again → 1
expect(await like(author.jwt, uploadId)).toBe(200); // author likes too → 2
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(2);
});
test('comment by user A → SSE new-comment delivered to user B', async ({ guest }) => {
const a = await guest('A');
const b = await guest('B');
// SSE frames can take a keep-alive tick to flush through the reverse proxy, so
// both the stream connect and the delivery may cost up to ~30s each.
test.setTimeout(120_000);
const a = await guest('CommenterA');
const b = await guest('ListenerB');
// B subscribes to the stream BEFORE A comments, so the broadcast is captured.
const sse = new SseListener();
await sse.start(b.jwt);
// Without an upload helper, this currently only verifies that the SSE stream
// *connects* for a guest. The comment send + receive assertion lands as soon
// as we add a backend-side helper to inject uploads bypassing multipart.
expect(sse.allEvents().length).toBeGreaterThanOrEqual(0);
sse.stop();
void a;
try {
const uploadId = await seedUpload(a.jwt, { caption: 'pic' });
await seedComment(a.jwt, uploadId, 'hello from A');
// B must receive the new-comment event for this upload. Generous timeout: SSE
// frames flush on the keep-alive tick through the compressing reverse proxy.
const evt = await sse.waitForEvent(
'new-comment',
(e) => e.data?.upload_id === uploadId,
45_000
);
expect(evt.data.comment_count).toBe(1);
} finally {
sse.stop();
}
});
});

View File

@@ -1,27 +1,49 @@
/**
* SSE reconnection after tab background. USER_JOURNEYS.md §17 / edge cases.
*
* The app closes the EventSource on `document.hidden` and reopens it (minting a
* fresh ticket + new EventSource) when the tab becomes visible again — see
* frontend/src/lib/sse.ts. We assert the reconnect by counting stream-open
* requests rather than event delivery, which keeps the test fast and independent
* of the reverse proxy's SSE buffering.
*/
import { test, expect } from '../../fixtures/test';
import { trackStreamOpens } from '../../helpers/sse';
test.describe('Feed — SSE behavior', () => {
test('SSE reconnects after tab visibility goes hidden then visible', async ({ page, guest, signIn }) => {
const h = await guest('SseReconnect');
await signIn(page, h);
await page.goto('/feed');
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => {
const g = await guest('SseReconnect');
const streamOpens = trackStreamOpens(page);
// Force-fire a visibilitychange to hidden, then back to visible. The app's
// sse.ts is expected to close + reopen the EventSource around this.
await signIn(page, g); // lands on /feed, which calls connectSse() on mount
// Initial connection established.
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
// Background: the visibility handler reads document.hidden, so override that
// (not just visibilityState) before dispatching, or the close never fires.
// disconnectSse() closes the EventSource and clears any reconnect timer, so no
// new stream opens while hidden.
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(500);
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
// any spurious native/error reconnect before now), so the assertion below can only
// be satisfied by a NEW open attributable to the foreground event itself.
const afterHidden = streamOpens();
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
});
// App should still be functional — assert the bottom nav remains visible.
// The reconnect is a brand-new stream GET that appears only after foregrounding.
await expect.poll(streamOpens, { timeout: 10_000 }).toBeGreaterThan(afterHidden);
// And the app is still functional.
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
});
});

View File

@@ -41,8 +41,9 @@ test.describe('Feed — error toast on user action failures', () => {
const card = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card).toBeVisible({ timeout: 10_000 });
// Click the like button in the actions row — first visible match inside the card.
await card.locator('button').filter({ hasText: /\d+/ }).first().click();
// Click the like button by its stable aria-label (the liker hasn't liked yet).
// Avoids matching a different digit-bearing button (e.g. the comment count).
await card.getByRole('button', { name: 'Gefällt mir' }).click();
// The toast is rendered inside the global Toaster region with aria-live="polite".
const toast = page.getByTestId('toast').first();

View File

@@ -33,4 +33,49 @@ test.describe('Host — event lock', () => {
// Currently no UI consumes the event-closed SSE on /feed. Add this banner
// and flip fixme to test once it lands.
});
// Locking is uploads-only: likes, comments and browsing stay open on a closed
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
// rejected. (An earlier revision froze social interaction too; that contradicted
// the documented behavior and was reverted.)
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const g = await guest('SocialLocked');
// Upload while still open so there's a target to interact with.
const { uploadRaw } = await import('../../helpers/upload-client');
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const sample = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
const upRes = await uploadRaw(g.jwt, readFileSync(sample), {
filename: 'x.jpg',
contentType: 'image/jpeg',
});
expect(upRes.status).toBe(201);
const { id } = await upRes.json();
await api.closeEvent(host.jwt);
// Likes stay open on a locked event.
const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(likeRes.status).toBe(200);
// Comments stay open on a locked event.
const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'darf durchgehen' }),
});
expect(commentRes.status).toBe(201);
// New uploads, however, are rejected while locked.
const blockedUpload = await uploadRaw(g.jwt, readFileSync(sample), {
filename: 'y.jpg',
contentType: 'image/jpeg',
});
expect(blockedUpload.status).toBe(403);
});
});

View File

@@ -4,6 +4,7 @@
* coverage of the buttons lives in a separate UI-focused spec.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment } from '../../helpers/seed';
test.describe('Host — moderation API', () => {
test('ban with hide_uploads=true sets the right flags', async ({ api, host, guest }) => {
@@ -15,13 +16,15 @@ test.describe('Host — moderation API', () => {
expect(row?.uploads_hidden).toBe(true);
});
test('ban without hiding leaves uploads_hidden=false', async ({ api, host, guest }) => {
test('ban always hides — the hide_uploads flag is ignored (USER_JOURNEYS §10.5)', async ({ api, host, guest }) => {
// Ban is now unconditionally a hide: even asking NOT to hide still hides, because a
// banned user's content is "gone" everywhere. The legacy hide_uploads arg is ignored.
const target = await guest('Banned2');
await api.banUser(host.jwt, target.userId, false);
const users = await api.listUsers(host.jwt);
const row = users.find((u: any) => u.id === target.userId);
expect(row?.is_banned).toBe(true);
expect(row?.uploads_hidden).toBe(false);
expect(row?.uploads_hidden).toBe(true);
});
test('banned user cannot call /upload', async ({ api, host, guest }) => {
@@ -45,3 +48,112 @@ test.describe('Host — moderation API', () => {
expect(row?.role).toBe('host');
});
});
/**
* Regression for the review's H1: role & ban used to be trusted from the JWT and
* never re-checked against the DB, so a demoted/banned host kept full powers for
* the life of their token (up to 30d) and a banned host could even unban
* themselves. The auth extractor now re-reads the live user row, so these take
* effect on the *existing* session with no re-login.
*/
test.describe('Host — live role/ban revocation (H1)', () => {
test('a demoted host loses host powers on their existing session', async ({
api,
adminToken,
guest,
}) => {
const u = await guest('DemoteMidSession');
await api.setRole(adminToken, u.userId, 'host');
// Fresh host token (role is encoded at mint time).
const { body } = await api.recover(u.displayName, u.pin);
const hostJwt = body.jwt;
// Sanity: the token currently has host powers.
await api.listUsers(hostJwt);
// Demote via admin — the host does NOT re-login.
await api.setRole(adminToken, u.userId, 'guest');
// Same token is now rejected with exactly 403 (RequireHost sees the DB role,
// not the JWT claim). Asserting the status guards against a spurious 500
// masquerading as "revoked".
await expect(api.listUsers(hostJwt)).rejects.toThrow(/→ 403/);
});
test('a banned host is locked out immediately on their existing session', async ({
api,
adminToken,
host,
}) => {
// Sanity: host token works.
await api.listUsers(host.jwt);
await api.banUser(adminToken, host.userId, false);
// Banned users are rejected with 403 by the auth extractor before any handler runs.
await expect(api.listUsers(host.jwt)).rejects.toThrow(/→ 403/);
});
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
await api.banUser(adminToken, host.userId, false);
await expect(
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
).rejects.toThrow(/→ 403/);
});
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
// NOT in the base extractor (which would wrongly block reads too).
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => {
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const target = await guest('BannedRW');
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
// Seed content the target OWNS *before* the ban, so the delete/edit paths get
// past the ownership check and it's genuinely the ban guard being exercised.
const ownUpload = await seedUpload(target.jwt, { caption: 'mine' });
const ownComment = await seedComment(target.jwt, ownUpload, 'my comment');
await api.banUser(host.jwt, target.userId, false);
// Reads still succeed.
const read = await fetch(base + '/api/v1/me/context', { headers: auth(target.jwt) });
expect(read.status).toBe(200);
// Every write is rejected — cover ALL guest-reachable mutations, not just
// /upload. delete_upload / delete_comment / edit_upload previously skipped the
// ban guard (a banned owner could still delete/edit their own content).
const create = await fetch(base + '/api/v1/upload', {
method: 'POST',
headers: auth(target.jwt),
body: new FormData(),
});
expect(create.status).toBe(403);
const delUpload = await fetch(base + `/api/v1/upload/${ownUpload}`, {
method: 'DELETE',
headers: auth(target.jwt),
});
expect(delUpload.status).toBe(403);
const editUpload = await fetch(base + `/api/v1/upload/${ownUpload}`, {
method: 'PATCH',
headers: { ...auth(target.jwt), 'Content-Type': 'application/json' },
body: JSON.stringify({ caption: 'edited while banned' }),
});
expect(editUpload.status).toBe(403);
const delComment = await fetch(base + `/api/v1/comment/${ownComment}`, {
method: 'DELETE',
headers: auth(target.jwt),
});
expect(delComment.status).toBe(403);
// The upload survived every blocked write — the delete was rejected, so the row still
// exists (deleted_at IS NULL). We check the DB directly rather than the feed: ban now
// ALWAYS hides, so a banned user's own upload is (correctly) filtered out of every feed
// even though the row is intact. Read access to *other* content is proven by the 200 on
// /me/context above.
expect(await db.countUploadsForUser(target.userId)).toBe(1);
});
});

View File

@@ -0,0 +1,43 @@
/**
* Regression for the review's C1: `reset_user_pin` wrote to a non-existent
* column (`pin_failed_attempts` vs the real `failed_pin_attempts`), so the
* endpoint 500'd every time and never returned a PIN — the feature had never
* worked against a real DB and no test caught it. These specs pin the contract:
* a successful reset returns a fresh 4-digit PIN, and the target can recover
* with it.
*/
import { test, expect } from '../../fixtures/test';
test.describe('Host — reset guest PIN (C1)', () => {
test('resetting a guest PIN returns a fresh 4-digit PIN', async ({ api, host, guest }) => {
const target = await guest('ResetMe');
const { status, body } = await api.resetUserPin(host.jwt, target.userId);
expect(status).toBe(200);
expect(body.pin).toMatch(/^\d{4}$/);
});
test('the target can recover with the newly reset PIN (and not the old one)', async ({
api,
host,
guest,
}) => {
const target = await guest('RecoverWithNewPin');
const { body } = await api.resetUserPin(host.jwt, target.userId);
const newPin = body.pin!;
// New PIN works.
await api.recover(target.displayName, newPin, { expectedStatus: [200] });
// Old PIN no longer works (overwritten). 401 = wrong PIN.
if (target.pin !== newPin) {
await api.recover(target.displayName, target.pin, { expectedStatus: [401] });
}
});
test('a host cannot reset their own PIN via this endpoint', async ({ api, host }) => {
await api.resetUserPin(host.jwt, host.userId, { expectedStatus: [400] });
});
});

View File

@@ -0,0 +1,75 @@
/**
* Regression for the review's H3: self-deleting an upload and banning a user with
* hide_uploads mutated visibility server-side but broadcast nothing, so the
* content lingered on every other viewer's feed (and the projector diashow) until
* a manual reload. Both now emit SSE so clients evict live.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { SseListener } from '../../helpers/sse-listener';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Host — live SSE eviction (H3)', () => {
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
const g = await guest('SelfDeleter');
const uploadId = await seedUpload(g.jwt, { caption: 'delete me' });
const sse = new SseListener();
await sse.start(g.jwt);
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(res.status).toBe(204);
await sse.waitForEvent(
'upload-deleted',
(e) => e.data.upload_id === uploadId
);
});
test('banning a user with hide_uploads broadcasts user-hidden', async ({
api,
host,
guest,
}) => {
const target = await guest('HideTarget');
await seedUpload(target.jwt, { caption: 'should vanish' });
const sse = new SseListener();
await sse.start(host.jwt);
await api.banUser(host.jwt, target.userId, true);
await sse.waitForEvent(
'user-hidden',
(e) => e.data.user_id === target.userId
);
});
// Frontend regression: the broadcasts above are inert if the client never
// registers the event name (the KNOWN_EVENTS gap that shipped both eviction
// handlers as dead code). Drive a real browser feed and assert LIVE eviction —
// this fails if 'user-hidden' is missing from KNOWN_EVENTS, unlike the
// backend-only SseListener checks above.
test('a hidden user is evicted from an open feed without reload (frontend)', async ({
page,
api,
host,
guest,
signIn,
}) => {
const viewer = await guest('LiveEvictViewer');
const target = await guest('LiveEvictTarget');
await seedUpload(target.jwt, { caption: 'evict-me-live-xyz' });
await signIn(page, viewer); // lands on the event-wide /feed
await expect(page.getByText('evict-me-live-xyz').first()).toBeVisible();
// Host hides the target — the viewer's feed must drop the card via SSE, no reload.
await api.banUser(host.jwt, target.userId, true);
await expect(page.getByText('evict-me-live-xyz')).toHaveCount(0, { timeout: 15_000 });
});
});

View File

@@ -63,8 +63,10 @@ test.describe('Admin — stats', () => {
await guest('Stat2');
await guest('Stat3');
const stats = await api.getStats(adminToken);
// Three guests + the Admin account auto-created on first admin login = 4 users.
expect(stats.user_count).toBeGreaterThanOrEqual(3);
// Deterministic after the per-test truncate: 3 seeded guests + the Admin account
// (recreated by the adminToken fixture's login) = exactly 4. An exact assertion
// catches undercount/overcount regressions a `>= 3` lower bound would miss.
expect(stats.user_count).toBe(4);
expect(typeof stats.disk_total_bytes).toBe('number');
});
});

View File

@@ -0,0 +1,60 @@
/**
* Regression for the review's CR2: export archives (Gallery.zip / Memories.zip)
* were written under media_path/exports, and /media is a public ServeDir — so
* anyone could GET /media/exports/Gallery.zip and download the whole gallery,
* bypassing the ticket + export_*_ready gate. Exports now live OUTSIDE media_path
* and are reachable only via the gated /api/v1/export/{zip,html} handlers.
*
* This drives a REAL export (release → job runs → archive on disk) and then
* asserts the archive is NOT public but IS gated. Asserting a 404 on an empty
* stack would pass even if exports were still under /media (the file just wouldn't
* exist yet) — so we produce a real file first, then prove it can't leak.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Export — no public leak (CR2)', () => {
test('a real export is downloadable only via the gated endpoint, never via /media', async ({
host,
}) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
// Seed content so the archive actually contains a file.
await seedUpload(host.jwt, { caption: 'in the export' });
// Host releases the gallery → spawns the real zip/html export jobs.
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
expect(rel.status).toBe(204);
// Wait for the real zip job to finish writing the archive to disk.
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 archive now EXISTS on disk. It must NOT be reachable via public /media…
for (const name of ['Gallery.zip', 'Memories.zip']) {
const leak = await fetch(`${BASE}/media/exports/${name}`);
// A 200 here = the whole-gallery archive is downloadable with no auth (CR2).
expect(leak.status, `${name} must not be served from public /media`).toBe(404);
}
// …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`, { method: 'POST', headers: bearer });
const { ticket } = await ticketRes.json();
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
expect(dl.status).toBe(200);
// Real ZIP payload: the archive starts with the PK local-file-header magic.
const head = new Uint8Array(await dl.arrayBuffer()).subarray(0, 2);
expect(Array.from(head)).toEqual([0x50, 0x4b]); // "PK"
});
});

View File

@@ -0,0 +1,64 @@
/**
* Covers the HTML export's VIDEO path (perf/stability fix P4). The export used to
* copy every original — including full-size videos — into a temp dir and then re-read
* them into the ZIP (transient 2× disk). It now streams video originals straight from
* disk into the archive. The image-only export specs never exercised that branch, so
* this drives a real video upload → export → and proves the video entry lands in
* Memories.zip (i.e. the streamed-from-original path works and the video isn't dropped).
*
* The fixture clip is <1s, so ffmpeg extracts no thumbnail frame — but exits 0, so the
* compression worker keeps the upload (it isn't auto-cleaned). That's the intended
* shape here: the full video is exported even when its thumbnail is absent.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Export — video streaming (P4)', () => {
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
test.setTimeout(60_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
// Seed a real video upload owned by the host.
const mp4 = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.mp4'));
const up = await uploadRaw(host.jwt, mp4, { filename: 'clip.mp4', contentType: 'video/mp4' });
expect(up.status).toBe(201);
const { id } = await up.json();
// Release the gallery → spawns the real zip + html export jobs.
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
expect(rel.status).toBe(204);
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).html?.status;
},
{ timeout: 45_000, intervals: [500] }
)
.toBe('done');
// Download Memories.zip via the gated single-use ticket.
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
const { ticket } = await ticketRes.json();
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
expect(dl.status).toBe(200);
const bytes = new Uint8Array(await dl.arrayBuffer());
// Valid ZIP (PK local-file-header magic).
expect(Array.from(bytes.subarray(0, 2))).toEqual([0x50, 0x4b]);
// ZIP entry names are stored verbatim (uncompressed) in the archive, so the video's
// media entry name appears as plaintext bytes. Its presence means the streamed
// video entry was written; if the stream had failed, entry.close() would have
// errored and the job would never have reached 'done'.
const needle = `media/${id}.mp4`;
const haystack = new TextDecoder('latin1').decode(bytes);
expect(haystack.includes(needle), `Memories.zip must contain ${needle}`).toBe(true);
});
});

View File

@@ -42,16 +42,42 @@ test.describe('Export — release and download', () => {
expect(body.html.status).toBe('done');
});
test('ZIP download returns 404 when no file is on disk (export released but never compressed)', async ({ guest, db }) => {
const g = await guest('NoFile');
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
// 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', {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
return (await res.json()).ticket;
}
test('ZIP download 404s when the export is not yet marked ready', async ({ guest, db }) => {
const g = await guest('NotReady');
// Released flag set, but export_zip_ready is still false → must refuse, never serve.
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
// Real backend additionally checks event.export_zip_ready. The faked row is
// enough for /status; the download path needs the boolean flag too.
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/zip', {
headers: { Authorization: `Bearer ${g.jwt}` },
});
// Either 404 ("not available" OR "file not found") — both are valid states for this setup.
expect([404, 200]).toContain(res.status);
const ticket = await mintTicket(g.jwt);
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
// Pinned to 404 (not [404,200]): a 200 here would mean serving an export that was
// never released for download — a data-exposure regression. This hits the
// `!export_zip_ready` guard.
expect(res.status).toBe(404);
});
test('ZIP download 404s when marked ready but the file is missing on disk', async ({ guest, db }) => {
const g = await guest('ReadyNoFile');
// Released AND ready, but no Gallery.zip on disk (we never ran a real export) →
// the handler must 404 on the missing-file check, not 200/500 or serve a stale file.
await db.setExportReleased(SLUG, true);
await db.setExportZipReady(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
const ticket = await mintTicket(g.jwt);
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(404);
});
});

View File

@@ -5,26 +5,79 @@
* with cross-user and banned-user scenarios that span multiple resources.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Adversarial — deep authorization', () => {
test('user A cannot delete user B\'s comment via /api/v1/comment/{id}', async ({ api, guest }) => {
const a = await guest('CommentA');
const b = await guest('CommentB');
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
// tested authorization at all.
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
const a = await guest('CommentOwnerA');
const b = await guest('AttackerB');
// We need an upload first; without a multipart helper here we use a placeholder:
// post a comment on a non-existent upload to force the path to return 404 / 403 / 401.
// The real intent is verified once an upload helper feeds this test a real upload_id.
const fakeId = '00000000-0000-0000-0000-000000000000';
const res = await fetch(`${BASE}/api/v1/comment/${fakeId}`, {
const uploadId = await seedUpload(a.jwt);
const commentId = await seedComment(a.jwt, uploadId, 'A owns this');
const res = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${b.jwt}` },
});
// Acceptable: 403 (not your comment), 404 (no such comment), 401.
expect([401, 403, 404]).toContain(res.status);
void a;
void api;
// Must be 403 specifically — the comment exists and is in B's event, so a 404 would
// mean the ownership check was skipped/reordered.
expect(res.status).toBe(403);
// No state change: the comment is still there.
const after = await listComments(a.jwt, uploadId);
expect(after.some((c: any) => c.id === commentId)).toBe(true);
// Control: the real owner CAN delete it (proves the 403 was about identity, not a broken route).
const ownerDel = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${a.jwt}` },
});
expect(ownerDel.status).toBe(204);
});
// IDOR: user B must not be able to delete user A's REAL upload.
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
const a = await guest('UploadOwnerA');
const b = await guest('AttackerB2');
const uploadId = await seedUpload(a.jwt);
expect(await db.countUploadsForUser(a.userId)).toBe(1);
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${b.jwt}` },
});
expect(res.status).toBe(403);
// No state change: A's upload is still present (not soft-deleted).
expect(await db.countUploadsForUser(a.userId)).toBe(1);
});
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
const a = await guest('UploadOwnerA2');
const b = await guest('AttackerB3');
// seedUpload marks compression done, so the upload is feed-visible for the read-back.
const uploadId = await seedUpload(a.jwt, { caption: 'original caption' });
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${b.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ caption: 'hacked by B' }),
});
expect(res.status).toBe(403);
// No state change: the caption A set is intact.
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
const row = findFeedRow(await feedRes.json(), uploadId);
expect(row?.caption).toBe('original caption');
});
test('banned user cannot toggle a like', async ({ api, host, guest }) => {

View File

@@ -4,6 +4,7 @@
* or rejected gracefully without crashing the backend.
*/
import { test, expect } from '../../fixtures/test';
import { mintSseTicket } from '../../helpers/sse';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
@@ -46,9 +47,15 @@ test.describe('Adversarial — small-scale abuse', () => {
test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
const g = await guest('SseFlood');
const controllers = Array.from({ length: 10 }, () => new AbortController());
const requests = controllers.map((c) =>
fetch(`${BASE}/api/v1/stream?token=${encodeURIComponent(g.jwt)}`, { signal: c.signal })
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
// (These streams must be held open concurrently, so we can't use the openStream
// helper which opens-and-aborts a single stream.)
const tickets = await Promise.all(Array.from({ length: 10 }, () => mintSseTicket(g.jwt)));
const controllers = tickets.map(() => new AbortController());
const requests = tickets.map((ticket, i) =>
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
);
const responses = await Promise.all(requests);
// All accepted (or some rate-limited — both fine).

View File

@@ -0,0 +1,59 @@
/**
* Security fix F2: preview/thumbnail images are now served through a visibility-checked
* alias (/api/v1/upload/{id}/preview) instead of the unauthenticated /media ServeDir,
* so moderation (delete / ban-hide) actually revokes access to the displayed image —
* not just the full-res original. Direct /media/previews access is blocked.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Media gating — moderation revokes preview access (F2)', () => {
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
host,
api,
}) => {
test.setTimeout(30_000);
const id = await seedUpload(host.jwt, { caption: 'gated' });
// Wait for the compression worker to produce the preview — the feed exposes
// `preview_url` only once `preview_path` is set.
let previewUrl: string | undefined;
await expect
.poll(
async () => {
const feed = await api.getFeed(host.jwt);
const row = (feed.uploads ?? []).find((u: any) => u.id === id);
previewUrl = row?.preview_url ?? undefined;
return previewUrl;
},
{ timeout: 20_000, intervals: [500] }
)
.toBeTruthy();
// Feed now emits the gated alias, not a /media path.
expect(previewUrl).toBe(`/api/v1/upload/${id}/preview`);
// The gated alias serves the image with the app-layer nosniff header.
const ok = await fetch(`${BASE}${previewUrl}`);
expect(ok.status).toBe(200);
expect(ok.headers.get('content-type')).toContain('image/jpeg');
// App sets nosniff (F6); the edge proxy may also set it → value can be doubled.
expect(ok.headers.get('x-content-type-options')).toContain('nosniff');
// Direct /media access is blocked (404) — the alias is the only way in.
const direct = await fetch(`${BASE}/media/previews/${id}.jpg`);
expect(direct.status, 'direct /media/previews must be blocked').toBe(404);
// Host deletes the upload → the preview must stop being served.
const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(del.status).toBe(204);
const afterDelete = await fetch(`${BASE}/api/v1/upload/${id}/preview`);
expect(afterDelete.status, 'moderation must revoke preview access').toBe(404);
});
});

View File

@@ -0,0 +1,45 @@
/**
* Security fix F1: a plain host must not be able to demote a *peer host*. Before the
* fix, `set_role` only blocked `target == admin`, so a host could demote another 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. This proves the
* demotion itself is now blocked for non-admins, while admins and guest-management
* still work.
*/
import { test, expect } from '../../fixtures/test';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
function patchRole(token: string, userId: string, role: string) {
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
}
test.describe('AuthZ — host cannot demote a peer host (F1)', () => {
test('host→host demotion is 403; admin may demote; host may still manage guests', async ({
host,
guest,
adminToken,
api,
}) => {
// A second host (the victim) and an ordinary guest.
const peer = await guest('PeerHost');
await api.setRole(adminToken, peer.userId, 'host');
const plain = await guest('PlainGuest');
// Attacker host tries to demote the peer host — must be refused.
const attack = await patchRole(host.jwt, peer.userId, 'guest');
expect(attack.status, 'a host must not be able to demote a peer host').toBe(403);
// An admin may still demote a host.
const byAdmin = await patchRole(adminToken, peer.userId, 'guest');
expect(byAdmin.status).toBe(204);
// A host may still manage guests (promote one to host).
const promote = await patchRole(host.jwt, plain.userId, 'host');
expect(promote.status).toBe(204);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Phase 2 adversarial — SSE ticket capability abuse.
*
* The stream endpoint authenticates via short-lived, single-use tickets minted at
* POST /api/v1/stream/ticket (never the raw JWT in the URL). These tests pin the
* security properties of that flow: minting requires auth, and a ticket is consumed
* on first use so it cannot be replayed.
*/
import { test, expect } from '../../fixtures/test';
import { mintSseTicket, openStream } from '../../helpers/sse';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Adversarial — SSE ticket abuse', () => {
test('minting a ticket requires authentication', async () => {
const res = await fetch(`${BASE}/api/v1/stream/ticket`, { method: 'POST' });
expect(res.status).toBe(401);
});
test('a ticket is single-use: replay after first open is rejected', async ({ guest }) => {
const g = await guest('SseReplay');
const ticket = await mintSseTicket(g.jwt);
// First open consumes the ticket.
expect(await openStream(ticket)).toBe(200);
// Replaying the exact same ticket must fail — it was consumed, so `consume` returns
// None → 401. A 200 here would mean tickets are reusable (capability replay).
expect(await openStream(ticket)).toBe(401);
});
test('an unminted / garbage ticket is rejected', async () => {
// 24-byte-shaped hex string that was never issued.
const bogus = 'deadbeef'.repeat(6);
expect(await openStream(bogus)).toBe(401);
});
// Note: the replay test's first open (→ 200) already proves a freshly-minted ticket
// works, so there is no separate "fresh ticket" sanity test — a second successful open
// would just add ~30s (SSE headers flush on the keep-alive tick through Caddy).
});

View File

@@ -3,18 +3,25 @@
* browser process.
*/
import { test, expect } from '../../fixtures/test';
import { trackStreamOpens } from '../../helpers/sse';
test.describe('Browser chaos — multi-tab', () => {
test('same user in two tabs — SSE delivers to both', async ({ page, context, guest, signIn }) => {
test('same user in two tabs — each tab establishes its own SSE stream', async ({ page, context, guest, signIn }) => {
const g = await guest('Twin');
await signIn(page, g);
await page.goto('/feed');
// Count each tab's own EventSource open. Asserting *connection establishment* is
// fast and reliable; asserting event *delivery* would depend on the reverse proxy's
// ~30s SSE buffering and isn't worth the flake here.
const opens1 = trackStreamOpens(page);
await signIn(page, g); // → /feed, connectSse() on mount
await expect.poll(opens1, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
const tab2 = await context.newPage();
const opens2 = trackStreamOpens(tab2);
await signIn(tab2, g);
await tab2.goto('/feed');
await expect.poll(opens2, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
// Both tabs should mount the bottom nav.
// Both tabs mounted and each opened its own independent stream.
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
await expect(tab2.getByRole('link', { name: 'Galerie' })).toBeVisible();
await tab2.close();

View File

@@ -61,7 +61,10 @@ test.describe('Browser chaos — network', () => {
const g = await guest('Throttled');
let attempts = 0;
await page.route('**/api/v1/feed', async (route) => {
// Match /api/v1/feed with or without a query string (the app requests
// `/feed?limit=20`), but NOT /api/v1/feed/delta. A plain `**/api/v1/feed` glob
// fails to match the query-string URL, so this route never fired before.
await page.route(/\/api\/v1\/feed(\?|$)/, async (route) => {
attempts++;
await route.fulfill({
status: 429,
@@ -72,9 +75,29 @@ test.describe('Browser chaos — network', () => {
await signIn(page, g);
await page.goto('/feed');
await page.waitForTimeout(3_000);
// First make sure the throttled endpoint was actually hit — otherwise the
// stabilize-poll below could settle at attempts=0 (feed request not yet fired)
// and pass vacuously without ever exercising the 429 path.
await expect.poll(() => attempts, { timeout: 8_000 }).toBeGreaterThanOrEqual(1);
// Then wait until the retry count stops climbing instead of sleeping a fixed 3s:
// a well-behaved client surfaces the 429 and stops, so the count settles quickly;
// a retry storm would keep incrementing and never stabilize (→ this poll times
// out and the test fails, which is the outcome we want).
let prev = -1;
await expect
.poll(
() => {
const stable = attempts === prev;
prev = attempts;
return stable;
},
{ timeout: 8_000, intervals: [300] }
)
.toBe(true);
// Sanity: client did not hammer the endpoint > a few times under throttle.
expect(attempts).toBeLessThan(15);
expect(attempts, `retry attempts=${attempts}`).toBeLessThan(15);
});
});

View File

@@ -75,10 +75,11 @@ test.describe('Mobile — double-tap gesture', () => {
await expect(imageButton).toBeVisible({ timeout: 10_000 });
await imageButton.click();
// LightboxModal is `role="dialog"` (no aria-modal). The other dialog on the
// page is the ContextSheet which has `aria-modal="true"` even when closed,
// so scope to NOT-aria-modal to pick the lightbox specifically.
const lightbox = page.locator('[role="dialog"]:not([aria-modal])');
// LightboxModal is the only dialog labelled by #lightbox-title, so target it
// directly. (Closed sheets no longer expose role=dialog — that semantics is
// gated on `open` — so a plain [role=dialog] match would be ambiguous only
// while a sheet is open; the labelledby scope keeps this unambiguous.)
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
await expect(lightbox).toBeVisible();
// Find the inner image element to tap.

View File

@@ -2,7 +2,7 @@
* Phase 3 mobile — long-press gesture.
*
* The `longpress` action attaches to `<article>` in FeedListCard and to
* grid cells in FeedGrid. Holding for ≥ 500 ms fires `onlongpress`,
* grid cells in VirtualFeed (grid mode). Holding for ≥ 500 ms fires `onlongpress`,
* which opens the ContextSheet bottom sheet via the feed page's
* `contextTarget` state.
*
@@ -45,13 +45,11 @@ test.describe('Mobile — long-press gesture', () => {
await longPress(page, card, 600);
// The ContextSheet renders a dialog with role="dialog" + aria-modal="true".
// Multiple sheets (UploadSheet, ContextSheet) may be in the DOM — match the
// one that actually has aria-modal=true (i.e. the open one).
// ContextSheet is always mounted (it just translates off-screen when closed).
// Match the OPEN state by the `translate-y-0` class the component applies
// when `open === true`.
const sheet = page.locator('[role="dialog"][aria-modal="true"].translate-y-0');
// The ContextSheet is always mounted (it translates off-screen when closed).
// Target it by its stable data-testid, gated on aria-modal="true" which the
// component sets only while open — unambiguous vs. the centered LightboxModal
// (which also has aria-modal) and independent of the animation classes.
const sheet = page.locator('[data-testid="context-sheet"][aria-modal="true"]');
await expect(sheet).toBeVisible({ timeout: 2_000 });
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
});
@@ -68,10 +66,9 @@ test.describe('Mobile — long-press gesture', () => {
// Simulate a short press (200 ms — well under the 500 ms threshold).
await longPress(page, card, 200);
// Within 1 s, no aria-modal=true dialog should be open (the ContextSheet
// is "open" only when its aria-modal flag is true).
// The ContextSheet stays mounted but `translate-y-0` is only set when open.
await expect(page.locator('[role="dialog"][aria-modal="true"].translate-y-0')).toHaveCount(0, { timeout: 1_000 });
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when
// open). A quick tap opens the lightbox instead, which is a different element.
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000 });
});
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => {

View File

@@ -45,20 +45,8 @@ test.describe('Mobile — safe-area insets', () => {
expect(distanceFromBottom).toBeLessThanOrEqual(2);
});
test('context sheet (when opened) carries the same safe-area declaration', async ({ page }) => {
// We can't easily open the context sheet without a feed card to long-press,
// but the markup lives in the layout once the route mounts. We probe by
// scanning every element with a `style` attribute for the env() reference.
await page.goto('/join');
const candidateStyles: string[] = await page.evaluate(() => {
return Array.from(document.querySelectorAll<HTMLElement>('[style]'))
.map((el) => el.getAttribute('style') ?? '')
.filter((s) => s.includes('env(safe-area-inset-bottom)'));
});
// On /join there may be zero — the assertion is more of a sanity check.
// On /feed and /account it would be ≥ 1. We assert that on /feed below.
expect(Array.isArray(candidateStyles)).toBe(true);
});
// (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true —
// was removed; the real sheet-level env() check is the structural test below.)
test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => {
const g = await guest('SafeAreaSheets');

View File

@@ -12,7 +12,9 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
await page.goto('/account');
// Click the "Original" radio in the Datennutzung section to open the warning sheet.
const originalRadio = page.getByRole('radio', { name: /Original$/i });
// The radio's accessible name is its title + description ("Original Lädt die
// Originaldateien…"), so anchor on the start, not the end.
const originalRadio = page.getByRole('radio', { name: /^Original/i });
await originalRadio.click();
const sheet = page.locator('[role="dialog"][aria-labelledby="data-mode-title"]');

View File

@@ -0,0 +1,198 @@
/**
* Re-review regression guard — Chain #2 ("export corruption on reopen→re-release").
*
* Bug that was fixed: `spawn_export_jobs` ran the worker unconditionally and the
* worker wrote a FIXED temp path (`Gallery.zip.tmp` / `viewer_tmp_{event}`).
* The new reopen→re-release flow could therefore spawn a second worker while the
* first was still running — two workers stomping the same temp file → a corrupt
* keepsake ZIP served to guests.
*
* The fix: `claim_job` is an atomic `UPDATE ... WHERE status='pending'`; a worker
* that doesn't win the claim bails without touching the temp file. The re-enqueue
* `ON CONFLICT DO UPDATE ... WHERE status <> 'running'` leaves an in-flight job
* alone. Net: exactly one worker per (event,type).
*
* This test seeds real uploads, releases, then churns reopen→re-release (stressing
* the claim guard), waits for the export to settle, and asserts the produced ZIP is
* INTACT (passes `unzip -t`) with exactly one entry per upload — plus that no
* export_job row is left stuck `running`. A temp-file race would corrupt/truncate
* the archive or drop entries, failing these assertions.
*
* Note on scope: a Dockerised export over small fixtures completes in well under a
* second, so this cannot *deterministically* force two workers to overlap in time.
* It stresses the claim/ON-CONFLICT guard under rapid churn and hard-checks archive
* integrity; the atomic single-worker guarantee itself is additionally proven by
* code review + SQL PREPARE. Together they cover the regression.
*/
import { test, expect } from '../../fixtures/test';
import { Client } from 'pg';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const SLUG = 'e2e-test-event';
const N_UPLOADS = 6;
const PG = {
host: process.env.E2E_DB_HOST ?? 'localhost',
port: Number(process.env.E2E_DB_PORT ?? '55432'),
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
};
async function pgQuery<T = any>(sql: string): Promise<T[]> {
const c = new Client(PG);
await c.connect();
try {
return (await c.query(sql)).rows as T[];
} finally {
await c.end();
}
}
function post(path: string, jwt: string) {
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
}
async function exportStatus(jwt: string): Promise<any> {
const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } });
return res.json();
}
async function mintTicket(jwt: string): Promise<string> {
const res = await post('/api/v1/export/ticket', jwt);
return (await res.json()).ticket;
}
test.describe('Flow re-review — reopen→re-release export integrity (#2)', () => {
// The real export job runs image processing; give it head-room over the tiny fixtures.
test.setTimeout(90_000);
test('rapid release/reopen/re-release yields exactly one INTACT ZIP and no stuck jobs', async ({
host,
}) => {
// Seed real, decodable uploads while the event is open.
for (let i = 0; i < N_UPLOADS; i++) {
await seedUpload(host.jwt, { caption: `pic ${i}` });
}
// First release → export starts and uploads lock (release ⇒ lock).
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
const { uploadRaw } = await import('../../helpers/upload-client');
const { readFileSync } = await import('node:fs');
const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), {
filename: 'late.jpg',
contentType: 'image/jpeg',
});
expect(rejected.status).toBe(403);
// Churn: reopen (clears release + ready flags) then re-release, several times in
// quick succession. This is the path that used to be able to double-spawn workers
// over the shared temp file. End on a release so the gallery is left released.
for (let i = 0; i < 4; i++) {
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
const rel = await post('/api/v1/host/gallery/release', host.jwt);
expect(rel.status).toBe(204);
}
// Wait for the export to settle: both jobs done and the ZIP marked ready.
await expect
.poll(async () => {
const s = await exportStatus(host.jwt);
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
}, { timeout: 60_000, intervals: [500] })
.toBe(true);
// No export_job may be left stuck 'running' (would mean a worker that never
// completed — the failure mode the claim guard exists to avoid).
const jobs = await pgQuery<{ type: string; status: string }>(
`SELECT ej.type::text AS type, ej.status::text AS status
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}'`
);
expect(jobs.length).toBe(2);
for (const j of jobs) expect(j.status, `${j.type} job status`).toBe('done');
// Download the ZIP and prove it is INTACT — a temp-file race would corrupt or
// truncate it. `unzip -t` verifies every entry's CRC; a non-zero exit throws.
const ticket = await mintTicket(host.jwt);
const res = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(200);
const bytes = Buffer.from(await res.arrayBuffer());
// ZIP local-file-header magic — a stomped/half-written file fails this immediately.
expect(bytes.subarray(0, 2).toString('latin1')).toBe('PK');
const dir = mkdtempSync(join(tmpdir(), 'es-zip-'));
const zipPath = join(dir, 'Gallery.zip');
writeFileSync(zipPath, bytes);
// Integrity: `unzip -t` exits 0 only if all CRCs check out and the archive is whole.
execFileSync('unzip', ['-t', zipPath], { stdio: 'pipe' });
// Completeness: exactly one entry per seeded upload — no entry lost to a stomped
// temp file, none duplicated by a second worker.
const listing = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.endsWith('/'));
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(N_UPLOADS);
});
test('re-release does NOT disturb an in-flight (running) export job — the anti-race guard', async ({
host,
}) => {
// Deterministic proof of the fix, independent of export timing. We simulate a worker
// that is still mid-export by pinning the zip job to `running` with a sentinel
// progress value, then drive reopen→re-release. The fix's
// ON CONFLICT ... DO UPDATE ... WHERE export_job.status <> 'running'
// must leave that row untouched (and the freshly-spawned worker's
// claim_job: UPDATE ... WHERE status = 'pending'
// finds nothing to claim, so it bails without racing the temp file).
//
// On the OLD code (unconditional ON CONFLICT DO UPDATE) the row would be reset to
// pending/progress=0 and a second worker would claim and run concurrently — so the
// sentinel below would be wiped. This assertion therefore fails on the bug and passes
// on the fix.
await seedUpload(host.jwt, { caption: 'a' });
await seedUpload(host.jwt, { caption: 'b' });
// Release and let the first export fully finish so no real worker is active.
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await expect
.poll(async () => {
const s = await exportStatus(host.jwt);
return s.zip?.status === 'done' && s.html?.status === 'done';
}, { timeout: 60_000, intervals: [500] })
.toBe(true);
// Simulate an in-flight worker owning the zip job, with a sentinel we can check.
await pgQuery(
`UPDATE export_job ej SET status = 'running', progress_pct = 77
FROM event e
WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
// Reopen (clears release + ready flags, does NOT touch job rows) then re-release.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Give any spawned worker a beat to attempt (and, correctly, bail) its claim.
await new Promise((r) => setTimeout(r, 750));
// The guard must have preserved the 'running' sentinel — untouched by both the
// re-enqueue and the bailing worker.
const [row] = await pgQuery<{ status: string; progress_pct: number }>(
`SELECT ej.status::text AS status, ej.progress_pct
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
);
expect(row.status, 'zip job status').toBe('running');
expect(Number(row.progress_pct), 'zip job sentinel progress').toBe(77);
});
});

View File

@@ -0,0 +1,100 @@
/**
* Re-review regression guard — Chain B1 ("offline queue must auto-resume").
*
* Bug that was fixed: a file staged while OFFLINE was attempted immediately;
* the XHR network error marked the queue item `error`, and every resume path
* (the `online` listener, `processQueue`, `loadQueue`) only picks up `pending`
* items — so the item was stranded until a manual "Erneut" tap.
*
* The fix: network failures are a distinct `NetworkError` that keeps the item
* `pending`; `processQueue` bails while `navigator.onLine === false` (so an
* offline-staged item is never burned to `error`); and `requeueRetriable()`
* flips transient `error`→`pending` on the `online` event and on mount.
*
* This test drives a REAL browser going offline → stage+submit → reconnect and
* asserts the upload lands with NO manual interaction. It fails on the old code
* (item errors, never resumes) and passes on the fix.
*/
import { test, expect } from '../../fixtures/test';
import { FeedPage, UploadSheet } from '../../page-objects';
import { join } from 'node:path';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
// White-box: read the upload queue straight out of IndexedDB so we can assert
// the item's status without depending on the (currently unrendered) queue widget.
async function queueStatuses(page: import('@playwright/test').Page): Promise<string[]> {
return page.evaluate(async () => {
const statuses = await new Promise<string[]>((resolve, reject) => {
const req = indexedDB.open('eventsnap-uploads', 3);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const dbh = req.result;
const tx = dbh.transaction('queue', 'readonly');
const all = tx.objectStore('queue').getAll();
all.onsuccess = () => resolve(all.result.map((r: any) => r.status));
all.onerror = () => reject(all.error);
};
});
return statuses;
});
}
test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
test('offline-staged upload stays pending, then flushes on reconnect with no manual tap', async ({
page,
guest,
signIn,
db,
}) => {
const g = await guest('OfflineResume');
await signIn(page, g);
await page.goto('/feed');
expect(await db.countUploadsForUser(g.userId)).toBe(0);
// Warm the code-split `/upload` route chunk while still ONLINE. Without this, the
// client-side navigation the UploadSheet triggers would try to fetch the chunk with
// no connectivity and hit SvelteKit's error boundary — a test artifact unrelated to
// the upload-queue fix under test. (A production PWA service worker would cache it.)
await page.goto('/upload');
await page.goto('/feed');
// Go offline BEFORE staging so the first attempt happens with no connectivity.
await page.context().setOffline(true);
const feed = new FeedPage(page);
const sheet = new UploadSheet(page);
await feed.openUploadSheet();
await sheet.stageFiles([SAMPLE_JPG]);
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
await sheet.fillCaption('shot with no signal');
await sheet.submit();
// handleSubmit navigates to /feed after enqueuing even while offline.
await page.waitForURL('**/feed', { timeout: 15_000 });
// While offline: nothing may have reached the server, and — the crux of the
// fix — the item must be parked as `pending`, NOT burned to `error`/`blocked`
// (which on the old code would require a manual retry tap).
await expect
.poll(() => queueStatuses(page), { timeout: 8_000 })
.toEqual(['pending']);
expect(await db.countUploadsForUser(g.userId)).toBe(0);
// Reconnect. The `online` listener must resume the queue automatically — we do
// NOT navigate, tap retry, or otherwise touch the page.
await page.context().setOffline(false);
// The queued upload is now created server-side without any manual interaction.
await expect
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
.toBe(1);
// And the queue item ends terminal-good (done) or is drained — never stuck in error.
await expect
.poll(async () => {
const s = await queueStatuses(page);
return s.every((x) => x === 'done') || s.length === 0;
}, { timeout: 10_000 })
.toBe(true);
});
});

View File

@@ -16,6 +16,10 @@ COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./
RUN npm install --omit=dev
# Run as the image's built-in non-root `node` user.
RUN chown -R node:node /app
USER node
EXPOSE 3001
ENV PORT=3001 HOST=0.0.0.0
CMD ["node", "build"]

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,9 @@
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
@@ -18,13 +20,16 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"@types/qrcode": "^1.5.6",
"jsdom": "^29.1.1",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.1.9"
},
"dependencies": {
"@tanstack/svelte-virtual": "^3.13.30",
"idb": "^8.0.3",
"qrcode": "^1.5.4"
}

View File

@@ -1 +1,15 @@
@import "./tailwind-theme.css";
/* Respect the OS "reduce motion" setting. Collapses every animation/transition
* (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a
* near-instant change rather than removing them outright, so state still updates. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,16 +1,30 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- viewport-fit=cover activates the env(safe-area-inset-*) padding used by the
bottom nav, sheets, toasts and FAB on notched / home-indicator devices. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="text-scale" content="scale" />
<meta name="theme-color" content="#ffffff" />
<!-- Light/dark theme-color so the browser chrome matches the active theme. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#030712" media="(prefers-color-scheme: dark)" />
<!-- Installable PWA keepsake: manifest + Apple home-screen metadata. -->
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="EventSnap" />
<!--
FOUC guard: apply the dark class *before* paint, so reloads of pages with
theme=dark don't flash a white screen. Mirrors the logic in
`src/lib/theme-store.ts`; kept in sync by hand (it's 6 lines).
-->
<script>
<!-- nonce is required: our CSP sets script-src 'self', and SvelteKit's
mode:'auto' only hashes scripts *it* injects, not this template-authored
one. %sveltekit.nonce% is substituted per request and added to the CSP. -->
<script nonce="%sveltekit.nonce%">
(function () {
try {
var pref = localStorage.getItem('eventsnap_theme') || 'system';

View File

@@ -1,9 +1,14 @@
// Svelte action — fires a `doubletap` CustomEvent when two pointerup events occur
// within `interval` ms on roughly the same spot. Used in the lightbox for the
// Instagram-style "double-tap to like" gesture.
// Svelte action — the single source of truth for tap gestures on a media element.
// Fires a `doubletap` CustomEvent when two pointerup events occur within `interval`
// ms on roughly the same spot (Instagram-style "double-tap to like"), and a
// `singletap` CustomEvent once `interval` has elapsed with no second tap. Owning
// both gestures in one place means a component no longer juggles its own debounce
// timer alongside a separate onclick handler.
//
// Native `dblclick` exists, but on iOS Safari it also zooms the page; gating on
// pointer events lets us preventDefault selectively and avoid the zoom.
// pointer events lets us preventDefault selectively and avoid the zoom. Keyboard
// activation still arrives as a normal `click` (detail === 0) — handle that on the
// element itself for an immediate, latency-free open.
import type { ActionReturn } from 'svelte/action';
@@ -16,6 +21,7 @@ export interface DoubletapOptions {
interface DoubletapAttributes {
'ondoubletap'?: (event: CustomEvent<void>) => void;
'onsingletap'?: (event: CustomEvent<void>) => void;
}
export function doubletap(
@@ -26,12 +32,21 @@ export function doubletap(
let lastTime = 0;
let lastX = 0;
let lastY = 0;
let singleTimer: ReturnType<typeof setTimeout> | null = null;
const clearSingle = () => {
if (singleTimer) {
clearTimeout(singleTimer);
singleTimer = null;
}
};
const onPointerUp = (e: PointerEvent) => {
const now = performance.now();
const dx = Math.abs(e.clientX - lastX);
const dy = Math.abs(e.clientY - lastY);
if (now - lastTime < interval && dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD) {
clearSingle(); // the pending single-tap was actually the first of a double
e.preventDefault();
node.dispatchEvent(new CustomEvent('doubletap'));
lastTime = 0; // reset so a triple-tap doesn't re-fire
@@ -40,6 +55,12 @@ export function doubletap(
lastTime = now;
lastX = e.clientX;
lastY = e.clientY;
// Defer the single-tap action until we're sure no second tap follows.
clearSingle();
singleTimer = setTimeout(() => {
singleTimer = null;
node.dispatchEvent(new CustomEvent('singletap'));
}, interval);
};
node.addEventListener('pointerup', onPointerUp);
@@ -49,6 +70,7 @@ export function doubletap(
interval = newOptions.interval ?? INTERVAL_MS;
},
destroy() {
clearSingle();
node.removeEventListener('pointerup', onPointerUp);
}
};

View File

@@ -17,8 +17,11 @@ const FOCUSABLE =
'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
function focusables(root: HTMLElement): HTMLElement[] {
// `offsetParent` is null for any `position: fixed` element (and our sheets/modals
// are fixed), so it would wrongly drop their buttons. `getClientRects().length`
// is true whenever the element is actually rendered — fixed or not.
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
(el) => !el.hasAttribute('disabled') && el.getClientRects().length > 0
);
}
@@ -61,11 +64,16 @@ export function focusTrap(
node.addEventListener('keydown', onKeyDown);
if (opts.autoFocus !== false) {
// Defer one frame so the element is fully laid out (sheets animate in).
// Focus the container synchronously on mount so the trap owns the keyboard
// immediately — otherwise an Escape pressed before the deferred focus below
// lands on an element *outside* the node, where this node-scoped listener
// never sees it (the keystroke is silently dropped). Then defer one frame to
// move focus onto the first control once the sheet has laid out / animated in.
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1');
node.focus({ preventScroll: true });
requestAnimationFrame(() => {
const list = focusables(node);
const target = list[0] ?? node;
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
});
}

View File

@@ -0,0 +1,29 @@
// Make everything *outside* a modal's subtree inert while it's open, so screen
// readers and tab order can't reach the background (focus-trap only covers
// keyboard focus; `inert` also hides the content from assistive tech).
//
// Walks from the node up to <body> and marks every sibling along the path as
// inert, then restores exactly those it changed on teardown. Applying it to a
// `display: contents` wrapper keeps the modal's own backdrop + dialog interactive.
export function modalInert(node: HTMLElement) {
const changed: HTMLElement[] = [];
let el: HTMLElement | null = node;
while (el && el !== document.body) {
const parent: HTMLElement | null = el.parentElement;
if (!parent) break;
for (const sib of Array.from(parent.children)) {
if (sib !== el && sib instanceof HTMLElement && !sib.hasAttribute('inert')) {
sib.setAttribute('inert', '');
changed.push(sib);
}
}
el = parent;
}
return {
destroy() {
for (const sib of changed) sib.removeAttribute('inert');
}
};
}

View File

@@ -22,8 +22,17 @@ export function pullToRefresh(
): ActionReturn<PullToRefreshOptions> {
let opts = options;
let startY = 0;
let startX = 0;
let pulling = false;
let triggered = false;
let intentLocked = false; // have we decided this gesture is a vertical pull?
// Distance the finger must travel before we commit to "this is a vertical pull"
// vs. a horizontal swipe or an incidental tap.
const INTENT_SLOP = 8;
// Rubber-band resistance: the sheet follows the finger at a fraction of 1:1 so
// the pull feels elastic rather than rigid.
const RESISTANCE = 0.5;
function scroller(): HTMLElement | (Window & typeof globalThis) {
return node.scrollHeight > node.clientHeight ? node : window;
@@ -40,17 +49,54 @@ export function pullToRefresh(
opts.onpull(Math.max(0, delta), Math.max(0, delta) / threshold);
}
function reset() {
pulling = false;
intentLocked = false;
}
function onTouchStart(e: TouchEvent) {
if (opts.disabled) return;
// Ignore pinch / multi-finger gestures entirely.
if (e.touches.length !== 1) {
reset();
return;
}
if (scrollTop() > 0) return;
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
pulling = true;
triggered = false;
intentLocked = false;
}
function onTouchMove(e: TouchEvent) {
if (!pulling || triggered) return;
const delta = e.touches[0].clientY - startY;
// A second finger landing mid-gesture cancels the pull.
if (e.touches.length !== 1) {
reset();
return;
}
const rawDy = e.touches[0].clientY - startY;
const dx = e.touches[0].clientX - startX;
// Decide intent once, after the finger has moved past the slop. A
// horizontal-dominant or upward move is not a pull — bail and let the
// browser scroll normally.
if (!intentLocked) {
if (Math.abs(rawDy) < INTENT_SLOP && Math.abs(dx) < INTENT_SLOP) return;
if (rawDy <= 0 || Math.abs(rawDy) <= Math.abs(dx)) {
reset();
return;
}
intentLocked = true;
}
// Committed vertical pull at the top edge: stop the browser's own
// overscroll / pull-to-refresh from competing for the gesture.
if (e.cancelable) e.preventDefault();
const delta = rawDy * RESISTANCE;
reportPull(delta);
if (delta > (opts.threshold ?? 60)) {
triggered = true;
@@ -60,11 +106,12 @@ export function pullToRefresh(
function onTouchEnd() {
if (pulling && !triggered) reportPull(0);
pulling = false;
reset();
}
node.addEventListener('touchstart', onTouchStart, { passive: true });
node.addEventListener('touchmove', onTouchMove, { passive: true });
// Non-passive so we can preventDefault() once a top-edge pull is in progress.
node.addEventListener('touchmove', onTouchMove, { passive: false });
node.addEventListener('touchend', onTouchEnd);
node.addEventListener('touchcancel', onTouchEnd);

View File

@@ -0,0 +1,50 @@
// Body scroll-lock for open dialogs/sheets. While any locker is active, the
// document body can't scroll behind the overlay. Ref-counted so that nested or
// stacked dialogs (e.g. a ConfirmSheet opened from the LightboxModal) don't let
// the first one to close unlock the page out from under the others. Compensates
// for the vanished scrollbar width so the layout doesn't jump on lock.
import type { ActionReturn } from 'svelte/action';
let lockCount = 0;
let savedOverflow = '';
let savedPaddingRight = '';
function lock() {
if (typeof document === 'undefined') return;
if (lockCount === 0) {
const body = document.body;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
savedOverflow = body.style.overflow;
savedPaddingRight = body.style.paddingRight;
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
const current = parseFloat(getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${current + scrollbarWidth}px`;
}
}
lockCount++;
}
function unlock() {
if (typeof document === 'undefined') return;
lockCount = Math.max(0, lockCount - 1);
if (lockCount === 0) {
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPaddingRight;
}
}
/**
* Locks body scroll for the lifetime of the node. Mount the node only while the
* dialog is open (e.g. inside `{#if open}` or alongside a `class:` toggle) so the
* action's create/destroy line up with open/close.
*/
export function scrollLock(node: HTMLElement): ActionReturn {
lock();
return {
destroy() {
unlock();
}
};
}

View File

@@ -13,6 +13,8 @@ export class ApiError extends Error {
}
}
const TIMEOUT_MS = 20_000;
async function request<T>(
method: string,
path: string,
@@ -27,23 +29,53 @@ async function request<T>(
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
// Abort hung requests so a dead connection surfaces as a friendly error
// instead of a spinner that never resolves.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new ApiError(0, 'timeout', 'Zeitüberschreitung bitte erneut versuchen.');
}
throw new ApiError(0, 'network', 'Netzwerkfehler bitte Verbindung prüfen.');
} finally {
clearTimeout(timer);
}
if (res.status === 204) {
return undefined as T;
}
const data = await res.json();
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
const raw = await res.text();
let data: { error?: string; message?: string } | unknown = null;
if (raw) {
try {
data = JSON.parse(raw);
} catch {
data = null;
}
}
if (!res.ok) {
// 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) {
clearAuth();
}
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
const d = (data ?? {}) as { error?: string; message?: string };
throw new ApiError(res.status, d.error ?? 'unknown', d.message ?? `Serverfehler (${res.status}).`);
}
return data as T;

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
// auth.ts guards every localStorage access behind `browser`; force it true so the
// jsdom localStorage is actually used (the shared mock stubs it to false).
vi.mock('$app/environment', () => ({ browser: true, dev: false, building: false, version: 'test' }));
import { setAuth, getToken, getPin, getExpiry, getRole, clearAuth, clearPin } from './auth';
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
function makeJwt(claims: object): string {
const seg = (o: object) => btoa(JSON.stringify(o));
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
}
beforeEach(() => localStorage.clear());
describe('auth — token storage', () => {
it('setAuth then getToken/getPin round-trips', () => {
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
expect(getToken()).toBe('a.b.c');
expect(getPin()).toBe('1234');
});
it('setAuth without a PIN leaves the PIN unset', () => {
setAuth('a.b.c', null, 'uid-1');
expect(getToken()).toBe('a.b.c');
expect(getPin()).toBeNull();
});
it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => {
setAuth('a.b.c', '1234', 'uid');
clearAuth();
expect(getToken()).toBeNull();
expect(getPin()).toBe('1234');
});
it('clearPin removes the cached PIN', () => {
setAuth('a.b.c', '1234', 'uid');
clearPin();
expect(getPin()).toBeNull();
});
});
describe('auth — JWT claim decode', () => {
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
const exp = 2_000_000_000; // far-future unix seconds
setAuth(makeJwt({ exp }), null, 'u');
expect(getExpiry()?.getTime()).toBe(exp * 1000);
});
it('getExpiry is null with no token, no exp claim, or a malformed token', () => {
expect(getExpiry()).toBeNull(); // no token
setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp
expect(getExpiry()).toBeNull();
localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable
expect(getExpiry()).toBeNull();
});
it('getRole extracts the role claim; null when absent or malformed', () => {
setAuth(makeJwt({ role: 'host' }), null, 'u');
expect(getRole()).toBe('host');
setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim
expect(getRole()).toBeNull();
localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload
expect(getRole()).toBeNull();
});
});

View File

@@ -83,6 +83,9 @@ export function clearAuth(): void {
if (!browser) return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_ID_KEY);
// Clear the display name too — on a shared device the next user shouldn't see
// the previous guest's name pre-filled on /join.
localStorage.removeItem(DISPLAY_NAME_KEY);
// PIN is intentionally kept so the user can recover
isAuthenticated.set(false);
// Hooks fire in registration order. Keep them dependency-free of each other —

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { avatarPalette, initials } from './avatar';
describe('avatarPalette', () => {
it('returns the neutral palette for empty / nullish names', () => {
expect(avatarPalette(null)).toContain('bg-gray-100');
expect(avatarPalette(undefined)).toContain('bg-gray-100');
expect(avatarPalette('')).toContain('bg-gray-100');
});
it('is deterministic for the same name', () => {
expect(avatarPalette('Alice')).toBe(avatarPalette('Alice'));
});
it('returns a real palette entry (not neutral) for a non-empty name', () => {
expect(avatarPalette('Bob')).toMatch(/bg-(blue|purple|green|amber|rose|teal)-100/);
});
});
describe('initials', () => {
it('returns "?" for empty / nullish / whitespace-only names', () => {
expect(initials(null)).toBe('?');
expect(initials(undefined)).toBe('?');
expect(initials('')).toBe('?');
expect(initials(' ')).toBe('?');
});
it('uses the first letter (uppercased) for a single word', () => {
expect(initials('alice')).toBe('A');
});
it('uses the first letters of the first two words', () => {
expect(initials('Alice Bob Carol')).toBe('AB');
});
it('collapses runs of whitespace between words', () => {
expect(initials(' john doe ')).toBe('JD');
});
});

Some files were not shown because too many files have changed in this diff Show More