53 Commits

Author SHA1 Message Date
MechaCat02
77d55e941c harden(deploy): add app/frontend healthchecks + readiness-gate Caddy
Caddy depended on app/frontend with no readiness gate, so it could proxy to a
not-yet-listening upstream and surface brief 502s on boot/restart. Add
wget-based healthchecks (busybox wget ships in both alpine runtime images):
app hits the unauthenticated /health, frontend hits / (SSR returns 200; the
redirect is client-side). app uses a 30s start_period to cover boot-time
migrations. Caddy now depends_on both with condition: service_healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:38:51 +02:00
MechaCat02
a895bebdc2 harden(deploy): close public DB exposure + make JWT prod guard effective
Two HIGH deployment fixes found in the go-live review, plus two cheap backstops.

HIGH 1 — Postgres was published to the public internet. The committed,
auto-merged docker-compose.override.yml mapped 5432:5432 on 0.0.0.0 (and a
Docker publish bypasses ufw), so a `docker compose up` on the prod host exposed
the DB with the .env password. Bind the dev port to 127.0.0.1 so it's reachable
only from the host's loopback even when the override lands in prod. .env.example
also stops shipping `secret` as the DB password (now CHANGE_ME_*).

HIGH 2 — the JWT-secret production guard was inert. APP_ENV was never set to
production (defaulted to development), so the guard never ran; and it only
matched one stale dev sentinel, not the `.env.example` placeholder that an
operator is most likely to leave in place. Now: docker-compose.yml forces
APP_ENV=production on the app service, and config.rs refuses to boot in
production on any placeholder (change/example/placeholder/replace) or any secret
< 64 chars. Verified: placeholder and short secrets are rejected; a 128-char
secret passes. This turns "looks protected" into actually protected.

Backstops:
- frontend container now runs as the non-root `node` user (the backend was
  already hardened; the frontend wasn't).
- app gets mem_limit 3g so a miss in the in-app upload RAM caps OOM-kills the
  container instead of the 8 GB host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:35:57 +02:00
MechaCat02
2d7169e971 harden: low-severity bucket — unspoofable IP, admin floor, log/SSE hygiene
The cheap, real LOWs from the review (bucket 🅰); 🅱 items and won't-fix
decisions are recorded in docs/SECURITY-BACKLOG.md.

- XFF spoofing (highest-value): client_ip now prefers an unspoofable X-Real-IP
  (Caddy `header_up X-Real-IP {remote_host}`) and otherwise takes the *rightmost*
  X-Forwarded-For token, never the client-controlled leftmost. The join cap,
  recover throttle, and admin-login floor all keyed on this. Unit-tested.
  Caddyfile also drops the dead /media/previews|originals cache matchers (the
  gateway sets its own Cache-Control) and the false "only host can download"
  comment.
- admin_login: add a hard, non-disableable rate floor (30/5min/IP) so the DB
  rate-limit master toggle can't remove brute-force protection.
- SSE: the broadcast upload-error payload no longer includes the raw error
  (absolute filesystem paths) — generic message to clients, details logged
  server-side only.
- Access logs: the request span logs the path only, never the query string, so
  the replayable media `?sig=` capability isn't written to logs.
- Reaper TOCTOU: reap_deleted now deletes rows by the ids it actually unlinked
  (DELETE … WHERE id = ANY) instead of re-evaluating the time predicate, so a
  row crossing the grace line mid-sweep can't be row-deleted without its files
  unlinked.
- Dockerfile: run the backend as a non-root user; create /media owned by `app`
  so a fresh volume is writable.
- a11y: aria-labels on the upload caption and feed search inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:01:41 +02:00
MechaCat02
db1e5b8833 fix: post-review punch-list — upload concurrency cap, atomic release, ban SSE
Follow-up to the adversarial re-review of the audit-fix branch.

MUST-FIX:
- H3: bound aggregate upload RAM. The body limit alone didn't cap concurrency,
  so ~N parallel 550MB uploads could OOM the box. Add an Arc<Semaphore>
  (UPLOAD_MAX_CONCURRENCY=4) in AppState, acquired at the top of the upload
  handler before the multipart body is read, so waiting requests hold only a
  connection — peak buffered RAM ≈ 4×550MB. (Matches the CompressionWorker
  semaphore pattern; avoids tower's non-default `limit` feature.)
- M8: release_gallery now runs the claim UPDATE + both job INSERTs in one
  transaction, so the row lock is held until the jobs exist — closing the
  cross-table TOCTOU where two concurrent presses could both spawn workers
  racing the same output files. Export temp filenames are now per-run
  (Gallery.{uuid}.zip.tmp, viewer_tmp_{event}_{uuid}, Memories.{uuid}.zip.tmp)
  so overlapping runs can't truncate each other. Final served names unchanged.
- M11: 'user-banned' was missing from sse.ts KNOWN_EVENTS, so EventSource
  silently dropped the frame and the forced-logout handler never fired. Added.

SHOULD-FIX:
- M8-3: re-release is now allowed when nothing is in progress and at least one
  job failed (was: only when *every* job failed), so a one-sided failure (zip
  done, html failed) is no longer permanently unrecoverable.
- M18: two light-mode contrast spots the earlier sweep missed — the LightboxModal
  char-counter class: directives and the account PIN-missing notice.
- M15: the diashow auto-advance is now slowed to a ≥30s floor under
  prefers-reduced-motion (WCAG 2.2.2), making the app.css comment accurate.

Verified: cargo build + 6 tests, npm run check (0 errors), and a live smoke test
against Postgres — first release 204, second 400, one-sided-failure re-release
204, no SQL errors; a normal upload still returns 201 through the new permit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:40:54 +02:00
MechaCat02
d4181b1119 chore(git): ignore local audit/review reports
The audit + fix-verification reports (docs/AUDIT-2026-06-27.md,
docs/FIX-VERIFICATION-2026-06-27.md) are local working artifacts; one was
accidentally swept into the media-gateway commit. Dropped from history and
ignored so they stay untracked local files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:22:20 +02:00
MechaCat02
23a7d89a89 fix(migrations): renumber feed-view migration 006 → 010
The new v_feed-rewrite migration collided with the existing 006_user_pin_lockout
(versions must be unique). Verified against a fresh Postgres: all migrations
001–010 now apply cleanly on startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:47:27 +02:00
MechaCat02
8272197cea fix(auth): escalating recovery-PIN lockout backoff (M4)
The failed-attempt counter is no longer reset when the lockout window expires,
so each further wrong PIN past the 3-strike threshold doubles the lockout —
15min, 30, 60, … capped at 24h. Combined with the now 6-digit PIN (1M space),
this makes sustained guessing via /recover infeasible. A successful recovery or
a host PIN reset clears the counter; legitimate users (who don't fail) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:28:31 +02:00
MechaCat02
0737288ed9 a11y: viewport-fit, reduced-motion, like aria-pressed, labels, contrast (M13-M18)
M13: add viewport-fit=cover so env(safe-area-inset-*) resolves on notched phones.
M14: like buttons (list card, grid overlay, lightbox) get aria-pressed + a
descriptive aria-label so AT announces self-state, not just the shared count.
M15: a prefers-reduced-motion media query neutralizes the decorative keyframes
(Ken Burns, crossfade, HeartBurst) and snaps transitions.
M16: join/recover name + PIN inputs and the lightbox comment input get aria-labels.
M17: FeedGrid overlay like/comment buttons get aria-labels and ≥44px hit areas.
M18: bump light-mode secondary text from gray-400 to gray-500 (keeping
dark:text-gray-400) across the app for WCAG AA contrast.

Also raises the PIN inputs to 6 digits (placeholder, maxlength, slice, and the
auto-submit threshold) to match the new 6-digit generated PINs; legacy 4-digit
PINs still submit via the button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:26:21 +02:00
MechaCat02
5bd008591b fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal +
accessible name, a focus-trap/Escape/focus-restore $effect mirroring
ContextSheet, and inert + pointer-events-none when closed so its controls leave
the tab order and AT tree.

H12: a layout-level $effect toasts each upload-queue item the first time it
transitions to 'error', so a banned/locked/over-quota guest is actually told
why their photo didn't post instead of the composer silently closing.

M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against
loops on the auth screens), so a 401 no longer strands the user on a dead page.

M11: ban and event-lock are pushed to guests. A new uploadsLocked store is
seeded from /me/context (now exposes uploads_locked) and kept live by
event-closed/opened SSE; the feed shows a banner and the FAB is disabled when
locked. A user-banned SSE event force-logs-out the targeted user.

M19: feed and export pages track a distinct loadError and render an "Erneut
versuchen" retry card instead of collapsing a fetch failure into "empty" /
"not released". Light-mode empty-state text bumped to gray-500 for contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:16:01 +02:00
MechaCat02
cf428725b9 perf(feed): rewrite v_feed, bound feed_delta, server-time SSE cursor (H6,H7,M9)
H6: migration 006 replaces v_feed's double LEFT JOIN + COUNT(DISTINCT) (which
materialized a likes×comments Cartesian per upload) with correlated scalar
subqueries that each use their own index. Output columns are unchanged, so
feed + hashtag-filtered paths both benefit.

H7: feed_delta now applies the feed rate limit (keyed per user), caps results
at 200 rows, and clamps how far back a client `since` may reach (7 days). When
clamped or capped it returns reload_required=true; the SSE client turns that
into a full feed reload instead of streaming the whole gallery through the view
on every tab refocus.

M9: the SSE reconnect cursor is now advanced only from server timestamps (an
upload's created_at, seeded from the feed and updated on new-upload events and
delta responses), never the client clock — so a skewed phone clock can't drop
events missed while backgrounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:58:35 +02:00
MechaCat02
ae6c496f94 fix(authz): event-scope social handlers, atomic gallery release (M1, M3, M8)
M1: toggle_like / add_comment / list_comments now resolve the target via
Upload::find_by_id_and_event, returning 404 for uploads outside the caller's
event or already soft-deleted — closing the cross-event IDOR and the
write-to-deleted-post path.

M3: the redundant in-handler is_banned fetches (upload, like, comment) are
removed; the AuthUser extractor (WS1) already rejects banned users on every
authenticated route, which also covers the previously-ungated edit_upload /
delete_upload / delete_comment.

M8: release_gallery claims the release with a single conditional UPDATE and only
proceeds when it matches a row, so concurrent presses can't spawn duplicate
export workers racing on the same files. Re-release is now permitted when all
prior export jobs terminally failed (e.g. a crash mid-export), and the job
upsert resets failed rows so the workers re-run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:43:12 +02:00
MechaCat02
9364cb624a fix(dos): bound uploads, offload bcrypt, cap image decode, split queues
H3: re-enable a request-body limit (550MB, sized to the largest allowed video +
multipart overhead) instead of DefaultBodyLimit::disable(), and drop the
second full-file copy by keeping the upload as Bytes.

H4: image decode now goes through ImageReader with explicit dimension
(12000x12000) and allocation caps, rejecting decompression bombs before the
expensive resize, and the whole spawn_blocking is wrapped in a 120s timeout
mirroring the ffmpeg guard.

H8: every bcrypt hash/verify (join, recover, admin_login, host PIN reset) now
runs via spawn_blocking through a new services::password helper, so concurrent
auths can't pin the async workers.

M7: images and videos get independent compression permit pools, so slow videos
can no longer starve image-preview generation.

H5: add a generous per-IP daily account-creation cap (NAT-safe at ~100 guests)
on top of the burst cap, and wire up the previously-dead upload_count_quota_*
config as an event-wide file-count cap that a re-join cannot reset.

Also bumps generated recovery PINs from 4 to 6 digits (part of M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:38:59 +02:00
MechaCat02
8faf702208 feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:

- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
  download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
  CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
  uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
  get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
  commit; soft_delete returns the paths; an hourly reaper reclaims disk for
  rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).

Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).

Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:30:30 +02:00
MechaCat02
ab9f1d89b2 fix(upload): server-side MIME/ext allowlist + atomic transactional write
C2: stored-XSS via the application/* MIME bypass is closed — the declared
Content-Type and client filename are no longer trusted. New classify() derives
both the canonical MIME and a safe extension purely from magic bytes against a
strict allowlist (jpeg/png/webp/heic/mp4/mov/webm); anything infer can't
positively identify (incl. HTML/SVG script payloads) is rejected. Unit tests
cover accept + reject paths.

M12: the stored extension can no longer carry '/' or arbitrary client text —
it's one of the allowlist's safe constants, ending directory-pollution and the
export DoS.

M5/M6: quota is now reserved with a single conditional UPDATE that row-locks
(no TOCTOU overshoot) and the reservation + row INSERT run in one transaction;
the file is written before commit and unlinked on any rollback, so a crash
mid-write can no longer leak quota bytes or orphan a row. Upload::create is now
executor-generic to run inside the tx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:19:34 +02:00
MechaCat02
2068e8c1f3 fix(auth): reconcile role/ban from DB, revoke sessions, fix pin/comment bugs
H1: AuthUser now JOINs session→user and sources role/ban/event from the live
DB row instead of trusting JWT claims. Bans take effect on the next request;
demotion/promotion is immediate. Adds Session::find_auth_context and rejects
tokens whose sub/event_id disagree with the session row. This also closes M2
(banned export) and M3 (banned edit/delete) globally — banned users can no
longer pass the AuthUser extractor.

Adds Session::delete_by_user_id and revokes all of a user's sessions on
ban_user, set_role, and reset_user_pin so existing JWTs die immediately.
ban_user also emits a user-banned SSE for forced client logout.

H9: reset_user_pin used a non-existent column pin_failed_attempts (runtime
sqlx, so it 500'd in prod). Corrected to failed_pin_attempts — the only
account-recovery path now works.

C4: LightboxModal posted comments to /comment (singular); backend only
registers /comments. One-character route fix re-enables the comment feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:14:22 +02:00
MechaCat02
bbec815854 Merge feat/ux-review-followups: UX review batch fixes + shared primitives
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
2026-05-24 22:50:34 +02:00
MechaCat02
309c25bc06 feat(frontend): UX review followups — primitives + a11y/UX fixes across 4 passes
New shared primitives:
- Toaster + toast-store, ConfirmSheet, Modal, focusTrap action,
  pullToRefresh action, avatarPalette + initials helper, Skeleton,
  HeartBurst, haptics, export-status store with onClearAuth hook

Critical UX/a11y:
- Replaced window.confirm with branded ConfirmSheet
- Focus management + Escape on every modal (PIN, Lightbox,
  Onboarding, ContextSheet, data-mode sheet, leave-confirm,
  HTML guide, host/admin ban + PIN-display modals)
- Sheet backdrops are real buttons with aria-label
- Silent ApiError catches now surface via global Toaster

Major polish:
- Dark-mode parity on HashtagChips + avatars (shared palette)
- Conditional Export tab in BottomNav (badge dot when ZIP ready)
- Back chevrons on /recover (history-aware) and /export
- Upload composer discard confirmation when content is staged
- Camera segmented Photo/Video shutter
- PIN auto-submit on 4th digit, paste-flash-free (controlled input)
- Welcome-back toast on /feed after PIN recovery

Minor:
- Skeleton states on feed; pull-to-refresh with live drag indicator
- Haptics on like / capture / submit / PIN-copy / onboarding complete
- Comment 500-char counter; quota "Fast voll" / "Limit erreicht" labels
- Onboarding pip ≥24px tap targets; long-press hint step
- overscroll-behavior lock on <html> while feed mounted
- teardownExportStatus wired via onClearAuth (covers 401 + explicit logout)
- ConfirmSheet per-instance titleId; Modal requires titleId or ariaLabel

Tests (7 new Playwright specs):
- 01-auth/pin-auto-submit, 01-auth/back-chevron
- 03-feed/confirm-sheet-delete, 03-feed/toast-on-failure
- 09-mobile/focus-trap, 09-mobile/sheet-escape,
  09-mobile/upload-cancel-confirm

FOLLOWUPS.md captures the deferred AT inert containment work
with acceptance criteria + implementation sketches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:50:28 +02:00
MechaCat02
b241ba6415 Merge fix/security-review-followups: security review batch fixes
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
2026-05-17 21:00:55 +02:00
MechaCat02
d228676a56 fix(security): cross-event authz, SSE ticket flow, account hardening, audit logs
Follow-up to the comprehensive code review. Five batches:

1. Cross-event authorization: host_delete_upload, unban_user, and
   host_delete_comment now scope by auth.event_id. Adds
   Upload::find_by_id_and_event / soft_delete_in_event and a
   Comment::soft_delete_in_event variant that joins through upload.

2. Token exposure: SSE auth no longer puts the JWT in the URL.
   New /api/v1/stream/ticket endpoint mints a short-lived single-use
   ticket bound to the session; the EventSource passes ?ticket=...
   instead. Refuse to start in APP_ENV=production with the dev JWT
   sentinel; warn loudly otherwise.

3. Account hardening: per-IP+name rate limit on /recover (mitigates
   targeted lockout DoS), per-IP rate limit on /admin/login, random
   32-char admin recovery PIN (replaces "0000"), structured tracing
   events for wrong PIN, lockout, failed admin login, ban/unban/role
   change/pin-reset/host-delete.

4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before=
   cursor), hashtag extraction whitelisted to ASCII alnum+underscore
   (≤40 chars) with unit tests, display_name / caption / comment body
   length validated in chars rather than bytes.

5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS
   env var (default 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:00:51 +02:00
MechaCat02
2340f21637 Merge feat/e2e-test-suite: Playwright E2E suite + small fixes
Adds a Playwright-driven end-to-end test suite under e2e/ with 134 tests
across 9 spec folders, running against an isolated docker-compose test
stack. Mobile-gesture coverage on a Pixel 7 viewport. Cross-UA smoke
matrix including a Tier-A Samsung Internet emulation. CI workflow in
.github/workflows/e2e.yml.

Also ships small fixes surfaced while writing the suite:

  Backend
  - JWT now carries a `jti` (per-token UUID) so two admin logins in the
    same wall-clock second don't collide on session.token_hash UNIQUE.
  - join handler rejects 0x00 in display names with a clean 400 (was 500
    from Postgres).
  - dev-only POST /api/v1/admin/__truncate route, registered only when
    EVENTSNAP_TEST_MODE=1.
  - rate_limiter.clear() for test isolation.
  - Dockerfile: rust:1.88, COPY ./migrations into the build context.

  Frontend
  - Removed `aria-hidden="true"` from the leave-confirm and data-mode
    backdrops on /account (it cascaded into the dialog content and made
    Abmelden / Aktivieren unreachable to a11y tools).
  - Bumped the PIN-copy button to ≥44×44 px touch target.
  - data-testid attributes on ~20 stable interactive elements
    (auth + upload routes).

Findings the suite surfaces as `[finding]` warnings on every run:
  1. /admin/login has no rate-limit or lockout.
  2. PIN-attempt counter races under parallel /recover requests.
  3. Zero-byte uploads pass /api/v1/upload.
  4. SVG-with-script can pass the magic-byte check.

Final test result: 134 passed / 0 failed / 9 skipped (test.fixme stubs
for planned gestures and one UI-upload-flow investigation).
WebKit/Firefox projects need libavif16 on the host to launch — configs
are in place; install with `sudo apt-get install libavif16` to enable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:37:42 +02:00
MechaCat02
e42d8a92a1 feat(e2e): Playwright suite — 134 tests across 9 spec areas + UA matrix
Adds an end-to-end Playwright test suite under e2e/ that spins up an
isolated docker-compose stack (Postgres :55432, Caddy :3101, backend with
EVENTSNAP_TEST_MODE=1, SvelteKit adapter-node frontend) and exercises the
SvelteKit app against the real Rust backend.

Phase 1 — happy paths covering every documented USER_JOURNEYS.md flow:
  01-auth/      join, recover, admin login, leave event, PIN lockout
  02-upload/    gallery picker (API path), rate-limit + admin toggle
  03-feed/      like/comment SSE, filters, SSE reconnect on visibility
  04-host/      event lock API, ban/unban, promote
  05-admin/     config validation, foundational authz guards, stats
  06-export/    /export status + download stub
  __smoke/      cross-UA happy-path (runs on every UA project)

Phase 2 — adversarial + browser chaos:
  07-adversarial/  XSS payloads (6 × display name path), SQLi shapes,
                   length / encoding / RTL override / NUL byte;
                   file-upload boundaries (ELF body claimed as JPEG,
                   oversize vs max_image_size_mb, zero-byte, NUL
                   filename, path-traversal, SVG-with-script);
                   JWT alg:none, signature/payload tamper, expired
                   session, PIN brute-force (serial + parallel),
                   admin password brute-force; deep authz (cross-user
                   delete, banned user across like/comment/feed-read,
                   host→admin escalation); small-scale DDoS (20× /join,
                   10MB comment body, 10 concurrent SSE).
  08-browser-chaos/ localStorage / sessionStorage / cookie purge,
                    IndexedDB drop mid-session, offline → reconnect,
                    slow-3G, 503 flakes, 429 with no retry storm,
                    multi-tab same/different user, no-JS, hostile CSS,
                    clock skew ±1h / -2d, localStorage quota exhausted.

Phase 3 — mobile gestures (runs only on chromium-mobile / Pixel 7):
  09-mobile/    touch-target ≥44px audit, env(safe-area-inset-bottom)
                structural check, long-press (FeedListCard → ContextSheet,
                quick-tap negation, click-suppression), double-tap
                (feed card like + lightbox heart-burst, via synthetic
                pointer events to bypass the first-tap-fires-click trap),
                viewport reflow (portrait/landscape/narrow/phablet),
                plus fixme stubs documenting planned gestures (swipe
                lightbox L/R, swipe-down dismiss, pull-to-refresh,
                long-press-comment).

Cross-UA matrix (chromium-engine projects run @smoke only):
  chromium-pixel7, chromium-galaxy-s22, samsung-internet (Samsung UA
  emulation on Galaxy viewport), edge-android, plus webkit-iphone,
  chrome-ios, firefox-android, firefox-desktop — the latter four need
  libavif16 on the host (Playwright dep) but the configs are in place.

Infrastructure:
  - fixtures/test.ts central test.extend (api, db, adminToken, guest,
    host, signIn). Per-test DB truncate via the dev-only POST
    /admin/__truncate route, gated by EVENTSNAP_TEST_MODE=1.
  - helpers/sse-listener.ts, helpers/upload-client.ts (Node-side
    multipart for adversarial file-upload tests + JPEG/PNG/ELF magic
    constants), helpers/touch.ts (longPress / doubleTap / swipe /
    inlineStyle / computedStyle).
  - 10 page objects covering every route + UploadSheet/Lightbox.
  - global-setup waits for /health, logs in admin, disables every
    rate-limit and quota toggle.
  - .github/workflows/e2e.yml: PR check runs chromium-desktop + the
    smoke matrix in parallel, uploads playwright-report/ and traces on
    failure.

Findings the suite surfaces as live `[finding]` warnings (not silenced):
  1. /admin/login has no rate-limit or lockout (bcrypt cost only).
  2. PIN-attempt counter races under parallel /recover requests.
  3. Zero-byte uploads pass /api/v1/upload.
  4. SVG-with-script can pass the magic-byte check (consider CSP +
     X-Content-Type-Options on /media/*).

Stack-internal docs live in e2e/README.md (UA tier table, Samsung
Internet escalation tiers A/B/C, debugging tips, roadmap).

Final tally: 134 passed / 0 failed / 9 skipped (test.fixme stubs for
not-yet-shipped gestures and one UI-upload-flow investigation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:37:11 +02:00
MechaCat02
1cdab21514 fix(frontend): a11y backdrop, ≥44px PIN button, test-ids on auth & upload
- account/+page.svelte: remove `aria-hidden="true"` from the
  leave-confirm and data-mode-warning bottom-sheet backdrops. The
  attribute cascaded into the dialog children, making the inner
  Abmelden/Aktivieren/Abbrechen buttons unreachable in the accessibility
  tree (and to Playwright's `getByRole`). Discovered while writing the
  E2E suite; the visual layout is unchanged.

- join/+page.svelte: bump the PIN-copy button from `py-1` (28px tall) to
  `min-h-11 min-w-11 py-2` so it clears the ≥44px touch-target floor on
  mobile. Touch-target audit revealed the gap.

- data-testid attributes on stable interactive elements (join name input,
  join submit, PIN modal + copy + continue, recovery PIN + submit + try-
  different-name, admin login password + submit + error, recover name +
  PIN + submit + error, upload header submit + sticky submit + caption
  textarea). Targeted at ~20 spots where semantic locators were ambiguous
  (e.g. two "Hochladen" buttons on /upload, German strings that may iterate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:54 +02:00
MechaCat02
05f76514a2 fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint
Two bugs surfaced while running the new E2E suite, plus a small test hook:

- jwt.rs: add a per-token `jti: Uuid` claim. Without it, two `create_token`
  calls in the same wall-clock second for the same (sub, role, event_id)
  produced identical JWT bytes — and identical sha256(token) hashes —
  which then collided on `session.token_hash UNIQUE` with a 500. Manifests
  in real use when an admin clicks "Anmelden" twice fast.

- auth/handlers.rs: reject display names containing 0x00. Postgres rejects
  NUL in TEXT columns with `invalid byte sequence for encoding "UTF8"` and
  the request leaks back as a 500. Now returns 400 with a clean message.

- handlers/test_admin.rs + main.rs: new POST /api/v1/admin/__truncate route,
  compiled in always but only **registered** when EVENTSNAP_TEST_MODE=1 is
  set on startup. Truncates every event-scoped table, reseeds config from
  migration defaults, wipes media on disk, and clears the in-memory rate
  limiter. RequireAdmin-gated so it's not anonymous even in test mode. In
  production builds (no env var) the route returns 404 — verified by the
  startup log message.

- services/rate_limiter.rs: add `clear()` so the truncate handler can wipe
  the in-memory window map between tests.

- Dockerfile: bump rust:1.87 → rust:1.88 (current dep tree needs it) and
  COPY ./migrations into the build context so the `sqlx::migrate!()` macro
  can resolve at compile time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:34 +02:00
MechaCat02
e6efffafe5 Merge feat/platform-v0.16: platform features, dark mode, hardening
A bundle of v0.16 platform work landing on top of the SvelteKit viewer.
Brings in 7 commits across docs, backend infra + features, frontend
plumbing, the live diashow, all UI surfaces (with dark mode), and a
viewer rebuild.

Headline features:
- Live diashow with two-queue policy (live drains first, shuffle as
  fallback) and pluggable transitions. Design: docs/CONCEPT_DIASHOW.md.
- Dark mode: 'system' / 'light' / 'dark' preference, picked in the
  onboarding step + the Account page, applied via Tailwind v4
  class-based dark variant with FOUC guard.
- Host/Admin PIN reset with one-time PIN modal; admin may also reset
  host PINs. Hosts may demote other hosts.
- Per-user dynamic storage quota enforced on upload + live widget in
  My Account and on the upload screen. Toggleable per-area.
- All rate-limits + quotas individually toggleable from the admin
  config UI (rendered as switches + a privacy_note textarea).
- Mobile-first gestures: long-press → context sheet, double-tap to
  like with heart-burst. Buttons stay as desktop equivalents.
- Data mode (Saver vs Original) per device, applied across feed,
  lightbox, and diashow.
- Per-event Datenschutzhinweis admin-editable, live-refreshed on all
  clients via SSE event-updated.
- /api/v1/upload/{id}/original endpoint, /me/context + /me/quota.

Hardening (latent issues from the long-term review):
- Startup recovery for stuck compression / export jobs after a crash.
- Hourly cleanup of expired sessions + cold rate-limiter HashMap keys.
- ffmpeg 120s timeout with kill_on_drop (no more permit leaks).
- Per-user IndexedDB upload queue (no more cross-user leak on shared
  devices); IDB schema bumped to v2.
- SSE reconnect uses exponential backoff (no more retry storm).
- PIN lockout no longer escalates — attempts reset when the cooldown
  expires.
- soft_delete is now transactional and decrements total_upload_bytes
  so quotas don't drift.
- pin-reset SSE handler filters by user_id so a host resetting Anna's
  PIN doesn't clear Bob's cached PIN.
- Privacy note shown preformatted; admin-editable, ≤16 KiB cap.

Docs:
- New: FEATURES.md (role matrix), USER_JOURNEYS.md, IDEAS.md,
  CONCEPT_DIASHOW.md, backend/migrations/README.md,
  frontend/src/lib/README.md.
- Refresh: PROJECT.md, README.md, TEST_GUIDE.md, the two existing
  CONCEPT_*.md banners.
2026-05-16 14:39:13 +02:00
MechaCat02
16d8bdb680 Merge feat/html-viewer-export: SvelteKit-static offline viewer
Replaces the old single-file minijinja HTML export with a polished
read-only SvelteKit app shipped alongside the event data. Same
components and Tailwind tokens as the live app — visual parity with
zero divergence risk.

Brings in 4 commits:
  4f96653 feat: add export-viewer SvelteKit static app
  2fd66a8 chore: build and commit export-viewer static output
  ffc926b feat: replace HTML export with SvelteKit viewer
  1685bf1 fix: update export page guide text for new viewer

Design: docs/CONCEPT_HTML_VIEWER.md
2026-05-16 14:38:14 +02:00
MechaCat02
2761ac7db6 chore: rebuild export-viewer with the shared Tailwind theme
Pre-built output regenerated after frontend/export-viewer/src/app.css
started importing ../../src/tailwind-theme.css. Output now picks up the
same @theme tokens and class-driven dark variant as the live app, so
future viewer-side use of bg-primary / dark: utilities will resolve
identically to the main bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:33:44 +02:00
MechaCat02
e619a3bd64 feat(ui): v0.16 features + dark mode across every page
Wires up everything from the previous commits into actual UI surfaces, and
applies Tailwind dark: variants throughout. All pages now support the
'system' / 'light' / 'dark' preference set in the onboarding step or in
Mein Konto → Design.

Layout & nav:
- routes/+layout.svelte: initTheme(), global pin-reset SSE handler that
  filters by user_id and calls clearPin(), one-shot /me/context fetch
  on boot to hydrate privacyNote + quota.
- components/BottomNav.svelte: dark variants on the frosted-glass bar.
- components/UploadSheet.svelte: dark variants on backdrop, sheet,
  source buttons.
- components/OnboardingGuide.svelte: new "Helles oder dunkles Design?"
  step (3-option custom-radio grid), reactive currentStep with proper
  type narrowing, dark variants throughout. Privacy-note nudge appears
  on the PIN step only when one is configured.

Feed:
- routes/feed/+page.svelte: diashow entry icon (tablet/desktop only),
  long-press → ContextSheet (Löschen for own posts, Original anzeigen
  for all), upload-deleted + feed-delta SSE handlers, dark variants on
  header, search, autocomplete, filter chips, empty states.
- components/FeedListCard.svelte: long-press wireup, double-tap-to-like,
  data-mode-aware mediaSrc via pickMediaUrl, kebab fallback for desktop,
  isOwn prop, dark variants.
- components/FeedGrid.svelte: long-press wireup, dark variants.
- components/LightboxModal.svelte: data-mode-aware src, double-tap heart
  burst, dark variants on card / comments / input.
- components/HashtagChips.svelte: dark variants.

Account:
- routes/account/+page.svelte: theme picker (3-button radio grid), data
  mode picker (with confirm sheet for Original), live quota widget,
  preformatted Datenschutzhinweis block, diashow tile (mobile only),
  pin now sourced from the $currentPin store so a global pin-reset
  clears it live, clearQueue() on explicit logout, dark variants
  across every card + both bottom sheets.

Upload:
- routes/upload/+page.svelte: per-user quota progress bar above the
  submit button, dark variants.

Host & Admin:
- routes/host/+page.svelte: PIN-reset confirm + one-time PIN modal,
  hosts may demote other hosts, canResetPinFor() helper, dark variants
  on all cards, modals, stats, toast.
- routes/admin/+page.svelte: Config form rebuilt as CONFIG_GROUPS with
  per-field kind (number / bool / text), renders toggles for the
  rate-limit + quota switches and a textarea for the privacy_note;
  Nutzer tab gains PIN reset + hosts-may-demote-hosts wiring; same
  one-time PIN modal; dark variants everywhere.
- routes/admin/login/+page.svelte: dark variants.

Join / Recover / Export:
- routes/join/+page.svelte: rename inline link to
  "Ich habe bereits einen Account", dark variants.
- routes/recover/+page.svelte: dark variants.
- routes/export/+page.svelte: dark variants on status cards + HTML
  guide modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:33:30 +02:00
MechaCat02
8a769b52bf feat(diashow): live slideshow with two-queue policy + pluggable transitions
A fullscreen auto-advancing slideshow any user can start. Design:
docs/CONCEPT_DIASHOW.md.

- lib/diashow/queue.ts: SlideQueue state machine — liveQueue drains first
  (FIFO, seeded by SSE upload-processed), then shuffleQueue (refilled
  from allKnown minus a 5-id ring buffer of recently shown). Pure logic,
  unit-testable.
- lib/diashow/wakelock.ts: Screen Wake Lock wrapper that re-acquires on
  visibility change (the OS drops the lock when the tab hides).
- lib/diashow/transitions/{index,crossfade,kenburns}.ts: registry +
  the v1 transitions. Adding a new animation is one file + one entry —
  the extensibility target from docs/FEATURES §2.9.
- routes/diashow/+page.svelte: fullscreen page, hides bottom nav,
  6 s default dwell (3/6/10 configurable), keyboard shortcuts
  (Escape exits, Space toggles pause), tap-to-reveal overlay with
  pause / dwell / transition / exit. Respects $dataMode to choose
  preview vs. original URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:32:55 +02:00
MechaCat02
251f9f1469 frontend(plumbing): shared theme tokens, cross-cutting stores, gestures, sheets
The plumbing layer the v0.16 UI features (and dark mode) build on.

Shared design tokens (Tailwind v4):
- tailwind-theme.css (new): @custom-variant dark (class-driven, beats OS
  default) + @theme color/font/radius tokens + baseline html/html.dark
  rules so any page that hasn't been re-themed still renders the right
  body bg + color-scheme.
- src/app.css + export-viewer/src/app.css now import the shared theme.
- src/app.html: 6-line FOUC guard sets <html class="dark"> before paint
  (mirrored from theme-store.ts) so dark reloads no longer flash white.
  Adds <meta name="theme-color"> kept in sync by initTheme().

Cross-cutting stores (one per concern, per docs/FEATURES §2.9):
- data-mode-store.ts: 'saver' | 'original' per-device, plus pickMediaUrl
  helper so feed cards / lightbox / diashow all resolve URLs the same way.
- privacy-note-store.ts: hydrated from /me/context, refreshed on SSE
  event-updated.
- quota-store.ts: { enabled, used, limit, active_uploaders, free_disk },
  refreshed after each upload completes.
- theme-store.ts: 'system' | 'light' | 'dark' preference + derived
  appliedTheme + initTheme() that syncs <html class>, localStorage,
  and the theme-color meta. Listens to prefers-color-scheme.
- auth.ts: currentPin writable mirror + clearPin() helper called from
  the global pin-reset SSE handler — fixes the stale-PIN bug where the
  localStorage copy survived a reset.

DTO mirror:
- types.ts: QuotaDto, MeContextDto, PinResetResponse, DeltaResponse each
  carry a `// mirrors backend/...` comment per the lib README convention.

SSE client:
- sse.ts: KNOWN_EVENTS registry (one entry per server-emitted type),
  synthetic feed-delta dispatched after foreground reconnect via the
  /feed/delta?since= endpoint, exponential backoff (1 → 60 s + jitter)
  on errors, attempt counter reset on user-initiated visibility resume.

Upload queue:
- upload-queue.ts: IDB schema bumped to v2 — entries tagged with userId;
  loadQueue filters by current user (no cross-user leak on shared
  devices); uploadItem refuses to upload an entry whose userId differs
  from getUserId() (defense-in-depth); new clearQueue() called on
  explicit logout. v2 upgrade wipes pre-v2 entries (no userId, can't
  attribute safely).

Mobile primitives:
- actions/longpress.ts: 500 ms hold with 10 px move tolerance, swallows
  the next click + the right-click contextmenu so the gesture doesn't
  double-fire the inner button's onclick.
- actions/doubletap.ts: tap-pair detector that preventDefaults the
  second tap so iOS Safari doesn't also zoom on double-tap.
- components/ContextSheet.svelte: generic bottom sheet driven by a
  ContextAction[] prop. Reused by feed posts, comments, host user rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:32:37 +02:00
MechaCat02
2e98f5ddf5 backend(features): quota enforcement, PIN reset, /me, original download, toggles
- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
  + quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
  (live used / limit / active uploaders / free disk).
- handlers/upload.rs:
  - quota enforcement via the dynamic formula
    floor((free_disk * tolerance) / max(active_uploaders, 1)),
    gated by quota_enabled + storage_quota_enabled toggles
  - new GET /api/v1/upload/{id}/original — unauthed by design
    (matches /media/previews/* — URL is the secret) so it works as
    <img src> / <video src> / window.open
  - rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
  - POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
    Admin may reset guest + host PINs (never another admin or self).
    Returns the freshly-generated plaintext PIN once; emits a global
    pin-reset SSE so the affected user's device can clear its localStorage.
  - set_role guard expanded so hosts can demote other hosts (not self,
    never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
  TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
  event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
  rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
  reset failed_pin_attempts to zero before the bcrypt check — without
  this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
  new /me/context, /me/quota, /upload/{id}/original, and
  /host/users/{id}/pin-reset routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:32:05 +02:00
MechaCat02
141c918dd5 backend(infra): shared config helper, startup recovery, periodic maintenance
Foundations for the v0.16 features. No new endpoints here — those land in
the next commit on top of these.

- migrations 008 + 009: commit the load-bearing compression_status column
  that was uncommitted on disk; add 009_feature_toggles seeding the master
  + per-endpoint rate-limit switches, the master + per-area quota switches,
  and the admin-editable privacy_note.
- services/config.rs (new): get_str / get_i64 / get_usize / get_f64 / get_bool
  consolidating the scattered helpers that lived in three handlers.
- services/maintenance.rs (new):
  - startup_recovery() — resets compression_status='processing' and
    export_job.status='running' rows orphaned by a previous crashed
    instance, so users never see permanent "Wird vorbereitet…" spinners.
  - spawn_periodic_tasks() — hourly cleanup of expired sessions (rows
    were never pruned) + rate-limiter HashMap pruning (windows kept one
    entry per IP forever).
- services/jobs.rs (new sketch): BackgroundJob trait + JobContext for
  future jobs to plug into the same progress + SSE pipeline as
  compression/export. Not wired yet — codifies the convention.
- services/compression.rs: 120s hard timeout + kill_on_drop on ffmpeg
  so a malformed video can't hang and leak a worker semaphore permit.
- services/rate_limiter.rs: new prune() called from the periodic task.
- state.rs: SseEvent::new() constructor so event-type strings stay
  consistent instead of being typed inline at every emit site.
- models/user.rs: UserRole::as_str() for /me/context serialization.
- models/upload.rs: soft_delete() now runs in a transaction and
  decrements the uploader's total_upload_bytes (GREATEST(0, …) guard) —
  fixes a quota drift where deleting reclaimed no quota.
- Cargo.toml + Cargo.lock: add `infer = "0.15"` (multipart MIME sniffing
  used by the upload handler).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:31:41 +02:00
MechaCat02
9a0ceeced7 docs: realign blueprint with shipped state + add feature/journey/ideas docs
- PROJECT.md, README.md, TEST_GUIDE.md: status line refreshed; rate-limiter
  doc-vs-code drift fixed; HTML export section rewritten for the SvelteKit-
  static viewer; SSE event names + new events documented; config seed block
  extended with planned toggles + privacy_note; decision log entries added.
- docs/CONCEPT_HTML_VIEWER.md, docs/CONCEPT_MOBILE_UI.md: banner the design
  intent as shipped; point at the source-of-truth code paths.
- docs/CONCEPT_DIASHOW.md: planned-then-shipped design for the live diashow
  (two-queue policy, pluggable transitions, data-mode aware).
- docs/FEATURES.md: capability matrix by role (Guest / Host / Admin) plus
  prose per area (auth, posting, feed, moderation, admin, export, gestures,
  data mode, quotas, privacy note, extensibility).
- docs/USER_JOURNEYS.md: step-by-step flows for every supported scenario,
  including PIN reset by host, data mode, privacy note, gestures, and the
  admin toggles.
- docs/IDEAS.md: speculative extensions (global diashow, reactions,
  multi-tenancy, animation pack, etc.) — explicitly out of v0.16 scope.
- backend/migrations/README.md, frontend/src/lib/README.md: codify the
  "never edit a shipped migration" rule and the lib/ conventions
  (one store per concern, gestures via actions, sheets via ContextSheet,
  transitions as drop-in components).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:31:06 +02:00
MechaCat02
f7fdfa4627 docs: add mobile testing guide
Comprehensive testing guide for the v0.15.0 mobile-first UI
covering bottom nav, upload FAB/sheet, feed views, search,
account, host/admin dashboards, and edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:29:25 +02:00
MechaCat02
1685bf105c fix: update export page guide text for new viewer
Change "Memories.html" to "index.html" in the HTML export
download guide modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:26:06 +02:00
MechaCat02
ffc926bf4d feat: replace HTML export with SvelteKit viewer
Replace the minijinja-based HTML export with a full SvelteKit static
viewer app. The new export produces a ZIP with:
- Pre-built viewer assets (index.html + JS/CSS bundle)
- data.json with all posts, comments, tags, and like counts
- Processed media: 400px thumbnails for grid, full images (2000px
  cap if >5MB), video thumbnails via ffmpeg

Remove minijinja dependency, add include_dir to embed viewer assets
at compile time. Update Dockerfile to copy static/ for builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:26:03 +02:00
MechaCat02
2fd66a800a chore: build and commit export-viewer static output
Pre-built SvelteKit static output for embedding into HTML export ZIPs.
When viewer source changes, rebuild with `npm run build` in
frontend/export-viewer/ and re-commit this directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:01:08 +02:00
MechaCat02
4f966533fe feat: add export-viewer SvelteKit static app
Standalone SvelteKit project at frontend/export-viewer/ using
adapter-static. Replicates the live feed experience as a read-only
offline gallery: list/grid views, search with autocomplete, hashtag
filtering, lightbox with swipe navigation and comments.

Built output goes to backend/static/export-viewer/ for embedding
into the HTML export ZIP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:01:01 +02:00
MechaCat02
4a5506f32d feat: mobile-first UI redesign (v0.15.0)
- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages
- Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer
- Upload page redesigned as full-screen composer with thumbnail strip, textarea,
  quick-tag chips, sticky submit button; bottom nav suppressed while composing
- Slim upload progress bar above bottom nav driven by queue state
- Feed: list/grid view toggle; list = chronological full-width FeedListCard;
  grid = 3-col with search bar, autocomplete from loaded posts, filter chips
- Account page: role-gated dashboard links (Host / Admin); Konto section with
  leave-confirm bottom sheet; no more per-page header nav icons
- Host dashboard: back arrow, collapsible sections, 2-col stats, user search
- Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer),
  stacked config inputs with sticky save, new Nutzer tab
- BottomNav hidden on unauthenticated pages via isAuthenticated store
- FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB
- Concept docs added to docs/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:40:57 +02:00
MechaCat02
4757be71a3 feat: role-aware nav icons across feed, host, and admin pages
Feed: shows star icon (→ /host) for host/admin, shield icon (→ /admin)
for admin only, alongside the existing account icon.
Host: shows shield icon (→ /admin) for admin only next to "Zur Galerie".
Admin: replaces "Host-Dashboard" text link with star icon (→ /host).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:08:37 +02:00
MechaCat02
d0a199e9b5 fix: HTML export tojson filter + authenticated file download (v0.14.1)
- Enable minijinja 'json' feature so the tojson filter is available in
  the Memories.html template (was causing 'unknown filter' render error)
- Replace window.location.href downloads with fetch+blob so the
  Authorization header is sent (window.location.href caused 401)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:01:48 +02:00
MechaCat02
2375a9cfa6 fix: refresh only export jobs list on Aktualisieren, not the whole page 2026-04-03 19:47:07 +02:00
MechaCat02
0bda0eecc8 fix: stringify config values before PATCH to avoid 422 (number vs string) 2026-04-03 19:39:55 +02:00
MechaCat02
eab5bb4d1c feat: add /admin/login page (v0.14.0)
Password form at /admin/login that calls POST /api/v1/admin/login and
redirects to /admin on success. Admin dashboard now redirects to
/admin/login instead of /join when unauthenticated. Test guide updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 19:35:40 +02:00
MechaCat02
5b2947cdbe docs: add admin & host dashboard test steps (Steps 10–15) 2026-04-03 19:31:04 +02:00
MechaCat02
0351e967c0 feat: unique display names + inline recover on join (v0.13.1)
Backend: migration 007 adds a case-insensitive unique index on user names
per event. join endpoint returns 409 conflict when the name is taken.
find_by_event_and_name uses LOWER() for case-insensitive recovery.

Frontend: join page handles 409 with a name-taken view — amber warning,
name-choice tips, inline PIN recovery form, and "Anderen Namen wählen"
button. Test guide updated with Steps 8 and 9.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:37:51 +02:00
MechaCat02
de0e395a9e feat: auto-retry uploads when rate limited (v0.13.0)
Backend: rate limiter gains check_with_retry() returning seconds until
the next slot opens. Upload 429 responses include retry_after_secs in
JSON and a Retry-After header.

Frontend: upload queue catches 429 as RateLimitError, resets affected
item to pending, schedules processQueue() for the server-reported delay,
and shows a live countdown banner in the queue UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:37:51 +02:00
MechaCat02
3dc69e6c6d fix: upload body limit, role case, and connection drain (v0.12.1)
- Disable Axum's 2 MB default body limit on the upload route so large
  photos/videos are accepted without HTTP 400
- Serialize UserRole as lowercase in JWT so the frontend role checks
  ('guest'/'host'/'admin') match correctly
- Drain multipart body before returning early upload errors (rate-limit,
  ban, event-lock) to keep the HTTP keep-alive connection clean and
  prevent cascading Netzwerkfehler / empty-500 responses
- Add TraceLayer for request logging and Vite dev proxy config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:14:12 +02:00
MechaCat02
87b5aff478 feat: implement onboarding guide and HTML export guide
Add 4-step dismissible onboarding overlay shown on first feed visit
(welcome, upload, hashtags, PIN importance). Dismissed state persisted
in localStorage under eventsnap_guide_seen. Step indicator dots and
skip/continue buttons included.

Update HTML export guide modal to persist the eventsnap_html_guide_seen
flag: first download shows the instructions modal; subsequent clicks go
straight to download without interruption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:13:14 +02:00
MechaCat02
75d186fad3 feat: implement My Account page
Add /account route showing display name (from localStorage), role badge,
session expiry decoded from JWT, and recovery PIN display with copy button.
Join and recover flows now persist display_name to localStorage via setAuth().
Feed header logout button replaced with person-icon link to /account;
logout is available from the account page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:11:11 +02:00
MechaCat02
989d88022a feat: implement rate limiting across all API endpoints
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:03:59 +02:00
MechaCat02
258e2bd84d feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.

Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
  async_zip (Stored compression), organised into Photos/ and Videos/
  with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
  inlined CSS + JS, relative media paths); packs it with README.txt and
  all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
  export_zip_ready / export_html_ready on the event, and broadcast SSE
  export-progress + export-available when both complete

Backend — new endpoints (AuthUser):
- GET /export/zip   → streams Gallery.zip if export_zip_ready
- GET /export/html  → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)

Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)

Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
  download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:56:21 +02:00
MechaCat02
32c16da3e2 feat: implement admin dashboard
Add Admin Dashboard at /admin for server configuration, disk usage
monitoring, and export job status, plus a public export/status endpoint.

Backend — new /api/v1/admin/* endpoints (RequireAdmin auth):
- GET  /admin/stats           → user/upload/comment counts + disk usage
- GET  /admin/config          → all config key/value pairs
- PATCH /admin/config         → update any subset of config keys; validates
                                 key whitelist and numeric values
- GET  /admin/export/jobs     → export_job rows for the event

Backend — public (AuthUser) endpoint:
- GET  /export/status         → released flag + zip/html job status/progress

Frontend — /admin page:
- Stats grid: guest count, upload count, comment count
- Disk usage bar with GB/MB formatting; red ≥ 90%, amber ≥ 75%
- Config form: labelled numeric inputs for all eight config keys,
  sends only changed values on save
- Export jobs list: type label, status badge, progress bar for running jobs,
  error message if failed; manual refresh button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:45:37 +02:00
MechaCat02
71a2987a3e feat: implement host dashboard
Add Host Dashboard for event and guest management, accessible at /host.

Backend — new /api/v1/host/* endpoints (RequireHost auth):
- GET  /host/event                    → event name + lock/release state
- POST /host/event/close|open         → lock or unlock uploads; SSE broadcast
- POST /host/gallery/release          → set release timestamp, enqueue export jobs
- GET  /host/users                    → all guests with upload count & bytes
- POST /host/users/{id}/ban           → ban with optional upload-hide choice
- POST /host/users/{id}/unban         → lift ban
- PATCH /host/users/{id}/role         → promote guest→host or demote host→guest
- DELETE /host/upload/{id}            → host-level soft-delete + SSE
- DELETE /host/comment/{id}           → host-level soft-delete

Frontend — /host page:
- Event controls: lock/unlock toggle and release-gallery button with status badges
- Guest table: display name, role badge, upload count, storage used
- Ban flow: modal asking whether to keep or hide the user's uploads
- Promote/demote buttons respecting caller role (host can promote guests; admin can demote hosts)
- auth.ts: getRole() decodes JWT payload client-side to gate the route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:43:09 +02:00
217 changed files with 19871 additions and 676 deletions

View File

@@ -4,15 +4,21 @@ DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# docker-compose.yml already forces APP_ENV=production for the `app` service.
# Only set this for a non-compose (bare cargo) run; leave unset for local dev.
# APP_ENV=production
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
# Set a strong password and keep it identical in DATABASE_URL and POSTGRES_PASSWORD.
DATABASE_URL=postgres://eventsnap:CHANGE_ME_strong_db_password@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 64
# REQUIRED in production: generate with `openssl rand -hex 64` (128 hex chars).
# The backend refuses to start in production with this placeholder or any value
# that is short or contains change/example/placeholder/replace.
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
SESSION_EXPIRY_DAYS=30

110
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: E2E
on:
pull_request:
push:
branches: [main]
jobs:
e2e:
name: Playwright E2E (chromium-desktop)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'e2e/package.json'
- name: Install e2e deps
working-directory: ./e2e
run: npm install
- name: Install Playwright browsers
working-directory: ./e2e
run: npx playwright install --with-deps chromium
- name: Bring up the test stack
working-directory: ./e2e
run: docker compose -f docker-compose.test.yml up -d --build
- name: Wait for stack health
working-directory: ./e2e
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3101/health > /dev/null; then exit 0; fi
sleep 2
done
echo "Stack never became healthy. Dumping logs:"
docker compose -f docker-compose.test.yml logs
exit 1
- name: Run E2E tests
working-directory: ./e2e
run: npm run test:e2e -- --project=chromium-desktop
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: e2e/playwright-report/
retention-days: 14
- name: Upload traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: traces
path: e2e/test-results/
retention-days: 14
- name: Tear down stack
if: always()
working-directory: ./e2e
run: docker compose -f docker-compose.test.yml down -v
smoke-ua-matrix:
name: Cross-UA smoke matrix
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install e2e deps
working-directory: ./e2e
run: npm install
- name: Install Playwright browsers (full)
working-directory: ./e2e
run: npx playwright install --with-deps chromium firefox webkit
- name: Bring up the test stack
working-directory: ./e2e
run: docker compose -f docker-compose.test.yml up -d --build
- name: Wait for stack health
working-directory: ./e2e
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3101/health > /dev/null; then exit 0; fi
sleep 2
done
docker compose -f docker-compose.test.yml logs
exit 1
- name: Smoke matrix
working-directory: ./e2e
run: npm run test:e2e:smoke
- name: Upload smoke report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-smoke-report
path: e2e/playwright-report/
retention-days: 14
- name: Tear down
if: always()
working-directory: ./e2e
run: docker compose -f docker-compose.test.yml down -v

13
.gitignore vendored
View File

@@ -8,10 +8,23 @@ backend/target/
frontend/node_modules/
frontend/.svelte-kit/
frontend/build/
frontend/export-viewer/node_modules/
frontend/export-viewer/.svelte-kit/
# Media uploads (mounted volume in production)
media/
# Playwright E2E suite — runtime artifacts (the suite itself is committed)
e2e/node_modules/
e2e/playwright-report/
e2e/test-results/
e2e/.cache/
e2e/.env.test
# OS
.DS_Store
Thumbs.db
# Local audit/review reports — generated working artifacts, kept out of git
docs/AUDIT-2026-06-27.md
docs/FIX-VERIFICATION-2026-06-27.md

View File

@@ -5,21 +5,24 @@
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# Media previews and thumbnails
@previews path /media/previews/* /media/thumbnails/*
header @previews Cache-Control "public, max-age=3600"
# Original media files (private — only host can download)
@originals path /media/originals/*
header @originals Cache-Control "private, max-age=86400"
# API — never cache
@api path /api/*
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
# Media is served by the authenticated gateway at /media/{kind}/{id}, which
# sets its own Cache-Control (private) and security headers per response — no
# Caddy-side cache rules (the old /media/previews|originals matchers were for
# the retired static mount).
# Route API and media requests to the Rust backend. Set X-Real-IP from the
# real TCP peer and overwrite any client-supplied value so the backend's
# rate-limit keys can't be spoofed via a forged X-Forwarded-For.
reverse_proxy /api/* app:3000 {
header_up X-Real-IP {remote_host}
}
reverse_proxy /media/* app:3000 {
header_up X-Real-IP {remote_host}
}
# Everything else goes to SvelteKit frontend
reverse_proxy frontend:3001

53
FOLLOWUPS.md Normal file
View File

@@ -0,0 +1,53 @@
# Follow-ups
Tracked work that was deferred during the multi-round UI/UX review pass.
Each item has a clear acceptance criterion so a future pass can land it
without re-deriving the context.
## A11y — assistive-tech containment inside modals
**Problem.** Open modals (LightboxModal, ConfirmSheet, Modal, OnboardingGuide,
join PIN modal, account data-mode sheet, export HTML guide) trap keyboard Tab
via `focusTrap`, but VoiceOver rotor / TalkBack arrow-key navigation can still
escape into the page content behind the dialog. Screen-reader users hear the
wrong context.
**Why deferred.** A naive sibling-walk that sets `inert` on direct children of
the modal's parent silences the global `<Toaster>` (`aria-live="polite"` region
mounted in `+layout.svelte`) and the `<BottomNav>` while a modal is open —
breaking toast announcements and the visible nav state. SvelteKit has no
built-in portal mechanism, so dialogs render inside the route tree alongside
the Toaster.
**Acceptance criterion.** With any modal open:
- VoiceOver rotor (iOS Safari) and TalkBack swipe navigation (Android Chrome)
cannot leave the dialog subtree.
- Toasts that fire while a modal is open are still announced.
- Nested modals (e.g. ConfirmSheet opened from inside ContextSheet) maintain
correct containment when the inner closes.
**Sketch of an approach.** One of:
1. **Portal pattern.** Render dialogs into a dedicated `<div id="modal-root">`
that's a sibling of the main app root in `app.html`. `focusTrap` then sets
`inert` on the main root, leaving the modal root and the toast region (also
moved to its own portal root) untouched.
2. **Opt-out marker.** Walk siblings and inert them, but skip any node carrying
a `data-modal-passthrough` attribute. Mark `<Toaster>` with it. Document
the contract.
3. **Stack-aware containment.** Maintain a module-level stack of open dialog
nodes; the topmost owns the inert state, popped dialogs restore the
previous layer. Avoids the nested-modal restoration bug.
Approach 1 is the cleanest long-term but the highest blast radius. Approach 2
is the smallest patch.
**Files to touch.**
- [frontend/src/lib/actions/focus-trap.ts](frontend/src/lib/actions/focus-trap.ts) — add inert logic
- [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)
## 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.
- **Onboarding pip tap target on the vertical axis.** [OnboardingGuide.svelte](frontend/src/lib/components/OnboardingGuide.svelte) — current `p-2.5` yields ~26 px height, meets WCAG 2.2 AA (≥24 px) but below iOS HIG / Material's 44 / 48 dp recommendation. Bumping to `p-3` is the easy improvement; further increases start crowding the row.
- **Migrate bespoke focus-trapped dialogs to `<Modal>`.** Join PIN modal, OnboardingGuide, LightboxModal, HTML guide, account data-mode sheet — all currently roll their own shell with `focusTrap`. They're correct, just not using the canonical primitive. Migrate when `<Modal>` gains features (e.g. the inert work above) you'd want everywhere.

View File

@@ -42,7 +42,7 @@ A guest scans the QR code on their way in, types their name, and is immediately
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
### Status
Idea / Planning phase. Greenfield personal project.
Implementation in progress (~v0.16). Core flows + new features all wired: auth, feed, upload, host/admin dashboards, ZIP + HTML-viewer export, SSE with delta-fetch on reconnect, toggleable rate limits + quotas with live per-user estimate, host PIN reset with one-time modal, data-mode (Saver/Original), Datenschutzhinweis, mobile gestures (long-press context sheet, double-tap to like), and the live Diashow with pluggable transitions. Open items: low-disk alert, event banner UI, chunked resumable upload for very large videos. See [FEATURES.md](docs/FEATURES.md) for the capability matrix.
---
@@ -208,9 +208,9 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
│ Axum HTTP Server (Rust — Single Binary) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
│ │ │ │ stream │ │ output, embedded) │ │
│ │ REST API │ │ SSE Engine │ │ Media Static Server │ │
│ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
│ │ │ │ stream │ │ previews, thumbnails) │ │
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
│ │ │ │
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
@@ -245,36 +245,14 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
### Docker Compose Stack
Four services: Postgres, the Rust API (`app`), the SvelteKit Node server (`frontend`), and Caddy. Caddy routes `/api/*` and `/media/*` to the Rust binary and everything else to the SvelteKit server. See [docker-compose.yml](docker-compose.yml) for the authoritative definition.
```yaml
services:
app:
build: ./backend # Multi-stage Rust Dockerfile
env_file: .env
depends_on: [db]
volumes:
- media_data:/media
restart: unless-stopped
db:
image: postgres:16-alpine
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [app]
restart: unless-stopped
volumes:
postgres_data:
media_data:
caddy_data:
db: # postgres:16-alpine, persisted in postgres_data volume
app: # ./backend — Rust API on :3000, mounts media_data:/media
frontend: # ./frontend — SvelteKit (adapter-node) on :3001
caddy: # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
```
### Caddyfile
@@ -345,11 +323,14 @@ COMPRESSION_WORKER_CONCURRENCY=2
|-------|---------|---------|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
| `new-like` | `{ upload_id, like_count }` | Like toggled |
| `like-update` | `{ upload_id, like_count }` | Like toggled |
| `upload-deleted` | `{ upload_id }` | Upload deleted |
| `event-closed` | `{}` | Host locks uploads |
| `event-opened` | `{}` | Host unlocks uploads |
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
| `upload-processed` | `{ upload_id, preview_url, thumbnail_url }` | Server-side compression / preview generation finished |
| `upload-error` | `{ upload_id, message }` | Compression / preview generation failed |
| `export-progress` | `{ type, progress_pct }` | Periodic progress update from an export job |
**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`
@@ -372,7 +353,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
| Real-Time | Axum SSE + `tokio::sync::broadcast` | Native, lightweight, perfect for fan-out at this scale |
| ZIP Export | `async-zip` crate | Streaming ZIP generation without buffering the full archive in RAM |
| HTML Export | `minijinja` (Rust templating) | Generates `Memories.html` as a single self-contained file |
| Rate Limiting | `tower-governor` | Token-bucket per IP / per user; config from DB; hot-reloadable |
| Rate Limiting | Custom in-memory sliding-window limiter ([services/rate_limiter.rs](backend/src/services/rate_limiter.rs)) | Per IP / per user; limits read from `config` DB table on each request; hot-reloadable without restart |
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |
@@ -417,9 +398,9 @@ No paid third-party services required.
| Role | Permissions |
|------|------------|
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) |
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, lock/unlock uploads, release gallery export |
| Admin | All Host permissions + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation |
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release), pick data mode, read privacy note |
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, demote *other* Hosts to guest (never self), reset guest PINs (planned), lock/unlock uploads, release gallery export |
| Admin | All Host permissions + reset any non-admin PIN, configure storage/file/rate limits with on/off toggles, edit quota tolerance and per-area quota toggles, edit the Datenschutzhinweis, view disk usage, manage app config, trigger export generation |
| Banned Guest | View feed only — cannot upload, like, comment, or export |
### Compliance
@@ -473,37 +454,33 @@ Full-quality originals only. File naming: `{date}_{time}_{username}_{original_fi
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
The HTML export is a **pre-built SvelteKit static app** (`adapter-static`, `ssr=false`) shipped together with the event data. It is a non-interactive, read-only clone of the live feed — same components, same Tailwind tokens, same look — minus auth, upload, comment, and any dashboards. Full design rationale in [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md).
```
Memories/
Memories.html ← single entry point (all CSS + JS inlined; no external deps)
README.txt ← plain-text setup guide (in German, as the UI language)
Photos/ ...
Videos/ ...
index.html ← entry point; open this in any browser
_app/
immutable/... ← hashed JS/CSS bundles (viewer SPA)
data.json ← event metadata, posts, comments, likes, hashtags
media/
{id}_thumb.jpg ← grid thumbnails (≈400 px wide)
{id}_full.jpg/.mp4 ← full-size media for the lightbox
```
**Fully self-contained / true offline:** `Memories.html` is a single file with all CSS and JS inlined as `<style>` and `<script>` tags — no external stylesheets, no CDN scripts, no network requests. All images and videos are referenced via **relative paths** to the sibling `Photos/` and `Videos/` folders — not base64-embedded (that would make the HTML file unworkably large). The ZIP must be unzipped first; relative paths resolve correctly from any location on disk.
**How it works:** open `index.html` in any modern browser. The viewer hydrates client-side, `fetch('./data.json')` loads the event snapshot, all media references are relative paths into `media/`. No network calls, no service required. The ZIP must be unzipped first; the viewer does not run from inside an archive.
**`Memories.html` features:** responsive photo/video grid, fullscreen lightbox, client-side hashtag filter chips, comments + like counts per upload, uploader name + timestamp, warm keepsake album aesthetic — all in self-contained vanilla JS + CSS.
**Viewer feature parity with the live app:**
- List view (chronological) and 3-column grid view with the same toggle as the live app
- Lightbox with swipe navigation
- Hashtag filter chips and grid-view search/autocomplete
- Like counts and comment lists shown as a static snapshot from export time
- All UI strings in German
**`README.txt`** (in German, as the app's UI language):
```
Willkommen in der Event-Galerie!
**Build flow:** The viewer lives at [frontend/export-viewer/](frontend/export-viewer/) and is built ahead of time into [backend/static/export-viewer/](backend/static/export-viewer/) (committed to the repo). The export job embeds those assets via `include_dir!`, generates `data.json` from the database, processes thumbnails/full-sized variants, and streams the ZIP.
So geht's:
1. Entpacke diese ZIP-Datei
(Windows: Rechtsklick > "Alle extrahieren"; Mac: Doppelklick;
Handy: Dateimanager-App verwenden).
2. Öffne die Datei "Memories.html" in deinem Browser
(z. B. Chrome, Safari oder Firefox).
3. Stöbere durch alle Fotos und Videos.
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.
4. Eine Internetverbindung ist nicht nötig.
Alles ist lokal auf deinem Gerät gespeichert.
**Source files (ZIP archive export, see below)** still contain the unmodified originals — the viewer is the polished read-only experience, the ZIP is the raw archive.
Viel Freude mit den Erinnerungen!
```
For video-heavy events the ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
For video-heavy events the viewer ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
---
@@ -625,7 +602,9 @@ CREATE TABLE "user" (
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- No UNIQUE(event_id, display_name) — PIN disambiguates name collisions
-- Case-insensitive UNIQUE on (event_id, LOWER(display_name)) added by migration 007.
-- Name collisions are rejected on join; the user is prompted to recover with their PIN
-- (or to pick a different name).
);
-- ─────────────────────────────────────────
@@ -735,7 +714,18 @@ INSERT INTO config (key, value) VALUES
('export_rate_per_day', '3'),
('quota_tolerance', '0.75'),
('estimated_guest_count', '100'),
('compression_concurrency', '2')
('compression_concurrency', '2'),
-- Planned (see docs/FEATURES.md §2.6 and §2.7):
-- on/off switches for rate limits and quotas, and the privacy note text
('rate_limits_enabled', 'true'),
('upload_rate_enabled', 'true'),
('feed_rate_enabled', 'true'),
('export_rate_enabled', 'true'),
('join_rate_enabled', 'true'),
('quota_enabled', 'true'),
('storage_quota_enabled', 'true'),
('upload_count_quota_enabled', 'true'),
('privacy_note', '') -- free text, whitespace + newlines preserved, no HTML
ON CONFLICT (key) DO NOTHING;
```
@@ -1173,7 +1163,11 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
| Decision | Chosen | Rationale |
|----------|--------|-----------|
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page | Simple for non-technical guests; no email required |
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page; Host/Admin can issue a fresh PIN via the user list when a guest loses it entirely | Simple for non-technical guests; no email required; Host-mediated reset preserves the no-email identity model |
| Host demotion authority | Hosts can demote other Hosts (never themselves); Admin can demote anyone non-admin | Avoids requiring an Admin for every staffing change at the event |
| Privacy note | Free-text, plain (no HTML), admin-edited, rendered preformatted in My Account | Many events need a per-event privacy statement; preformatted text avoids any markup-injection risk |
| Data mode | Per-device `localStorage` setting (Saver / Original), default Saver | A guest can be on cellular on one device and Wi-Fi on another; per-device is the right scope |
| Rate-limit & quota toggles | On/off switches plus numeric values in the `config` table | Lets the Admin disable enforcement for testing or trusted events without redeploying |
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
@@ -1215,7 +1209,7 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
| `uuid` | UUID v7 (time-sortable) |
| `serde` / `serde_json` | Serialisation |
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
| `tower-governor` | Token-bucket rate limiting (per IP and per user) |
| (custom limiter, no crate) | Token-bucket / sliding window built in-tree at [services/rate_limiter.rs](backend/src/services/rate_limiter.rs) |
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
| `minijinja` | HTML export template rendering (`Memories.html`) |

View File

@@ -144,18 +144,18 @@ See [.env.example](.env.example) for the full list with descriptions and default
┌───▼────┐ ┌─────▼──────┐
│ app │ │ frontend │
│ :3000 │ │ :3001 │
│ (Rust) │ │ (SvelteKit)│
│ (Rust) │ │(SvelteKit)
└───┬────┘ └────────────┘
┌───▼────┐
│ db │
│ :5432 │
│(Postgres│
│(Postgres)
└────────┘
```
- `/api/*` and `/media/*` → Rust backend
- Everything else → SvelteKit frontend
- Everything else → SvelteKit frontend (`adapter-node`)
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
---
@@ -174,21 +174,57 @@ The `/media` volume holds originals, previews, thumbnails, exports, and DB backu
---
## Running the E2E test suite
Playwright-based end-to-end tests live in [`e2e/`](e2e/). They spin up an isolated docker-compose stack (Postgres on `:55432`, Caddy on `:3101`) and exercise the SvelteKit frontend against the real Rust backend with rate limits disabled.
```bash
cd e2e
npm install
npm run install:browsers # one-time
npm run stack:up # bring up the test stack
npm run test:e2e # full Phase 1 suite on chromium-desktop
npm run test:e2e:smoke # cross-UA matrix (chromium, samsung-internet, webkit, firefox, …)
npm run stack:down # tear it down
```
See [`e2e/README.md`](e2e/README.md) for the full UA matrix, Samsung Internet escalation tiers, and the Phase 2/3 roadmap.
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml).
---
## Development Roadmap
Done:
- [x] Project blueprint & architecture
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
- [ ] DB schema + SQLx migrations
- [ ] Auth flow (join, JWT, PIN recovery)
- [ ] Upload pipeline (multipart → compression worker → SSE broadcast)
- [ ] Client upload queue (IndexedDB, progress, retry)
- [ ] Gallery feed (grid, SSE, hashtag filters)
- [ ] Camera capture (`getUserMedia`)
- [ ] Host Dashboard
- [ ] Admin Dashboard
- [ ] Export engine (ZIP + offline HTML)
- [ ] Rate limiting middleware
- [ ] End-to-end test event (10+ real devices)
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
- [x] Mobile-first redesign (bottom nav + FAB, see [docs/CONCEPT_MOBILE_UI.md](docs/CONCEPT_MOBILE_UI.md))
Open:
- [ ] Dynamic per-user storage quota enforcement (formula in [PROJECT.md §12](PROJECT.md); only tracking exists today)
- [ ] Own-upload deletion UI in the lightbox (backend route exists)
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
- [ ] Individual file download button per post
- [ ] Low-disk alert (< 10 GB free)
- [ ] Event banner / cover image
- [ ] Chunked resumable upload for files > 100 MB
- [ ] Shared Tailwind config between main app and export-viewer
- [ ] End-to-end test event (10+ real devices on cellular)
See [docs/FEATURES.md](docs/FEATURES.md) for the up-to-date capability matrix by role.
Speculative / v2+ ideas live in [docs/IDEAS.md](docs/IDEAS.md).
---

109
TEST_GUIDE.md Normal file
View File

@@ -0,0 +1,109 @@
## Frontend Testing — Step by Step
Please test each step in order and report any errors (console errors, wrong text, broken UI, API errors).
### Step 1 — Join flow + PIN modal
1. Open **http://localhost:5173/** in your browser (or navigate there if already open)
2. You should land on the **join page** (`/join`) with a name input
3. Enter your name (e.g. `Max`) and click **Beitreten**
4. ✅ Expected: A modal appears showing your 4-digit PIN in large monospace font with a "Kopieren" button
5. Click **Weiter zur Galerie**
### Step 2 — Onboarding guide
6. You should land on the **feed page** (`/feed`)
7. ✅ Expected: A dark overlay appears at the bottom (or center on desktop) — the onboarding guide — showing step 1 of 4 with a step indicator and the Willkommen screen
8. Click **Weiter** through all 4 steps, then **Los geht's!**
9. ✅ Expected: Overlay disappears
### Step 3 — Feed & navigation
10. ✅ Expected: Feed shows the empty state ("Noch keine Fotos." or similar) with a hint to upload
11. ✅ Expected: A **persistent bottom nav** is visible with three slots — 🏠 **Feed** on the left, an elevated 📷+ **FAB** in the center, 👤 **Account** on the right
### Step 4 — My Account page
12. Tap the **👤 Account** tab in the bottom nav
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
14. Tap **Kopieren** — check the clipboard contains your PIN
15. ✅ Expected: Button briefly shows "Kopiert!"
16. Tap the 🏠 **Feed** tab to go back
### Step 5 — Upload
17. Tap the central **📷+ FAB** in the bottom nav
18. ✅ Expected: A bottom sheet slides up offering **Kamera** and **Galerie** options
19. Tap **Galerie** → pick a photo from your device library
20. ✅ Expected: Preview screen (`/upload`) shows the staged file with an optional caption / hashtag editor
21. Tap **Hochladen**
22. ✅ Expected: You return to the feed immediately; the FAB shows a small badge while uploading; the photo appears in the feed once processing completes
### Step 6 — Onboarding guide not shown again
23. Reload the page at `/feed`
24. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
### Step 7 — Recover (open a private/incognito window)
25. Open a new **private/incognito** window at **http://localhost:5173/recover**
26. Enter the same name (`Max`) and the PIN you copied
27. ✅ Expected: You're redirected to the feed with the same account
### Step 8 — Upload rate-limit auto-retry
28. Upload more than the per-hour limit of photos in quick succession to trigger the rate limit
29. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
30. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
31. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
### Step 9 — Name uniqueness (case-insensitive)
32. In a private/incognito window go to **http://localhost:5173/join**
33. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
34. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
35. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
36. Enter your PIN from Step 1 and click **Anmelden**
37. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
38. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
---
## Admin & Host Features
For these steps you need an admin session. Go to **http://localhost:5173/admin/login** and enter the admin password (`admin123` for the dev environment). You'll be redirected to the dashboard automatically.
### Step 10 — Admin Dashboard: Stats & Config
1. Go to **http://localhost:5173/admin**
2. ✅ Expected: Stats card shows user count, upload count, comment count, and a disk-usage progress bar
3. In the **Konfiguration** section, change **Upload-Limit pro Stunde** to a different value (e.g. `5`) and click **Speichern**
4. ✅ Expected: Toast "Konfiguration gespeichert." appears briefly
5. Reload — ✅ Expected: the changed value persists
### Step 11 — Admin Dashboard: Export Jobs
6. The **Export-Jobs** section shows all past jobs (likely empty if gallery hasn't been released yet)
7. Click **Aktualisieren** — ✅ Expected: list refreshes without a full page reload
### Step 12 — Host Dashboard: Event Controls
8. Navigate to **http://localhost:5173/host** (or click "Host-Dashboard" from the admin page)
9. ✅ Expected: Event name shown in the header; two status dots (Uploads open/locked, Export released/locked)
10. Click **Uploads sperren**
11. ✅ Expected: Toast "Uploads wurden gesperrt."; button changes to "Uploads wieder öffnen"; status dot turns red
12. Try uploading a photo as a guest — ✅ Expected: "Uploads sind gesperrt." error
13. Click **Uploads wieder öffnen** — ✅ Expected: dot turns green; uploads work again
### Step 13 — Host Dashboard: User Management
14. The **Gäste** list shows all registered users with upload counts and sizes
15. Find a guest and click **Host** next to their name
16. ✅ Expected: Toast "X ist jetzt Host."; a blue "Host" badge appears next to their name
17. As admin, a **Degradieren** button is now visible — click it
18. ✅ Expected: Toast "X ist jetzt Gast."; badge disappears
### Step 14 — Host Dashboard: Ban & Unban
19. Click **Sperren** next to a guest
20. ✅ Expected: A confirmation modal opens asking what to do with their uploads, with a checkbox "Uploads aus der Galerie ausblenden"
21. Leave the checkbox unchecked and click **Sperren**
22. ✅ Expected: Toast "X wurde gesperrt."; a red "Gesperrt" badge appears; buttons change to **Entsperren**
23. Try uploading as that banned user — ✅ Expected: "Du bist gesperrt." error
24. Click **Entsperren** — ✅ Expected: ban lifted; badge gone
### Step 15 — Gallery Release & Export
25. Make sure you have at least a few photos uploaded, then on the Host Dashboard click **Galerie freigeben**
26. ✅ Expected: Toast "Galerie wurde freigegeben. Export wird vorbereitet…"; button becomes disabled "Galerie bereits freigegeben"
27. Navigate to **http://localhost:5173/export** as any logged-in user
28. ✅ Expected: Two cards — **ZIP-Archiv** and **HTML-Viewer** — both initially showing "Wird vorbereitet…" or a progress bar
29. Wait for both to show "Bereit zum Download" (reload or wait for SSE to update the UI)
30. Click **Download** on the ZIP card — ✅ Expected: `Gallery.zip` downloads
31. Click **Download** on the HTML card — ✅ Expected: A guide modal appears explaining how to open the file; click **Herunterladen** to get `Memories.zip`
32. In the Admin Dashboard → **Export-Jobs**, click **Aktualisieren** — ✅ Expected: both jobs show "Fertig" with green badges

65
backend/Cargo.lock generated
View File

@@ -513,6 +513,17 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfb"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -896,8 +907,9 @@ dependencies = [
"dotenvy",
"futures",
"image",
"include_dir",
"infer",
"jsonwebtoken",
"minijinja",
"oxipng",
"rand 0.9.2",
"serde",
@@ -907,6 +919,7 @@ dependencies = [
"sysinfo",
"tokio",
"tokio-stream",
"tokio-util",
"tower",
"tower-http",
"tower_governor",
@@ -1003,6 +1016,12 @@ dependencies = [
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1576,6 +1595,25 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -1589,6 +1627,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "infer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199"
dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -1831,12 +1878,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "mime"
version = "0.3.17"
@@ -1853,16 +1894,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "minijinja"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
dependencies = [
"memo-map",
"serde",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"

View File

@@ -17,6 +17,7 @@ bcrypt = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = { version = "0.7", features = ["io", "compat"] }
futures = "0.3"
sha2 = "0.10"
rand = "0.9"
@@ -28,7 +29,8 @@ sysinfo = "0.32"
image = "0.25"
oxipng = "9"
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
minijinja = "2"
include_dir = "0.7"
infer = "0.15"
[profile.release]
opt-level = 3

View File

@@ -1,5 +1,5 @@
# --- Build stage ---
FROM rust:1.87-alpine AS builder
FROM rust:1.88-alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
@@ -11,15 +11,25 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
rm -rf src
COPY src ./src
COPY static ./static
COPY migrations ./migrations
RUN touch src/main.rs && cargo build --release
# --- Runtime stage ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates ffmpeg
# Run as an unprivileged user (defense-in-depth). The media volume mounts at
# /media; creating it here as `app` means a fresh named volume inherits app
# ownership and is writable without running as root. (An existing root-owned
# volume from a prior deploy needs a one-time `chown -R app:app` — see deploy notes.)
RUN apk add --no-cache ca-certificates ffmpeg \
&& addgroup -S app && adduser -S -G app app \
&& mkdir -p /media && chown app:app /media
WORKDIR /app
COPY --from=builder /app/target/release/eventsnap-backend ./
RUN chown -R app:app /app
USER app
EXPOSE 3000
CMD ["./eventsnap-backend"]

View File

@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_user_event_name_ci;
CREATE INDEX idx_user_event_name
ON "user"(event_id, display_name);

View File

@@ -0,0 +1,15 @@
-- Deduplicate users with the same name (case-insensitive) per event,
-- keeping the oldest account so no real data is lost.
DELETE FROM "user"
WHERE id NOT IN (
SELECT DISTINCT ON (event_id, LOWER(display_name)) id
FROM "user"
ORDER BY event_id, LOWER(display_name), created_at ASC
);
-- Drop the old non-unique index (replaced below)
DROP INDEX IF EXISTS idx_user_event_name;
-- Unique index enforces one account per name per event (case-insensitive)
CREATE UNIQUE INDEX idx_user_event_name_ci
ON "user" (event_id, LOWER(display_name));

View File

@@ -0,0 +1,2 @@
-- Remove compression_status field
ALTER TABLE upload DROP COLUMN compression_status;

View File

@@ -0,0 +1,6 @@
-- Add compression_status to track media processing state
ALTER TABLE upload ADD COLUMN compression_status TEXT NOT NULL DEFAULT 'pending';
-- Values: 'pending', 'processing', 'done', 'failed'
-- Add comment to document the field
COMMENT ON COLUMN upload.compression_status IS 'Tracks media compression/preview generation: pending -> processing -> (done or failed)';

View File

@@ -0,0 +1,11 @@
DELETE FROM config WHERE key IN (
'rate_limits_enabled',
'upload_rate_enabled',
'feed_rate_enabled',
'export_rate_enabled',
'join_rate_enabled',
'quota_enabled',
'storage_quota_enabled',
'upload_count_quota_enabled',
'privacy_note'
);

View File

@@ -0,0 +1,16 @@
-- Feature toggles for rate limits and quotas, plus the admin-configurable
-- Datenschutzhinweis. Everything lives in the `config` table — no schema change.
INSERT INTO config (key, value) VALUES
-- Rate limits (master + per-endpoint)
('rate_limits_enabled', 'true'),
('upload_rate_enabled', 'true'),
('feed_rate_enabled', 'true'),
('export_rate_enabled', 'true'),
('join_rate_enabled', 'true'),
-- Quotas (master + per-area)
('quota_enabled', 'true'),
('storage_quota_enabled', 'true'),
('upload_count_quota_enabled', 'true'),
-- Free-text privacy note shown to guests in My Account. Plain text — no HTML.
('privacy_note', '')
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,23 @@
-- Restore the original join-based v_feed definition.
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,26 @@
-- H6: replace v_feed's double LEFT JOIN + COUNT(DISTINCT) (which materializes a
-- likes×comments Cartesian per upload before de-duping) with correlated scalar
-- subqueries. Each count now uses its own index (idx_like_upload /
-- idx_comment_upload) and there is no GROUP BY. The output columns are
-- unchanged, so every consumer keeps working.
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,
(SELECT COUNT(*) FROM "like" l
WHERE l.upload_id = u.id) AS like_count,
(SELECT COUNT(*) FROM comment c
WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE;

View File

@@ -0,0 +1,28 @@
# Migrations
SQLx-managed Postgres migrations. Each `NNN_topic.up.sql` has a matching
`NNN_topic.down.sql`. Run by `sqlx::migrate!()` at app start.
## Rules
1. **Never edit a shipped migration.** If a column needs to change or a fix needs to
land, write a new migration. Production has already applied the old one and SQLx
tracks each by checksum — editing in place will fail to apply on existing databases.
2. **Always pair `.up.sql` with a `.down.sql`.** Reverts may not be perfect (data
loss is sometimes unavoidable) but the file must exist and do the best it can.
3. **Prefer additive changes.** New columns, new tables, new keys in `config`. Drop /
rename only when there is no alternative.
4. **No business logic in migrations.** Schema + seeds only. Anything that needs Rust
code goes in a one-off binary, not a migration file.
5. **One concern per migration.** Easier to revert. Easier to read in `git log`.
## Numbering
Zero-padded three digits, monotonically increasing. The next free number lives at the
bottom of the directory listing — pick that.
## Seed-only migrations
When you only need to add `config` keys (feature flags, defaults), use
`INSERT … ON CONFLICT DO NOTHING` so existing operator overrides survive. See
`009_feature_toggles.up.sql` for the canonical shape.

View File

@@ -1,7 +1,9 @@
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use chrono::{Duration, Utc};
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -12,6 +14,9 @@ use crate::error::AppError;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::services::password;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -29,14 +34,45 @@ pub struct JoinResponse {
pub async fn join(
State(state): State<AppState>,
headers: HeaderMap,
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;
if rate_limits_on && join_rate_on {
// Short burst cap (typos / double-taps) AND a generous per-IP daily cap
// on account creation. The daily cap bounds mass account-minting (which
// otherwise resets the per-user upload budget, see the event-wide count
// quota in the upload handler) while staying well above a real ~100-guest
// venue sharing one NAT'd IP.
let burst_ok =
state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60));
let daily_ok = state
.rate_limiter
.check(format!("join_day:{ip}"), 200, Duration::from_secs(86_400));
if !burst_ok || !daily_ok {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
let display_name = body.display_name.trim();
if display_name.is_empty() || display_name.len() > 50 {
let name_chars = display_name.chars().count();
if name_chars == 0 || name_chars > 50 {
return Err(AppError::BadRequest(
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
));
}
// Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers
// see a clean 400 instead of an internal error.
if display_name.contains('\0') {
return Err(AppError::BadRequest(
"Name enthält ungültige Zeichen.".into(),
));
}
let event = Event::find_or_create(
&state.pool,
@@ -45,10 +81,18 @@ pub async fn join(
)
.await?;
// Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
// Reject if a user with this name (case-insensitive) already exists
if User::name_taken(&state.pool, event.id, display_name).await? {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
// Generate a 6-digit PIN (≈20 bits vs the old ~13.3, so brute-forcing it
// against the 3-strike lockout is far harder).
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = password::hash(pin.clone(), 12).await?;
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
@@ -62,7 +106,7 @@ pub async fn join(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
Ok((
@@ -90,10 +134,33 @@ pub struct RecoverResponse {
pub async fn recover(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RecoverRequest>,
) -> Result<Json<RecoverResponse>, AppError> {
let display_name = body.display_name.trim();
// Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this
// an attacker who knows a display name (they're visible on the feed) can
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
// 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;
if rate_limits_on && recover_rate_on {
let name_key = display_name.to_lowercase();
if !state.rate_limiter.check(
format!("recover:{ip}:{name_key}"),
5,
Duration::from_secs(15 * 60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
@@ -108,17 +175,22 @@ pub async fn recover(
}
for user in &users {
// Check PIN lockout
// Check PIN lockout. The failed-attempt counter is deliberately NOT reset
// when the window expires — it drives the *escalating* backoff below, so a
// determined guesser faces an exponentially growing wait. A successful
// recovery (and a host PIN reset) is what clears it. The counter only ever
// grows on a *wrong* PIN, so a legitimate user is unaffected.
if let Some(locked_until) = user.pin_locked_until {
if Utc::now() < locked_until {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
"Zu viele Versuche. Bitte warte und versuche es später erneut.".into(),
None,
));
}
}
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
.unwrap_or(false);
let pin_matches =
password::verify(body.pin.clone(), user.recovery_pin_hash.clone()).await;
if pin_matches {
// Reset failed attempts on success
@@ -134,7 +206,7 @@ pub async fn recover(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
return Ok(Json(RecoverResponse {
@@ -145,9 +217,28 @@ pub async fn recover(
// Wrong PIN — increment failure count
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
tracing::warn!(
user_id = %user.id,
event_id = %event.id,
ip = %ip,
attempts,
"recover: wrong PIN"
);
if attempts >= 3 {
let lockout = Utc::now() + Duration::minutes(15);
// Escalating backoff: 15min, 30, 60, … doubling per failure past the
// threshold, capped at 24h. Makes sustained guessing against the
// 6-digit space (1M combinations) astronomically slow.
let exp = (attempts as u32).saturating_sub(3).min(10);
let minutes = 15i64.saturating_mul(1i64 << exp).min(24 * 60);
let lockout = Utc::now() + chrono::Duration::minutes(minutes);
User::lock_pin(&state.pool, user.id, lockout).await?;
tracing::warn!(
user_id = %user.id,
event_id = %event.id,
ip = %ip,
minutes,
"recover: account locked (escalating backoff)"
);
}
}
@@ -166,6 +257,7 @@ pub struct AdminLoginResponse {
pub async fn admin_login(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<AdminLoginRequest>,
) -> Result<Json<AdminLoginResponse>, AppError> {
if state.config.admin_password_hash.is_empty() {
@@ -174,10 +266,50 @@ pub async fn admin_login(
));
}
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
.unwrap_or(false);
// Throttle password attempts. The admin password is bcrypt-hashed (slow to
// verify) but with no IP-level limit a determined attacker can still mount
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
// honest typos.
let ip = client_ip(&headers, "unknown");
// Hard, non-disableable floor: admin_login is the one credential endpoint
// whose brute-force protection must survive the DB rate-limit master toggle
// being turned off. 30 attempts / 5 min / IP is generous for typos but caps
// sustained guessing regardless of config.
if !state.rate_limiter.check(
format!("admin_login_floor:{ip}"),
30,
Duration::from_secs(300),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
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;
if rate_limits_on && admin_rate_on
&& !state.rate_limiter.check(
format!("admin_login:{ip}"),
5,
Duration::from_secs(60),
)
{
return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
let valid = password::verify(
body.password.clone(),
state.config.admin_password_hash.clone(),
)
.await;
if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password");
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
}
@@ -194,9 +326,13 @@ pub async fn admin_login(
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
u
} else {
// Create admin user with a dummy PIN (admin authenticates via password)
let dummy_hash = bcrypt::hash("0000", 4)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
// Admin authenticates via password, but the schema still requires a PIN
// hash. Generate a random unguessable PIN so the recovery path remains
// unusable as an escalation route even if the role flag ever got cleared.
let dummy_pin: String = (0..32)
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
.collect();
let dummy_hash = password::hash(dummy_pin, 4).await?;
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
.bind(user.id)
@@ -207,6 +343,8 @@ pub async fn admin_login(
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
};
tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success");
let token = jwt::create_token(
admin_user.id,
event.id,
@@ -217,7 +355,7 @@ pub async fn admin_login(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(1);
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 }))

View File

@@ -13,6 +13,13 @@ pub struct Claims {
pub role: UserRole,
pub exp: i64,
pub iat: i64,
/// Random per-token identifier. Without it, two `create_token` calls in the
/// same wall-clock second for the same (sub, role, event) produce identical
/// JWT bytes — and identical sha256(token) hashes — which then collide on
/// the `session.token_hash` UNIQUE constraint. The jti is ignored by the
/// verifier but breaks the collision.
#[serde(default)]
pub jti: Uuid,
}
pub fn create_token(
@@ -29,6 +36,7 @@ pub fn create_token(
role,
iat: now.timestamp(),
exp: (now + Duration::days(expiry_days)).timestamp(),
jti: Uuid::new_v4(),
};
jsonwebtoken::encode(
&Header::default(),

View File

@@ -1,4 +1,4 @@
use axum::extract::{FromRequestParts, State};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use uuid::Uuid;
@@ -38,22 +38,44 @@ impl FromRequestParts<AppState> for AuthUser {
let token_hash = jwt::hash_token(token);
let session = Session::find_by_token_hash(&state.pool, &token_hash)
// Reconcile the session against the live `user` row. We do NOT trust the
// JWT claims for role/ban/event — a token can outlive a ban or demotion
// (default lifetime 30 days). The single JOIN query is the same number
// of round-trips as the old existence check.
let ctx = Session::find_auth_context(&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)
// Ban takes effect immediately on the next request, regardless of the
// token's remaining lifetime.
if ctx.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Defend against a JWT that was issued for a different user/event than
// the session's DB row points at (e.g. a swapped or replayed token).
if claims.sub != ctx.user_id || claims.event_id != ctx.event_id {
return Err(AppError::Unauthorized("Token passt nicht zur Sitzung.".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.
let pool = state.pool.clone();
let session_id = session.id;
let session_id = ctx.session_id;
tokio::spawn(async move {
let _ = Session::touch(&pool, session_id).await;
if let Err(e) = Session::touch(&pool, session_id).await {
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
}
});
Ok(Self {
user_id: claims.sub,
event_id: claims.event_id,
role: claims.role,
user_id: ctx.user_id,
event_id: ctx.event_id,
// Live role from the DB, not the claim — demotion/promotion takes
// effect on the next request.
role: ctx.role,
token_hash,
})
}

View File

@@ -1,6 +1,10 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
/// 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";
#[derive(Clone, Debug)]
pub struct AppConfig {
@@ -16,11 +20,41 @@ pub struct AppConfig {
impl AppConfig {
pub fn from_env() -> Result<Self> {
let app_env =
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
let is_prod = app_env.eq_ignore_ascii_case("production");
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
// A weak/placeholder signing key lets anyone forge any token (including
// admin). Detect the known dev sentinel, the `.env.example` placeholder,
// and anything that smells like an unreplaced template value.
let lower = jwt_secret.to_ascii_lowercase();
let looks_placeholder = jwt_secret == DEV_JWT_SECRET_SENTINEL
|| lower.contains("change")
|| lower.contains("example")
|| lower.contains("placeholder")
|| lower.contains("replace");
if is_prod {
// Production must use a real, strong secret — no placeholders, ≥64
// chars (an `openssl rand -hex 64` is 128 hex chars).
if looks_placeholder || jwt_secret.len() < 64 {
return Err(anyhow!(
"Refusing to start in production: JWT_SECRET is a placeholder or too short. \
Generate a real one (openssl rand -hex 64) and set it in the prod environment."
));
}
} else if looks_placeholder || jwt_secret.len() < 32 {
tracing::warn!(
"JWT_SECRET looks like a dev/placeholder value — fine for local development, \
NEVER ship this to production."
);
}
Ok(Self {
database_url: std::env::var("DATABASE_URL")
.context("DATABASE_URL must be set")?,
jwt_secret: std::env::var("JWT_SECRET")
.context("JWT_SECRET must be set")?,
jwt_secret,
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()

View File

@@ -2,9 +2,16 @@ use anyhow::{Context, Result};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
let pool = PgPoolOptions::new()
.max_connections(10)
.max_connections(max_connections)
.connect(database_url)
.await
.context("failed to connect to database")?;
@@ -14,6 +21,6 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
.await
.context("failed to run database migrations")?;
tracing::info!("database connected and migrations applied");
tracing::info!(max_connections, "database connected and migrations applied");
Ok(pool)
}

View File

@@ -1,6 +1,5 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
@@ -8,7 +7,9 @@ pub enum AppError {
Unauthorized(String),
Forbidden(String),
NotFound(String),
TooManyRequests(String),
Conflict(String),
/// Second field: optional retry-after seconds to include in the response.
TooManyRequests(String, Option<u64>),
Internal(anyhow::Error),
}
@@ -19,7 +20,8 @@ impl AppError {
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
}
}
@@ -30,7 +32,8 @@ impl AppError {
| Self::Unauthorized(msg)
| Self::Forbidden(msg)
| Self::NotFound(msg)
| Self::TooManyRequests(msg) => msg.clone(),
| Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
Self::Internal(err) => {
tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string()
@@ -42,13 +45,29 @@ impl AppError {
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = self.status_and_code();
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
Some(*secs)
} else {
None
};
let message = self.message();
let body = json!({
let mut body = serde_json::json!({
"error": code,
"message": message,
"status": status.as_u16(),
});
(status, axum::Json(body)).into_response()
if let Some(secs) = retry_after_secs {
body["retry_after_secs"] = secs.into();
}
let mut resp = (status, axum::Json(body)).into_response();
if let Some(secs) = retry_after_secs {
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
}
}
resp
}
}

View File

@@ -0,0 +1,362 @@
use std::collections::HashMap;
use std::time::Duration;
use axum::extract::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;
use crate::services::config;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct StatsDto {
pub user_count: i64,
pub upload_count: i64,
pub comment_count: i64,
pub disk_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_free_bytes: u64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct ExportJobDto {
pub id: uuid::Uuid,
pub r#type: String,
pub status: String,
pub progress_pct: i16,
pub error_message: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_stats(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<StatsDto>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let (user_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (upload_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (comment_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM comment c
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL",
)
.bind(event.id)
.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))
});
let disk_used = disk_total.saturating_sub(disk_free);
Ok(Json(StatsDto {
user_count,
upload_count,
comment_count,
disk_total_bytes: disk_total,
disk_used_bytes: disk_used,
disk_free_bytes: disk_free,
}))
}
pub async fn get_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, AppError> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
.fetch_all(&state.pool)
.await?;
Ok(Json(rows.into_iter().collect()))
}
#[derive(Deserialize)]
pub struct PatchConfigRequest(pub HashMap<String, String>);
pub async fn patch_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
// 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",
];
const BOOL_KEYS: &[&str] = &[
"rate_limits_enabled",
"upload_rate_enabled",
"feed_rate_enabled",
"export_rate_enabled",
"join_rate_enabled",
"quota_enabled",
"storage_quota_enabled",
"upload_count_quota_enabled",
];
const TEXT_KEYS: &[&str] = &["privacy_note"];
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
let mut privacy_note_changed = false;
for (key, value) in &body {
let key_str = key.as_str();
if NUMERIC_KEYS.contains(&key_str) {
if value.parse::<f64>().is_err() {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein."
)));
}
} else if BOOL_KEYS.contains(&key_str) {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
_ => {
return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss true oder false sein."
)));
}
}
} else if TEXT_KEYS.contains(&key_str) {
if value.len() > PRIVACY_NOTE_MAX_LEN {
return Err(AppError::BadRequest(format!(
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
)));
}
if key_str == "privacy_note" {
privacy_note_changed = true;
}
} else {
return Err(AppError::BadRequest(format!(
"Unbekannter Konfigurationsschlüssel: {key}"
)));
}
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)
.await?;
}
// Notify all clients that a publicly-readable config value changed so their stores
// (e.g. the privacy note in My Account) refresh without a manual reload.
if privacy_note_changed {
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"event-updated",
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_export_jobs(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<Vec<ExportJobDto>>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let jobs = sqlx::query_as::<_, ExportJobDto>(
"SELECT id, type::text, status::text, progress_pct, error_message, created_at, completed_at
FROM export_job
WHERE event_id = $1
ORDER BY created_at DESC",
)
.bind(event.id)
.fetch_all(&state.pool)
.await?;
Ok(Json(jobs))
}
// ── Export download endpoints (authenticated guests) ─────────────────────────
pub async fn download_zip(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_zip_ready {
return Err(AppError::NotFound(
"Der ZIP-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Gallery.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Gallery.zip", "application/zip").await
}
pub async fn download_html(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
enforce_export_rate(&state, &headers).await?;
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_html_ready {
return Err(AppError::NotFound(
"Der HTML-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Memories.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Memories.zip", "application/zip").await
}
async fn serve_file(
path: std::path::PathBuf,
filename: &str,
content_type: &str,
) -> Result<axum::response::Response, AppError> {
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
let disposition = format!("attachment; filename=\"{filename}\"");
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))?;
Ok(response)
}
/// Also expose export status to all authenticated users (guests need it for the export page)
pub async fn export_status(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let released = event.export_released_at.is_some();
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
)
.bind(event.id)
.fetch_all(&state.pool)
.await?;
let job_status = |type_name: &str| {
jobs.iter()
.find(|(t, _, _)| t == type_name)
.map(|(_, status, pct)| {
serde_json::json!({ "status": status, "progress_pct": pct })
})
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
};
Ok(Json(serde_json::json!({
"released": released,
"zip": job_status("zip"),
"html": job_status("html"),
})))
}
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
/// 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;
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;
if !state
.rate_limiter
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
{
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
Ok(())
}

View File

@@ -1,4 +1,7 @@
use std::time::Duration;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -6,6 +9,9 @@ use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::services::config;
use crate::services::media_token;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -22,6 +28,8 @@ pub struct FeedUpload {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Signed gateway URL for the full-resolution original. Always present.
pub original_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub like_count: i64,
@@ -50,11 +58,55 @@ struct FeedRow {
created_at: DateTime<Utc>,
}
/// Build a feed DTO, minting fresh signed gateway URLs for each artifact. The
/// preview/thumbnail URLs are present only when the derivative exists so the
/// client can show a skeleton while compression is still running; the original
/// URL is always present.
fn to_feed_upload(r: FeedRow, liked: bool, secret: &str, now: i64) -> FeedUpload {
FeedUpload {
liked_by_me: liked,
preview_url: r
.preview_path
.as_ref()
.map(|_| media_token::signed_url(secret, "preview", r.id, now)),
thumbnail_url: r
.thumbnail_path
.as_ref()
.map(|_| media_token::signed_url(secret, "thumbnail", r.id, now)),
original_url: Some(media_token::signed_url(secret, "original", r.id, now)),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
}
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
headers: HeaderMap,
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;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
if !state
.rate_limiter
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
{
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
let limit = q.limit.unwrap_or(20).min(100);
let rows = if let Some(hashtag) = &q.hashtag {
@@ -113,24 +165,13 @@ pub async fn feed(
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let now = Utc::now().timestamp();
let secret = &state.config.jwt_secret;
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}"));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url,
thumbnail_url,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
let liked = liked_set.contains(&r.id);
to_feed_upload(r, liked, secret, now)
})
.collect();
@@ -149,57 +190,90 @@ pub struct DeltaQuery {
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
/// Set when the delta was clamped (too-old cursor) or hit the row cap — the
/// client should do a full feed reload instead of trusting the partial set.
pub reload_required: bool,
}
/// Hard cap on how many uploads one delta returns. Beyond this the client is
/// told to reload rather than streaming the whole gallery through the view.
const DELTA_LIMIT: i64 = 200;
/// How far back a client-supplied `since` may reach. A tab backgrounded for days
/// must not pull the entire event on reconnect.
const DELTA_MAX_LOOKBACK_DAYS: i64 = 7;
pub async fn feed_delta(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
// H7: feed_delta runs the (expensive) feed query and fires on every tab
// refocus, so it needs the same rate limit as feed(), keyed per user.
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;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.pool, "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,
));
}
}
// Clamp the lookback server-side; signal a full reload if we had to.
let min_since = Utc::now() - chrono::Duration::days(DELTA_MAX_LOOKBACK_DAYS);
let clamped = q.since < min_since;
let since = if clamped { min_since } else { q.since };
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
LIMIT $3",
)
.bind(auth.event_id)
.bind(q.since)
.bind(since)
.bind(DELTA_LIMIT + 1)
.fetch_all(&state.pool)
.await?;
let capped = rows.len() as i64 > DELTA_LIMIT;
let rows: Vec<FeedRow> = rows.into_iter().take(DELTA_LIMIT as usize).collect();
let reload_required = clamped || capped;
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",
)
.bind(auth.event_id)
.bind(q.since)
.bind(since)
.fetch_all(&state.pool)
.await?;
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let now = Utc::now().timestamp();
let secret = &state.config.jwt_secret;
let uploads = rows
.into_iter()
.map(|r| FeedUpload {
liked_by_me: liked_set.contains(&r.id),
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}")),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
.map(|r| {
let liked = liked_set.contains(&r.id);
to_feed_upload(r, liked, secret, now)
})
.collect();
Ok(Json(DeltaResponse {
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
reload_required,
}))
}

View File

@@ -0,0 +1,474 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
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};
/// Revoke all of a user's sessions (best-effort). A failure here is logged but
/// never fails the surrounding admin action — the live-role/ban reconciliation
/// in `AuthUser` is the authoritative gate; revocation is defense-in-depth.
async fn revoke_sessions(pool: &sqlx::PgPool, user_id: Uuid, action: &str) {
match Session::delete_by_user_id(pool, user_id).await {
Ok(n) => tracing::info!(target_user_id = %user_id, revoked = n, action, "sessions revoked"),
Err(e) => tracing::warn!(error = ?e, target_user_id = %user_id, action, "session revoke failed"),
}
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize, sqlx::FromRow)]
pub struct UserSummary {
pub id: Uuid,
pub display_name: String,
pub role: String,
pub is_banned: bool,
pub uploads_hidden: bool,
pub upload_count: i64,
pub total_upload_bytes: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct EventStatus {
pub name: String,
pub is_active: bool,
pub uploads_locked: bool,
pub export_released: bool,
}
#[derive(Deserialize)]
pub struct BanRequest {
pub hide_uploads: bool,
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub role: String,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_event_status(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<Json<EventStatus>, AppError> {
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
Ok(Json(EventStatus {
name: event.name,
is_active: event.is_active,
uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_at.is_some(),
}))
}
pub async fn list_users(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<Json<Vec<UserSummary>>, AppError> {
let rows = sqlx::query_as::<_, UserSummary>(
"SELECT u.id,
u.display_name,
u.role::text AS role,
u.is_banned,
u.uploads_hidden,
COALESCE(COUNT(up.id), 0) AS upload_count,
u.total_upload_bytes,
u.created_at
FROM \"user\" u
LEFT JOIN upload up ON up.user_id = u.id AND up.deleted_at IS NULL
WHERE u.event_id = $1
GROUP BY u.id
ORDER BY u.created_at ASC",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
pub async fn ban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> {
// Cannot ban yourself or another host/admin
if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
}
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 sperren.".into()));
}
sqlx::query(
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1",
)
.bind(user_id)
.bind(body.hide_uploads)
.execute(&state.pool)
.await?;
revoke_sessions(&state.pool, user_id, "ban_user").await;
// Tell the banned user's online devices to log out immediately.
let _ = state.sse_tx.send(SseEvent::new(
"user-banned",
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"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn unban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
}
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
"host: unban_user"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_role(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<SetRoleRequest>,
) -> Result<StatusCode, AppError> {
if user_id == auth.user_id {
return Err(AppError::BadRequest(
"Du kannst deine eigene Rolle nicht ändern.".into(),
));
}
let new_role = match body.role.as_str() {
"guest" => "guest",
"host" => "host",
_ => {
return Err(AppError::BadRequest(
"Ungültige Rolle. Erlaubt: guest, host.".into(),
))
}
};
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
// promote guests and demote *other* hosts (the user explicitly requested this
// expansion). Hosts may not touch admins. Admins may do anything (except change
// themselves, blocked above).
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" {
return Err(AppError::Forbidden(
"Admins können nicht geändert werden.".into(),
));
}
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
.bind(user_id)
.bind(new_role)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// Force a clean re-auth so the new role can't be cached client-side and so
// any in-flight tokens carrying the old role are invalidated. The live-role
// reconciliation in AuthUser already prevents privilege escalation, but
// revoking is cheaper to reason about.
revoke_sessions(&state.pool, user_id, "set_role").await;
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
old_role = %target.0,
new_role,
"host: set_role"
);
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
pub struct PinResetResponse {
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
pub pin: String,
}
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
///
/// Authorisation:
/// - Host caller → may reset **guest** PINs only.
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
/// - Target ≠ caller.
pub async fn reset_user_pin(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<Json<PinResetResponse>, AppError> {
use rand::Rng;
if user_id == auth.user_id {
return Err(AppError::BadRequest(
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
));
}
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()))?;
match (auth.role.clone(), target.0.as_str()) {
(UserRole::Admin, "guest" | "host") => {}
(UserRole::Host, "guest") => {}
_ => {
return Err(AppError::Forbidden(
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
))
}
}
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = crate::services::password::hash(pin.clone(), 12).await?;
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
)
.bind(&pin_hash)
.bind(user_id)
.execute(&state.pool)
.await?;
// A PIN reset must invalidate existing sessions so a compromised/old token
// can't outlive the reset.
revoke_sessions(&state.pool, user_id, "reset_user_pin").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(
"pin-reset",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
"host: reset_user_pin"
);
Ok(Json(PinResetResponse { pin }))
}
pub async fn host_delete_upload(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
let _ = state.sse_tx.send(SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
upload_id = %upload_id,
"host: host_delete_upload"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn host_delete_comment(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
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()));
}
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
comment_id = %comment_id,
"host: host_delete_comment"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
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", "{}"));
Ok(StatusCode::NO_CONTENT)
}
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",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
Ok(StatusCode::NO_CONTENT)
}
pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
// Atomically claim the release AND enqueue the jobs in one transaction (M8).
// The claim UPDATE row-locks the event row and the lock is held until commit
// — *after* the export_job rows exist — so a second concurrent press re-reads
// the now-present jobs and matches 0 rows (clean 400). Doing the UPDATE and
// INSERTs as separate autocommit statements left a cross-table TOCTOU window
// where both presses could win and race the same output files.
//
// Re-release guard (M8-3): allow when never released, OR nothing is currently
// in progress and at least one job terminally failed. The old "every job
// failed" guard left a one-sided failure (zip done, html failed) permanently
// unrecoverable.
let mut tx = state.pool.begin().await?;
let claim = sqlx::query(
"UPDATE event SET export_released_at = NOW()
WHERE slug = $1
AND (
export_released_at IS NULL
OR (
NOT EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = event.id AND j.status IN ('running', 'pending')
)
AND EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = event.id AND j.status = 'failed'
)
)
)",
)
.bind(&state.config.event_slug)
.execute(&mut *tx)
.await?;
if claim.rows_affected() == 0 {
tx.rollback().await.ok();
return Err(AppError::BadRequest(
"Galerie wurde bereits freigegeben.".into(),
));
}
// Enqueue export jobs, resetting any prior terminal rows so the workers
// re-run cleanly (including an already-`done` sibling on re-release).
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 UPDATE SET status = 'pending', progress_pct = 0,
error_message = NULL, completed_at = NULL",
)
.bind(event.id)
.bind(export_type)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// Spawn export workers only after the claim+enqueue is durably committed.
crate::services::export::spawn_export_jobs(
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.sse_tx.clone(),
);
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,88 @@
//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because
//! these aren't about acquiring / refreshing a session — they're about reading my own
//! state once I'm already signed in.
//!
//! Current routes:
//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The
//! account page loads this once on mount instead of issuing several round trips.
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::handlers::upload::compute_storage_quota;
use crate::models::user::User;
use crate::services::config;
use crate::state::AppState;
#[derive(Serialize)]
pub struct QuotaDto {
pub enabled: bool,
pub used_bytes: i64,
pub limit_bytes: Option<i64>,
pub active_uploaders: i64,
pub free_disk_bytes: i64,
}
pub async fn get_quota(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<QuotaDto>, AppError> {
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let estimate = compute_storage_quota(&state).await;
Ok(Json(QuotaDto {
enabled: estimate.limit_bytes.is_some(),
used_bytes: user.total_upload_bytes,
limit_bytes: estimate.limit_bytes,
active_uploaders: estimate.active_uploaders,
free_disk_bytes: estimate.free_disk_bytes,
}))
}
#[derive(Serialize)]
pub struct MeContextDto {
pub user_id: uuid::Uuid,
pub display_name: String,
pub role: String,
/// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured.
pub privacy_note: String,
pub quota_enabled: bool,
pub storage_quota_enabled: bool,
/// Whether uploads are currently locked for the event, so the client can show
/// a banner + disable the upload affordance on load (not just via SSE).
pub uploads_locked: bool,
}
pub async fn get_context(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<MeContextDto>, AppError> {
let user = User::find_by_id(&state.pool, auth.user_id)
.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 uploads_locked = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.map(|e| e.uploads_locked_at.is_some())
.unwrap_or(false);
Ok(Json(MeContextDto {
user_id: user.id,
display_name: user.display_name,
role: user.role.as_str().to_string(),
privacy_note,
quota_enabled,
storage_quota_enabled,
uploads_locked,
}))
}

View File

@@ -0,0 +1,117 @@
//! Authenticated media gateway.
//!
//! Replaces the raw `/media` `ServeDir`. Every media byte now flows through a
//! signature check plus a DB lookup, so:
//! - soft-deleted uploads 404 (`find_by_id` filters `deleted_at`) — closes C3,
//! - ban-hidden uploaders' artifacts 404 (mirrors the `v_feed` rule) — H2,
//! - the export archives (which have no `upload` row) are unreachable — C1,
//! - HTML/SVG can't be served as an active document — the response carries
//! `nosniff` + a locked-down CSP (defense-in-depth behind the upload-time
//! allowlist) — C2 sink.
//!
//! Access is authorized by the embedded HMAC signature (see `media_token`), not
//! a Bearer header, because `<img>`/`<video>` cannot send one.
use axum::extract::{Path, Query, State};
use axum::response::Response;
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::upload::Upload;
use crate::models::user::User;
use crate::services::media_token;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct MediaQuery {
pub exp: i64,
pub sig: String,
}
pub async fn serve(
State(state): State<AppState>,
Path((kind, id)): Path<(String, Uuid)>,
Query(q): Query<MediaQuery>,
) -> Result<Response, AppError> {
if !media_token::verify(
&state.config.jwt_secret,
&kind,
id,
q.exp,
&q.sig,
Utc::now().timestamp(),
) {
return Err(AppError::Unauthorized(
"Ungültige oder abgelaufene Medien-URL.".into(),
));
}
// `find_by_id` filters `deleted_at IS NULL`, so soft-deleted uploads 404.
let upload = Upload::find_by_id(&state.pool, id)
.await?
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
// Hide artifacts of users whose uploads were hidden by a host (ban + hide),
// matching the `usr.uploads_hidden = FALSE` predicate in `v_feed`.
let uploader = User::find_by_id(&state.pool, upload.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
if uploader.uploads_hidden {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
let (rel_path, content_type) = match kind.as_str() {
"original" => (upload.original_path.clone(), upload.mime_type.clone()),
"preview" => (
upload
.preview_path
.clone()
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
"image/jpeg".to_string(),
),
"thumbnail" => (
upload
.thumbnail_path
.clone()
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
"image/jpeg".to_string(),
),
_ => return Err(AppError::NotFound("Unbekannter Medientyp.".into())),
};
stream_file(&state.config.media_path.join(&rel_path), &content_type).await
}
async fn stream_file(path: &std::path::Path, content_type: &str) -> Result<Response, AppError> {
use axum::body::Body;
use axum::http::{header, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(path)
.await
.map_err(|_| AppError::NotFound("Datei nicht gefunden.".into()))?;
let metadata = file
.metadata()
.await
.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_LENGTH, metadata.len())
// URLs are signed + time-boxed; cache only privately, aligned to the
// 1h URL-stability bucket.
.header(header::CACHE_CONTROL, "private, max-age=3600")
// Defense-in-depth against any active content slipping past the
// upload-time allowlist: never sniff, never script.
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; sandbox; frame-ancestors 'none'",
)
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}

View File

@@ -1,4 +1,9 @@
pub mod admin;
pub mod feed;
pub mod host;
pub mod me;
pub mod media;
pub mod social;
pub mod sse;
pub mod test_admin;
pub mod upload;

View File

@@ -1,6 +1,7 @@
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
@@ -8,6 +9,7 @@ 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;
pub async fn toggle_like(
@@ -15,13 +17,12 @@ pub async fn toggle_like(
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
// Ban is already rejected by the AuthUser extractor. Scope the target to the
// caller's event and reject deleted uploads (M1: no cross-event IDOR, no
// likes on soft-deleted posts).
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// Try to insert; if conflict, delete (toggle)
let result = sqlx::query(
@@ -51,12 +52,30 @@ pub async fn toggle_like(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize, Default)]
pub struct ListCommentsQuery {
/// RFC3339 timestamp — return only comments older than this. Pass the
/// `created_at` of the oldest currently-loaded comment to fetch the next
/// older page.
pub before: Option<DateTime<Utc>>,
}
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> {
let comments = Comment::list_for_upload(&state.pool, upload_id).await?;
// M1: a pure read behind any valid token must still be scoped to the
// caller's event and must not leak comments on soft-deleted uploads.
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))
}
@@ -71,15 +90,19 @@ pub async fn add_comment(
Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
// M1: scope the target upload to the caller's event and reject deleted
// posts. (Ban is already handled by the AuthUser extractor.)
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let text = body.body.trim();
if text.is_empty() || text.len() > 500 {
let text_chars = text.chars().count();
if text_chars == 0 || text_chars > 500 {
return Err(AppError::BadRequest(
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
));

View File

@@ -3,32 +3,51 @@ use std::time::Duration;
use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use futures::stream::Stream;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use crate::auth::jwt;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::session::Session;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct SseQuery {
pub token: String,
pub ticket: String,
}
/// SSE stream endpoint. Accepts JWT via query param since EventSource
/// doesn't support custom headers.
#[derive(Serialize)]
pub struct StreamTicketResponse {
pub ticket: String,
}
/// Mint a short-lived single-use SSE ticket. The browser's `EventSource` cannot
/// send an `Authorization` header, so the alternative used to be passing the JWT
/// as `?token=...` — which leaks the bearer token into access logs, referer
/// headers, and browser history. The client now exchanges its Bearer token for
/// an opaque ticket via this endpoint and passes that on the stream open.
pub async fn issue_ticket(
State(state): State<AppState>,
auth: AuthUser,
) -> Json<StreamTicketResponse> {
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(StreamTicketResponse { ticket })
}
/// SSE stream endpoint. Authenticates via a single-use ticket (see
/// [`issue_ticket`]) — never the raw JWT.
pub async fn stream(
State(state): State<AppState>,
Query(q): Query<SseQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
// Verify token
let _claims = jwt::verify_token(&q.token, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized("Token ungültig.".into()))?;
let token_hash = state
.sse_tickets
.consume(&q.ticket)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
let token_hash = jwt::hash_token(&q.token);
Session::find_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?

View File

@@ -0,0 +1,85 @@
//! Test-only admin routes. **Compiled in always, but only registered when
//! `EVENTSNAP_TEST_MODE=1` is set in the environment.** The route returns a hard
//! 404 in production builds because [`crate::main`] skips registering the handler.
//!
//! These exist to give the Playwright E2E suite a quick "reset everything"
//! escape hatch without forcing tests to maintain raw SQL fixtures or spin up a
//! fresh database container per test.
use axum::extract::State;
use axum::http::StatusCode;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::state::AppState;
/// Truncates every event-scoped table, wipes media on disk, and reseeds the
/// `config` table from migration defaults. Requires an admin JWT — even with
/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
pub async fn truncate_all(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<StatusCode, AppError> {
// Truncate in dependency order doesn't matter with CASCADE, but listing the
// tables explicitly makes the blast radius obvious in code review.
sqlx::query(
r#"TRUNCATE
comment_hashtag,
upload_hashtag,
hashtag,
"like",
comment,
export_job,
upload,
session,
"user",
event,
config
RESTART IDENTITY CASCADE"#,
)
.execute(&state.pool)
.await?;
// Reseed config — mirrors migrations 005 and 009. Kept in sync by hand
// because pulling SQL out of the migration files at runtime is fragile.
sqlx::query(
r#"INSERT INTO config (key, value) VALUES
('max_image_size_mb', '20'),
('max_video_size_mb', '500'),
('upload_rate_per_hour', '10'),
('feed_rate_per_min', '60'),
('export_rate_per_day', '3'),
('quota_tolerance', '0.75'),
('estimated_guest_count', '100'),
('compression_concurrency', '2'),
('rate_limits_enabled', 'false'),
('upload_rate_enabled', 'false'),
('feed_rate_enabled', 'false'),
('export_rate_enabled', 'false'),
('join_rate_enabled', 'false'),
('quota_enabled', 'false'),
('storage_quota_enabled', 'false'),
('upload_count_quota_enabled', 'false'),
('privacy_note', '')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"#,
)
.execute(&state.pool)
.await?;
// Wipe media directory. Best-effort: if it doesn't exist, that's fine.
let _ = tokio::fs::remove_dir_all(&state.config.media_path).await;
let _ = tokio::fs::create_dir_all(&state.config.media_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();
Ok(StatusCode::NO_CONTENT)
}
/// Returns whether the truncate endpoint is enabled. Used by the e2e harness
/// during global-setup to fail loud if the test backend was started without
/// `EVENTSNAP_TEST_MODE=1`.
pub fn is_test_mode() -> bool {
std::env::var("EVENTSNAP_TEST_MODE").as_deref() == Ok("1")
}

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use axum::extract::{Multipart, Path, State};
use axum::http::StatusCode;
use axum::Json;
@@ -9,36 +11,93 @@ use crate::error::AppError;
use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::{Upload, UploadDto};
use crate::models::user::User;
use crate::services::config;
use crate::state::AppState;
const MAX_CAPTION_LENGTH: usize = 2000;
/// Derive the canonical MIME type and a *safe* file extension purely from the
/// file's magic bytes, against a strict allowlist. The client-declared
/// `Content-Type` and the client filename are never trusted: the old code
/// skipped validation entirely for `application/*` and copied the extension
/// verbatim from the filename, which allowed storing `{uuid}.html`/`.svg` with
/// a script payload (stored XSS) and `/`-bearing extensions (path pollution +
/// export DoS).
///
/// `infer` cannot fingerprint HTML/SVG, so the reject-by-default arm is exactly
/// what closes the XSS vector — anything not positively identified as an allowed
/// raster image or video is refused. Returns `(canonical_mime, safe_ext, is_video)`.
fn classify(data: &[u8]) -> Result<(&'static str, &'static str, bool), AppError> {
let kind = infer::get(data)
.ok_or_else(|| AppError::BadRequest("Dateityp konnte nicht erkannt werden.".into()))?;
match kind.mime_type() {
"image/jpeg" => Ok(("image/jpeg", "jpg", false)),
"image/png" => Ok(("image/png", "png", false)),
"image/webp" => Ok(("image/webp", "webp", false)),
// infer reports HEIC/HEIF as image/heif
"image/heif" | "image/heic" => Ok(("image/heic", "heic", false)),
"video/mp4" => Ok(("video/mp4", "mp4", true)),
"video/quicktime" => Ok(("video/quicktime", "mov", true)),
"video/webm" => Ok(("video/webm", "webm", true)),
other => Err(AppError::BadRequest(format!(
"Dateityp nicht erlaubt: {other}. Erlaubt sind JPEG, PNG, WebP, HEIC, MP4, MOV, WebM."
))),
}
}
pub async fn upload(
State(state): State<AppState>,
auth: AuthUser,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// Check if user is banned
// 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;
if rate_limits_on && upload_rate_on {
let upload_rate = config::get_i64(&state.pool, "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,
Duration::from_secs(3600),
) {
drain_multipart(multipart).await;
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
Some(retry_after_secs),
));
}
}
// Ban is already rejected by the AuthUser extractor. We still load the user
// for the uploader's display name in the response DTO.
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Check if uploads are locked
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
}
// Read config limits from DB
let max_image_mb: i64 = get_config_i64(&state.pool, "max_image_size_mb", 20).await;
let max_video_mb: i64 = get_config_i64(&state.pool, "max_video_size_mb", 500).await;
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 mut file_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut content_type: Option<String> = None;
// Bound concurrent upload-body buffering to cap aggregate RAM (H3). Acquired
// *before* the multipart body is read, so requests waiting on a permit hold
// only a connection, not a buffered ~550 MB file. The permit is held for the
// rest of the handler (body read + write) and released on return.
let _upload_permit = state
.upload_limiter
.acquire()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let mut file_data: Option<axum::body::Bytes> = None;
let mut caption: Option<String> = None;
let mut hashtags_csv: Option<String> = None;
@@ -46,12 +105,13 @@ pub async fn upload(
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());
// The client-declared filename and Content-Type are deliberately
// ignored — the stored MIME and extension are derived from the
// file's magic bytes (see `classify`). Kept as `Bytes` (one copy
// off the wire) rather than an extra `.to_vec()`.
file_data = Some(
field.bytes().await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
.to_vec(),
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?,
);
}
"caption" => {
@@ -71,11 +131,27 @@ pub async fn upload(
}
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;
// Validate file size
let max_bytes = if mime.starts_with("video/") {
// 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 {
return Err(AppError::BadRequest(format!(
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
MAX_CAPTION_LENGTH
)));
}
}
// Derive canonical MIME + safe extension from magic bytes against a strict
// allowlist. The client Content-Type and filename are never trusted.
let (canonical_mime, safe_ext, is_video) = classify(&data)?;
let canonical_mime = canonical_mime.to_string();
// Validate file size against the per-category limit.
let max_bytes = if is_video {
max_video_mb * 1024 * 1024
} else {
max_image_mb * 1024 * 1024
@@ -87,41 +163,120 @@ pub async fn upload(
)));
}
// 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;
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
let relative_path = format!("originals/{event_slug}/{upload_id}.{safe_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()))?;
// 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. `None` ⇒ enforcement off.
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_limit: Option<i64> = if quota_on && storage_quota_on {
compute_storage_quota(&state).await.limit_bytes
} else {
None
};
// Update user's total upload bytes
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
// Event-wide file-count cap. Unlike the per-user budget, this can't be reset
// by minting a fresh account via /join, so it bounds total uploads no matter
// how many accounts an abuser creates. Wires up the previously-dead
// `upload_count_quota_*` config (default off to preserve existing behavior).
if quota_on && config::get_bool(&state.pool, "upload_count_quota_enabled", false).await {
let max_count = config::get_i64(&state.pool, "upload_count_quota_max", 10_000).await;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(auth.event_id)
.fetch_one(&state.pool)
.await?;
if count >= max_count {
return Err(AppError::TooManyRequests(
"Das Upload-Limit für dieses Event ist erreicht.".into(),
None,
));
}
}
// Reserve quota and insert the row in a single transaction so the byte
// counter and the upload row are atomic (M6: no quota leak if we crash
// between them) and the reservation is a conditional UPDATE that row-locks
// (M5: concurrent uploads can no longer all pass a stale snapshot and
// overshoot the limit). The file is written before commit and unlinked on
// any rollback so a failed transaction leaves nothing behind.
let mut tx = state.pool.begin().await?;
let reserved: Option<(Uuid,)> = if let Some(limit) = quota_limit {
sqlx::query_as(
"UPDATE \"user\"
SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3
RETURNING id",
)
.bind(auth.user_id)
.bind(size)
.execute(&state.pool)
.await?;
.bind(limit)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"UPDATE \"user\"
SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1
RETURNING id",
)
.bind(auth.user_id)
.bind(size)
.fetch_optional(&mut *tx)
.await?
};
if reserved.is_none() {
// The user exists (checked above), so a missing row here means the
// conditional limit guard rejected the reservation → over quota.
tx.rollback().await.ok();
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
// Insert upload record
let upload = Upload::create(
&state.pool,
// Write the file. On any failure, roll back (releasing the reservation) and
// remove a possibly-partial file.
if let Some(parent) = absolute_path.parent() {
if let Err(e) = tokio::fs::create_dir_all(parent).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}
}
if let Err(e) = tokio::fs::write(&absolute_path, data.as_ref()).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}
let upload = match Upload::create(
&mut *tx,
auth.event_id,
auth.user_id,
&relative_path,
&mime,
&canonical_mime,
size,
caption.as_deref(),
)
.await?;
.await
{
Ok(u) => u,
Err(e) => {
tx.rollback().await.ok();
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e.into());
}
};
if let Err(e) = tx.commit().await {
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e.into());
}
// Process hashtags from caption and explicit CSV
let mut tags: Vec<String> = Vec::new();
@@ -147,7 +302,7 @@ pub async fn upload(
// Spawn compression task
state
.compression
.process(upload.id, relative_path, mime.clone());
.process(upload.id, relative_path, canonical_mime.clone());
// Broadcast SSE event
let dto = UploadDto {
@@ -156,7 +311,13 @@ pub async fn upload(
uploader_name: user.display_name,
preview_url: None,
thumbnail_url: None,
mime_type: mime,
original_url: Some(crate::services::media_token::signed_url(
&state.config.jwt_secret,
"original",
upload.id,
chrono::Utc::now().timestamp(),
)),
mime_type: canonical_mime,
caption,
hashtags: tags,
like_count: 0,
@@ -165,10 +326,10 @@ pub async fn upload(
created_at: upload.created_at,
};
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-upload".to_string(),
data: serde_json::to_string(&dto).unwrap_or_default(),
});
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"new-upload",
serde_json::to_string(&dto).unwrap_or_default(),
));
Ok((StatusCode::CREATED, Json(dto)))
}
@@ -221,17 +382,120 @@ pub async fn delete_upload(
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
}
Upload::soft_delete(&state.pool, upload_id).await?;
if let Some(paths) = Upload::soft_delete(&state.pool, upload_id).await? {
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
}
// Tell other clients to drop the photo immediately (mirrors host delete).
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
.unwrap_or(None);
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
/// 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.
async fn drain_multipart(mut mp: Multipart) {
while let Ok(Some(mut field)) = mp.next_field().await {
while field.chunk().await.ok().flatten().is_some() {}
}
}
/// Snapshot of the dynamic per-user quota used both by the upload pre-check and the
/// `GET /me/quota` endpoint. `limit_bytes = None` means quota enforcement is currently
/// off (the frontend hides the widget in that case).
pub struct QuotaEstimate {
pub limit_bytes: Option<i64>,
pub active_uploaders: i64,
pub free_disk_bytes: i64,
pub tolerance: f64,
}
/// 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 (active_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
)
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
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;
let limit_bytes = if quota_on && storage_quota_on {
Some(((free_disk as f64 * tolerance) / active as f64).floor() as i64)
} else {
None
};
QuotaEstimate {
limit_bytes,
active_uploaders: active,
free_disk_bytes: free_disk,
tolerance,
}
}
// The original-file download is now served by the authenticated, signed media
// gateway (`handlers::media::serve`). The old unauthenticated `get_original`
// alias was the H2 vulnerability and has been removed.
#[cfg(test)]
mod tests {
use super::classify;
#[test]
fn classify_accepts_allowed_image_types() {
// PNG signature
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
let (mime, ext, is_video) = classify(&png).expect("png allowed");
assert_eq!(mime, "image/png");
assert_eq!(ext, "png");
assert!(!is_video);
// JPEG signature
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0];
let (mime, ext, _) = classify(&jpeg).expect("jpeg allowed");
assert_eq!(mime, "image/jpeg");
assert_eq!(ext, "jpg");
}
#[test]
fn classify_rejects_unrecognized_and_html_svg() {
// Plain HTML / script payload — infer cannot fingerprint it, so it must
// be rejected (this is the stored-XSS vector the old code let through
// via a declared `application/*` Content-Type).
let html = b"<html><script>alert(1)</script></html>";
assert!(classify(html).is_err());
// SVG (text-based) is likewise unrecognized → rejected.
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>x</script></svg>";
assert!(classify(svg).is_err());
// Empty / garbage.
assert!(classify(&[]).is_err());
assert!(classify(&[0x00, 0x01, 0x02, 0x03]).is_err());
}
}

View File

@@ -1,7 +1,8 @@
use anyhow::Result;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post};
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod auth;
@@ -29,7 +30,23 @@ async fn main() -> Result<()> {
let config = AppConfig::from_env()?;
let pool = db::create_pool(&config.database_url).await?;
let state = AppState::new(pool, config.clone());
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
// stuck `compression_status='processing'` uploads and `status='running'` export
// jobs. Must run before the server starts taking requests so clients never see
// the half-state.
services::maintenance::startup_recovery(&pool).await;
let state = AppState::new(pool.clone(), config.clone());
// 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(
pool,
state.rate_limiter.clone(),
state.sse_tickets.clone(),
config.media_path.clone(),
);
// Ensure media directories exist
tokio::fs::create_dir_all(&config.media_path).await.ok();
@@ -40,12 +57,18 @@ async fn main() -> Result<()> {
.route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// Upload
.route("/api/v1/upload", post(handlers::upload::upload))
// Upload — cap the request body so a single request can't buffer the box
// into OOM. Sized to the largest allowed video (500 MB) plus multipart
// overhead; the per-category limit is still enforced inside the handler.
.route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::max(550 * 1024 * 1024)))
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
)
// 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))
// Feed
.route("/api/v1/feed", get(handlers::feed::feed))
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
@@ -58,15 +81,74 @@ async fn main() -> Result<()> {
)
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
// SSE
.route("/api/v1/stream", get(handlers::sse::stream));
.route("/api/v1/stream", get(handlers::sse::stream))
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
// Host Dashboard
.route("/api/v1/host/event", get(handlers::host::get_event_status))
.route("/api/v1/host/event/close", post(handlers::host::close_event))
.route("/api/v1/host/event/open", post(handlers::host::open_event))
.route("/api/v1/host/gallery/release", post(handlers::host::release_gallery))
.route("/api/v1/host/users", get(handlers::host::list_users))
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
.route(
"/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin),
)
.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/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
.route("/api/v1/admin/stats", get(handlers::admin::get_stats))
.route(
"/api/v1/admin/config",
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
)
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
// Serve media files from disk
let media_service = ServeDir::new(&config.media_path);
// Test-only route: a hard reset for the Playwright E2E harness. The handler
// is compiled in always, but the route is only attached when
// `EVENTSNAP_TEST_MODE=1`. In production the call returns 404 — the route
// simply isn't there.
let api = if handlers::test_admin::is_test_mode() {
tracing::warn!(
"EVENTSNAP_TEST_MODE=1 — registering /api/v1/admin/__truncate. \
DO NOT enable this in production."
);
api.route(
"/api/v1/admin/__truncate",
post(handlers::test_admin::truncate_all),
)
} else {
api
};
// Media is served exclusively through the authenticated, signed gateway —
// there is no raw static mount. The gateway verifies an HMAC signature and
// consults the DB (deleted / ban-hidden / type), so private artifacts and
// the export archives are never reachable by guessing a path.
// Trace spans log the request *path* only, never the query string — signed
// media URLs carry a replayable `?sig=` capability that must not land in
// access logs.
let trace_layer = TraceLayer::new_for_http().make_span_with(
|req: &axum::http::Request<axum::body::Body>| {
tracing::info_span!(
"request",
method = %req.method(),
path = %req.uri().path(),
)
},
);
let router = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
.nest_service("/media", media_service)
.route("/media/{kind}/{id}", get(handlers::media::serve))
.layer(trace_layer)
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;

View File

@@ -40,15 +40,35 @@ impl Comment {
.await
}
pub async fn list_for_upload(pool: &PgPool, upload_id: Uuid) -> Result<Vec<CommentDto>, sqlx::Error> {
/// Paginated comment listing — returns up to `limit` rows in chronological
/// order (oldest first). If `before` is set, only comments older than that
/// timestamp are returned, enabling backward cursor pagination ("load
/// earlier"). Without the LIMIT a hot post with thousands of comments could
/// OOM the server on a single GET.
pub async fn list_for_upload(
pool: &PgPool,
upload_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<Vec<CommentDto>, sqlx::Error> {
// Two-step: pick the newest `limit` rows older than `before`, then flip
// them back into ascending order so the caller can render top-to-bottom.
sqlx::query_as::<_, CommentDto>(
"SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC",
"SELECT * FROM (
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
AND ($2::timestamptz IS NULL OR c.created_at < $2)
ORDER BY c.created_at DESC
LIMIT $3
) page
ORDER BY created_at ASC",
)
.bind(upload_id)
.bind(before)
.bind(limit)
.fetch_all(pool)
.await
}
@@ -69,4 +89,25 @@ impl Comment {
.await?;
Ok(())
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
/// comment doesn't exist or belongs to a different event.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
"UPDATE comment
SET deleted_at = NOW()
WHERE id = $1
AND deleted_at IS NULL
AND upload_id IN (SELECT id FROM upload WHERE event_id = $2)",
)
.bind(id)
.bind(event_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
}

View File

@@ -67,11 +67,46 @@ impl Hashtag {
}
}
/// Extract #hashtags from text (caption or body).
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
/// ASCII letters, digits, and underscore — emoji / punctuation / accented
/// characters are rejected. This is deliberately strict: hashtags are an index
/// (used for filtering and SQL JOINs), and tags like `#🎉` or `#!?` accumulate
/// noise without helping anyone find content.
pub fn extract_hashtags(text: &str) -> Vec<String> {
const MAX_TAG_LEN: usize = 40;
text.split_whitespace()
.filter(|w| w.starts_with('#') && w.len() > 1)
.map(|w| w.trim_start_matches('#').to_lowercase())
.filter(|t| !t.is_empty())
.filter_map(|w| w.strip_prefix('#'))
.map(|t| {
t.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<String>()
.to_lowercase()
})
.filter(|t| !t.is_empty() && t.chars().count() <= MAX_TAG_LEN)
.collect()
}
#[cfg(test)]
mod tests {
use super::extract_hashtags;
#[test]
fn ascii_word_chars_extracted() {
assert_eq!(
extract_hashtags("hello #wedding #Day_2 #cake!"),
vec!["wedding", "day_2", "cake"]
);
}
#[test]
fn emojis_and_punctuation_excluded() {
// The 🎉 tag drops out entirely (no ASCII chars after #), the next tag
// stops at the !? and yields only "fun".
assert_eq!(extract_hashtags("#🎉 #!? #fun!"), vec!["fun"]);
}
#[test]
fn empty_or_bare_hash_skipped() {
assert_eq!(extract_hashtags("# #"), Vec::<String>::new());
}
}

View File

@@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::user::UserRole;
#[derive(Debug, sqlx::FromRow)]
pub struct Session {
pub id: Uuid,
@@ -12,6 +14,18 @@ pub struct Session {
pub created_at: DateTime<Utc>,
}
/// Live identity reconciled from the DB for a valid, unexpired session. Used by
/// the `AuthUser` extractor so role/ban/event are sourced from the `user` row
/// rather than trusted from (potentially stale) JWT claims.
#[derive(Debug, sqlx::FromRow)]
pub struct AuthContext {
pub session_id: Uuid,
pub user_id: Uuid,
pub event_id: Uuid,
pub role: UserRole,
pub is_banned: bool,
}
impl Session {
pub async fn create(
pool: &PgPool,
@@ -43,6 +57,25 @@ impl Session {
.await
}
/// Reconcile a session against the live `user` row in a single round-trip.
/// Returns `None` when the session is missing/expired. The PK join to
/// `"user"` is cheap and lets the extractor read the *current* role/ban
/// state instead of the JWT claim.
pub async fn find_auth_context(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<AuthContext>, sqlx::Error> {
sqlx::query_as::<_, AuthContext>(
"SELECT s.id AS session_id, u.id AS user_id, u.event_id, u.role, u.is_banned
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
}
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)
@@ -61,4 +94,15 @@ impl Session {
.await?;
Ok(())
}
/// Revoke every session belonging to a user — used on ban, role change, and
/// PIN reset so existing JWTs stop working immediately. Returns the number
/// of sessions removed. Best-effort: callers log but do not fail on error.
pub async fn delete_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let result = sqlx::query("DELETE FROM session WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}

View File

@@ -14,10 +14,20 @@ pub struct Upload {
pub mime_type: String,
pub original_size_bytes: i64,
pub caption: Option<String>,
pub compression_status: String,
pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
/// On-disk artifact paths returned by the soft-delete methods so the caller can
/// unlink the files after the DB commit.
#[derive(Debug, sqlx::FromRow)]
pub struct DeletedPaths {
pub original: String,
pub preview: Option<String>,
pub thumbnail: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UploadDto {
pub id: Uuid,
@@ -25,6 +35,8 @@ pub struct UploadDto {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Signed gateway URL for the full-resolution original. Always present.
pub original_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub hashtags: Vec<String>,
@@ -35,15 +47,21 @@ pub struct UploadDto {
}
impl Upload {
pub async fn create(
pool: &PgPool,
/// Generic over the executor so it can run inside the upload transaction
/// (`&mut *tx`) alongside the quota-counter reservation, keeping the
/// byte-counter and the row atomic (no quota leak on a mid-write crash).
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)
@@ -55,7 +73,7 @@ impl Upload {
.bind(mime_type)
.bind(original_size_bytes)
.bind(caption)
.fetch_one(pool)
.fetch_one(executor)
.await
}
@@ -68,6 +86,23 @@ impl Upload {
.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(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM upload
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL",
)
.bind(id)
.bind(event_id)
.fetch_optional(pool)
.await
}
pub async fn set_preview_path(
pool: &PgPool,
id: Uuid,
@@ -94,12 +129,80 @@ impl Upload {
Ok(())
}
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
.bind(id)
.execute(pool)
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
/// Done in a single transaction so a crash between the two writes can't leave
/// the quota counter pointing at bytes the user has already deleted (which would
/// silently lock them out of future uploads).
///
/// No-op if the row is already deleted — protects against a double-tap on the
/// delete action double-decrementing the counter.
///
/// Returns the artifact paths of the row that was deleted (`None` if nothing
/// matched) so the caller can unlink the files after the commit.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
WHERE id = $1",
)
.bind(user_id)
.bind(bytes)
.execute(&mut *tx)
.await?;
Ok(())
Some(DeletedPaths { original, preview, thumbnail })
} else {
None
};
tx.commit().await?;
Ok(paths)
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
/// matched (already deleted, wrong event, or unknown id) so host handlers
/// can return a clean 404, and the artifact paths otherwise.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
)
.bind(id)
.bind(event_id)
.fetch_optional(&mut *tx)
.await?;
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
WHERE id = $1",
)
.bind(user_id)
.bind(bytes)
.execute(&mut *tx)
.await?;
Some(DeletedPaths { original, preview, thumbnail })
} else {
None
};
tx.commit().await?;
Ok(paths)
}
pub async fn update_caption(
@@ -114,4 +217,17 @@ impl Upload {
.await?;
Ok(())
}
pub async fn set_compression_status(
pool: &PgPool,
id: Uuid,
status: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET compression_status = $2 WHERE id = $1")
.bind(id)
.bind(status)
.execute(pool)
.await?;
Ok(())
}
}

View File

@@ -4,6 +4,7 @@ use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
Guest,
@@ -11,6 +12,16 @@ pub enum UserRole {
Admin,
}
impl UserRole {
pub fn as_str(&self) -> &'static str {
match self {
UserRole::Guest => "guest",
UserRole::Host => "host",
UserRole::Admin => "admin",
}
}
}
#[derive(Debug, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
@@ -58,7 +69,7 @@ impl User {
display_name: &str,
) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2",
"SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)",
)
.bind(event_id)
.bind(display_name)
@@ -66,6 +77,21 @@ impl User {
.await
}
pub async fn name_taken(
pool: &PgPool,
event_id: Uuid,
display_name: &str,
) -> Result<bool, sqlx::Error> {
let row: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))",
)
.bind(event_id)
.bind(display_name)
.fetch_one(pool)
.await?;
Ok(row.0)
}
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
let row: (i16,) = sqlx::query_as(
"UPDATE \"user\"

View File

@@ -3,24 +3,38 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use sqlx::PgPool;
use tokio::sync::Semaphore;
use tokio::sync::{broadcast, Semaphore};
use uuid::Uuid;
use crate::models::upload::Upload;
use crate::state::SseEvent;
#[derive(Clone)]
pub struct CompressionWorker {
semaphore: Arc<Semaphore>,
/// Separate permit pools for images and videos so a couple of slow/large
/// videos (each holding a permit across the full ffmpeg wait) can never
/// starve image-preview generation, and vice versa.
image_sem: Arc<Semaphore>,
video_sem: Arc<Semaphore>,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
}
impl CompressionWorker {
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize) -> Self {
pub fn new(
pool: PgPool,
media_path: PathBuf,
image_concurrency: usize,
video_concurrency: usize,
sse_tx: broadcast::Sender<SseEvent>,
) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(concurrency)),
image_sem: Arc::new(Semaphore::new(image_concurrency)),
video_sem: Arc::new(Semaphore::new(video_concurrency)),
pool,
media_path,
sse_tx,
}
}
@@ -28,9 +42,31 @@ impl CompressionWorker {
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone();
tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await;
if let Err(e) = worker.do_process(upload_id, &original_path, &mime_type).await {
tracing::error!("compression failed for upload {upload_id}: {e:#}");
let is_video = mime_type.starts_with("video/");
let sem = if is_video { &worker.video_sem } else { &worker.image_sem };
let _permit = sem.acquire().await;
match worker.do_process(upload_id, &original_path, &mime_type).await {
Ok(_) => {
tracing::info!("compression completed for upload {upload_id}");
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-processed".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
}
Err(e) => {
// Log the detailed error (incl. paths) server-side only; the
// SSE channel is broadcast to every client, so send a generic
// message — never leak absolute filesystem paths.
tracing::error!("compression failed for upload {upload_id}: {e:#}");
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-error".to_string(),
data: serde_json::json!({
"upload_id": upload_id,
"error": "Verarbeitung fehlgeschlagen."
}).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
}
}
});
}
@@ -41,6 +77,8 @@ impl CompressionWorker {
original_path: &str,
mime_type: &str,
) -> Result<()> {
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
let original = self.media_path.join(original_path);
if mime_type.starts_with("image/") {
@@ -53,6 +91,7 @@ impl CompressionWorker {
tracing::info!("thumbnail generated for upload {upload_id}");
}
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
Ok(())
}
@@ -71,10 +110,25 @@ impl CompressionWorker {
let preview_path_clone = preview_path.clone();
let mime_owned = mime_type.to_string();
// 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")?;
// Run blocking image operations in a spawn_blocking task, bounded by a
// hard timeout (mirrors the ffmpeg guard) so a pathological decode can't
// hold the permit forever.
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
use image::ImageReader;
// Reject decompression bombs *before* fully decoding: a small file can
// otherwise expand to enormous dimensions and a very expensive resize.
// 12000×12000 covers any real phone photo; max_alloc caps memory.
let mut reader = 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);
@@ -95,8 +149,12 @@ impl CompressionWorker {
}
Ok(())
})
.await??;
});
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
Ok(join) => join.context("image task panicked")??,
Err(_) => anyhow::bail!("image processing timeout after 120s"),
}
Ok(format!("previews/{preview_filename}"))
}
@@ -112,7 +170,11 @@ impl CompressionWorker {
let thumb_filename = format!("{upload_id}.jpg");
let thumb_path = thumbs_dir.join(&thumb_filename);
let output = tokio::process::Command::new("ffmpeg")
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a
// cap, the held compression-worker semaphore permit is never released and the
// pool eventually deadlocks (no further uploads ever processed). 120s is well
// above the time to extract one frame from any sane input.
let mut child = tokio::process::Command::new("ffmpeg")
.args([
"-i",
original.to_str().unwrap_or_default(),
@@ -125,13 +187,36 @@ impl CompressionWorker {
"-y",
thumb_path.to_str().unwrap_or_default(),
])
.output()
.await
.context("failed to run ffmpeg")?;
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.context("failed to spawn ffmpeg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ffmpeg failed: {stderr}");
let status = match tokio::time::timeout(
std::time::Duration::from_secs(120),
child.wait(),
)
.await
{
Ok(res) => res.context("ffmpeg wait failed")?,
Err(_) => {
let _ = child.kill().await;
anyhow::bail!("ffmpeg timeout after 120s");
}
};
if !status.success() {
// Best-effort: drain stderr for the log.
let mut stderr = Vec::new();
if let Some(mut handle) = child.stderr.take() {
use tokio::io::AsyncReadExt;
let _ = handle.read_to_end(&mut stderr).await;
}
anyhow::bail!(
"ffmpeg failed: {}",
String::from_utf8_lossy(&stderr)
);
}
Ok(format!("thumbnails/{thumb_filename}"))

View File

@@ -0,0 +1,49 @@
//! Reads of the runtime-tunable `config` table.
//!
//! 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_*`.
//!
//! 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 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)
}
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String {
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string())
}
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)
}
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_f64(pool: &PgPool, key: &str, default: f64) -> f64 {
fetch_raw(pool, 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 };
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => default,
}
}

View File

@@ -0,0 +1,626 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo};
use include_dir::{include_dir, Dir};
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio_util::compat::TokioAsyncReadCompatExt;
use uuid::Uuid;
use crate::state::SseEvent;
// ── Embedded viewer assets (pre-built SvelteKit static output) ──────────────
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
// ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ExportUploadRow {
id: Uuid,
original_path: String,
mime_type: String,
caption: Option<String>,
uploader_name: String,
like_count: i64,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct ExportCommentRow {
upload_id: Uuid,
uploader_name: String,
body: String,
created_at: DateTime<Utc>,
}
// ── Viewer JSON structs (serialised to data.json) ───────────────────────────
#[derive(Serialize)]
struct ViewerData {
event: ViewerEvent,
posts: Vec<ViewerPost>,
}
#[derive(Serialize)]
struct ViewerEvent {
name: String,
exported_at: String,
}
#[derive(Serialize)]
struct ViewerPost {
id: String,
uploader: String,
caption: String,
tags: Vec<String>,
timestamp: String,
likes: i64,
comments: Vec<ViewerComment>,
media: ViewerMedia,
}
#[derive(Serialize)]
struct ViewerComment {
author: String,
text: String,
timestamp: String,
}
#[derive(Serialize)]
struct ViewerMedia {
#[serde(rename = "type")]
media_type: String,
thumb: String,
full: String,
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let pool2 = pool.clone();
let media_path2 = media_path.clone();
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
// Per-run id so two runs (e.g. a re-release after a crash mid-export, where
// startup_recovery marked the old run 'failed' but its task may still be
// winding down) write to distinct temp paths and can't truncate each other's
// output (M8). The final served filenames stay fixed.
let run_id = Uuid::new_v4();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, run_id, &pool, &media_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, run_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
}
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
}
// ── ZIP export ───────────────────────────────────────────────────────────────
async fn run_zip_export(
event_id: Uuid,
run_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// Per-run temp name; final served name stays fixed.
let tmp_path = exports_dir.join(format!("Gallery.{run_id}.zip.tmp"));
let out_path = exports_dir.join("Gallery.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = ((i + 1) as f32 / total * 100.0) as i16;
update_progress(pool, event_id, "zip", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'zip'::export_type",
)
.bind(event_id)
.bind("exports/Gallery.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "zip", "progress_pct": 100 }).to_string(),
});
tracing::info!("ZIP export complete for event {event_id}");
Ok(())
}
// ── HTML viewer export ──────────────────────────────────────────────────────
async fn run_html_export(
event_id: Uuid,
run_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
// 1. Query data
let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
update_progress(pool, event_id, "html", 5).await;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// 2. Create temp directory for media processing (per-run, see run_id).
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}_{run_id}"));
let media_tmp = tmp_dir.join("media");
tokio::fs::create_dir_all(&media_tmp).await?;
// 3. Process media and build post data
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
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 {
let thumb = format!("{id_str}_thumb.jpg");
let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}");
// Video thumbnail via ffmpeg
let thumb_path = media_tmp.join(&thumb);
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
.args([
"-i",
src.to_str().unwrap_or_default(),
"-vframes",
"1",
"-ss",
"00:00:01",
"-vf",
"scale=400:-1",
"-y",
thumb_path.to_str().unwrap_or_default(),
])
.output()
.await;
match ffmpeg_result {
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
}
}
// Copy video as-is
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
(thumb, full)
} else {
let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path);
let full = format!("{id_str}_full.{ext}");
// Image thumbnail: resize to 400px wide
let src_clone = src.clone();
let thumb_path = media_tmp.join(&thumb);
let thumb_path_clone = thumb_path.clone();
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
.context("failed to save thumbnail")?;
Ok(())
})
.await?;
if let Err(e) = thumb_result {
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
}
// Full variant: compress if >5MB, otherwise copy original
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 src_clone = src.clone();
let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img =
image::open(&src_clone).context("failed to open image for compression")?;
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
resized
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
.context("failed to save compressed full image")?;
Ok(())
})
.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?;
}
} else {
tokio::fs::copy(&src, &full_path).await?;
}
(thumb, full)
};
// Build comments for this upload
let post_comments: Vec<ViewerComment> = comments
.iter()
.filter(|c| c.upload_id == row.id)
.map(|c| ViewerComment {
author: c.uploader_name.clone(),
text: c.body.clone(),
timestamp: c.created_at.to_rfc3339(),
})
.collect();
// Build tags for this upload
let tags: Vec<String> = hashtags_per_upload
.iter()
.filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone())
.collect();
viewer_posts.push(ViewerPost {
id: id_str,
uploader: row.uploader_name.clone(),
caption: row.caption.clone().unwrap_or_default(),
tags,
timestamp: row.created_at.to_rfc3339(),
likes: row.like_count,
comments: post_comments,
media: ViewerMedia {
media_type: if is_video {
"video".to_string()
} else {
"image".to_string()
},
thumb: format!("media/{thumb_name}"),
full: format!("media/{full_name}"),
},
});
let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
update_progress(pool, event_id, "html", pct.min(69)).await;
}
// 4. Build data.json
let viewer_data = ViewerData {
event: ViewerEvent {
name: event_name.to_string(),
exported_at: Utc::now().to_rfc3339(),
},
posts: viewer_posts,
};
let data_json =
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
update_progress(pool, event_id, "html", 72).await;
// 5. Create ZIP (per-run temp name; final served name stays fixed).
let tmp_path = exports_dir.join(format!("Memories.{run_id}.zip.tmp"));
let out_path = exports_dir.join("Memories.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
// Write embedded viewer assets (index.html, _app/*, etc.)
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
update_progress(pool, event_id, "html", 75).await;
// Write data.json
{
let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
// Write README.txt
{
let builder = ZipEntryBuilder::new("README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
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;
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;
}
}
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 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();
fcopy(&mut f, &mut zip_entry).await?;
zip_entry.close().await?;
files_written += 1;
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
update_progress(pool, event_id, "html", pct.min(98)).await;
}
zip.close().await?;
}
// 6. Finalise
tokio::fs::rename(&tmp_path, &out_path).await?;
// Clean up temp directory
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type",
)
.bind(event_id)
.bind("exports/Memories.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
});
tracing::info!("HTML viewer export complete for event {event_id}");
Ok(())
}
// ── DB helpers ───────────────────────────────────────────────────────────────
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
Ok(sqlx::query_as::<_, ExportUploadRow>(
"SELECT u.id, u.original_path, u.mime_type, u.caption,
usr.display_name AS uploader_name,
COUNT(DISTINCT l.user_id) AS like_count,
u.created_at
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
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_comments(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportCommentRow>> {
Ok(sqlx::query_as::<_, ExportCommentRow>(
"SELECT c.upload_id, usr.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" usr ON usr.id = c.user_id
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL AND u.deleted_at IS NULL
ORDER BY c.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, String)>> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT uh.upload_id, h.tag
FROM upload_hashtag uh
JOIN hashtag h ON h.id = uh.hashtag_id
JOIN upload u ON u.id = uh.upload_id
WHERE h.event_id = $1 AND u.deleted_at IS NULL",
)
.bind(event_id)
.fetch_all(pool)
.await?;
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",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'failed', error_message = $3
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(msg)
.execute(pool)
.await;
}
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
let _ = sqlx::query(
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.execute(pool)
.await;
}
async fn maybe_broadcast_complete(
pool: &PgPool,
event_id: Uuid,
sse_tx: &broadcast::Sender<SseEvent>,
) {
let row: Option<(bool, bool)> = sqlx::query_as(
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
)
.bind(event_id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if let Some((zip_ready, html_ready)) = row {
if zip_ready && html_ready {
let _ = sse_tx.send(SseEvent {
event_type: "export-available".to_string(),
data: serde_json::json!({ "types": ["zip", "html"] }).to_string(),
});
}
}
}
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
async fn write_dir_to_zip(
dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>,
) -> Result<()> {
for file in dir.files() {
let path = file.path().to_string_lossy().to_string();
let contents = file.contents();
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
for sub_dir in dir.dirs() {
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
}
Ok(())
}
fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin")
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect()
}
// ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "EventSnap Offline-Galerie\n\
\n\
So geht's:\n\
1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\
2. Öffne \"index.html\" im Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\
Du kannst zwischen Listen- und Rasteransicht wechseln,\n\
nach Hashtags filtern und nach Nutzern suchen.\n\
4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\
Viel Freude mit den Erinnerungen!\n";

View File

@@ -0,0 +1,73 @@
//! Shared shape for long-running background work.
//!
//! Today's [`compression`](crate::services::compression) and [`export`](crate::services::export)
//! pipelines each implement their own progress + SSE plumbing. They could converge on the
//! trait sketched here so future jobs (analytics, archival, ...) plug into one progress
//! pipeline.
//!
//! This module is intentionally a *sketch*: the existing services are not yet wired to
//! it. The aim is to (a) document the convention so new jobs follow it, (b) make the
//! refactor mechanical when someone is ready to do it. See `docs/IDEAS.md` —
//! "Maintainability principles" — for the rationale.
//!
//! Example of an eventual implementor:
//!
//! ```ignore
//! struct ZipExport { event_id: Uuid, /* … */ }
//!
//! impl BackgroundJob for ZipExport {
//! fn name(&self) -> &'static str { "zip-export" }
//! async fn run(self, ctx: JobContext) -> Result<()> {
//! for (i, item) in items.iter().enumerate() {
//! ctx.report(percent(i, items.len())).await?;
//! // … write to zip …
//! }
//! Ok(())
//! }
//! }
//! ```
use anyhow::Result;
/// Handle handed to a running job: reports progress and emits SSE events.
///
/// Wraps the existing SSE broadcaster and an optional `export_job` row. Implementors
/// don't need to know about `state.sse_tx` directly — they call [`JobContext::report`]
/// and get the same effect.
pub struct JobContext {
pub job_id: Option<uuid::Uuid>,
pub event_kind: &'static str,
pub sse_tx: tokio::sync::broadcast::Sender<crate::state::SseEvent>,
pub pool: sqlx::PgPool,
}
impl JobContext {
/// Update progress (0..=100) and broadcast an SSE tick. Cheap to call often —
/// rate-limit at the call site if a job emits at > 10 Hz.
pub async fn report(&self, percent: u8) -> Result<()> {
if let Some(job_id) = self.job_id {
sqlx::query("UPDATE export_job SET progress_pct = $1 WHERE id = $2")
.bind(percent as i16)
.bind(job_id)
.execute(&self.pool)
.await?;
}
let _ = self.sse_tx.send(crate::state::SseEvent::new(
self.event_kind,
serde_json::json!({ "progress_pct": percent }).to_string(),
));
Ok(())
}
}
/// One unit of work that publishes progress through a [`JobContext`].
///
/// `run` consumes `self`; spawn with `tokio::spawn` at the caller. Errors propagate;
/// the caller is responsible for mapping them to `export_job.error_message` or
/// equivalent. Implementors stay small — the trait deliberately has no `cancel`
/// or `pause`; we have not needed those yet.
#[allow(async_fn_in_trait)]
pub trait BackgroundJob: Send + 'static {
fn name(&self) -> &'static str;
async fn run(self, ctx: JobContext) -> Result<()>;
}

View File

@@ -0,0 +1,109 @@
//! Startup recovery + periodic background hygiene.
//!
//! Two responsibilities:
//!
//! 1. **Startup sweep** — when the server boots, fix rows left in an "in-progress"
//! state by the previous (possibly crashed) instance. Compression and export jobs
//! each leave a status row when they begin; if the process is killed mid-run, that
//! row stays `'processing'` / `'running'` forever, blocking re-tries and leaving
//! users staring at a spinner. Resetting them on startup recovers gracefully.
//!
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
//! request: expired sessions (otherwise the table grows unboundedly), and the
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
//! accumulate).
use std::path::PathBuf;
use std::time::Duration;
use sqlx::PgPool;
use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore;
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
/// before the HTTP server starts taking requests, so users never observe the
/// half-state.
pub async fn startup_recovery(pool: &PgPool) {
// Uploads whose preview generation was interrupted. Marking them 'failed' is
// safer than re-queueing — the original file is still on disk, the user can
// delete + re-upload if they care, and we avoid double-processing risk.
match sqlx::query(
"UPDATE upload SET compression_status = 'failed'
WHERE compression_status = 'processing'",
)
.execute(pool)
.await
{
Ok(r) if r.rows_affected() > 0 => {
tracing::warn!(
"startup recovery: reset {} stuck upload(s) from 'processing' to 'failed'",
r.rows_affected()
);
}
Ok(_) => {}
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.
match sqlx::query(
"UPDATE export_job
SET status = 'failed',
error_message = COALESCE(error_message, 'Server-Neustart während des Exports')
WHERE status = 'running'",
)
.execute(pool)
.await
{
Ok(r) if r.rows_affected() > 0 => {
tracing::warn!(
"startup recovery: reset {} stuck export job(s) from 'running' to 'failed'",
r.rows_affected()
);
}
Ok(_) => {}
Err(e) => tracing::error!("startup recovery: failed to sweep export_job: {e:#}"),
}
}
/// Spawns a background task that periodically:
/// - deletes session rows whose `expires_at` is more than a day in the past
/// - prunes the in-memory rate-limiter HashMap of empty windows
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
///
/// Cadence is 1h — fine for both jobs at our scale.
pub fn spawn_periodic_tasks(
pool: PgPool,
rate_limiter: RateLimiter,
sse_tickets: SseTicketStore,
media_path: PathBuf,
) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
// Fire the first tick immediately, then hourly.
tick.tick().await;
loop {
tick.tick().await;
cleanup_sessions(&pool).await;
rate_limiter.prune();
sse_tickets.prune();
// Reclaim disk for uploads soft-deleted more than the grace period
// ago, and hard-delete those rows (FKs cascade).
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
}
});
}
async fn cleanup_sessions(pool: &PgPool) {
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
.execute(pool)
.await
{
Ok(r) if r.rows_affected() > 0 => {
tracing::info!("cleaned up {} expired session(s)", r.rows_affected());
}
Ok(_) => {}
Err(e) => tracing::warn!("session cleanup failed: {e:#}"),
}
}

View File

@@ -0,0 +1,83 @@
//! Filesystem lifecycle for media artifacts.
//!
//! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on
//! a fixed-size disk until something removes them. This module is that
//! something: best-effort unlinking on delete, plus a periodic reaper that
//! sweeps files belonging to soft-deleted rows (catching anything an in-process
//! unlink missed — e.g. a crash between the DB commit and the unlink).
use std::path::Path;
use sqlx::PgPool;
use crate::models::upload::DeletedPaths;
/// Best-effort removal of an upload's three on-disk artifacts. A missing file is
/// not an error (it may already be gone, or never existed for videos without a
/// preview); anything else is logged but never propagated — losing the bytes
/// must not fail the user's delete.
pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) {
let candidates = [
Some(&paths.original),
paths.preview.as_ref(),
paths.thumbnail.as_ref(),
];
for rel in candidates.into_iter().flatten() {
let abs = media_path.join(rel);
match tokio::fs::remove_file(&abs).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"),
}
}
}
/// Reaper: remove on-disk files for uploads that were soft-deleted more than
/// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly
/// DB-row-driven — it never walks the filesystem, so it can never remove a file
/// belonging to a live upload.
pub async fn reap_deleted(pool: &PgPool, media_path: &Path) {
// Snapshot the exact rows (id + paths) we are about to unlink, and delete by
// those ids — NOT by re-evaluating the `deleted_at < now - 1 day` predicate.
// A row that crosses the 1-day line *during* the unlink loop would otherwise
// be hard-deleted by the second predicate without ever having its files
// unlinked → a permanent orphan.
let rows: Vec<(uuid::Uuid, String, Option<String>, Option<String>)> = match sqlx::query_as(
"SELECT id, original_path, preview_path, thumbnail_path
FROM upload
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "media reaper: query failed");
return;
}
};
if rows.is_empty() {
return;
}
let mut ids = Vec::with_capacity(rows.len());
for (id, original, preview, thumbnail) in &rows {
unlink_media(media_path, &DeletedPaths {
original: original.clone(),
preview: preview.clone(),
thumbnail: thumbnail.clone(),
})
.await;
ids.push(*id);
}
match sqlx::query("DELETE FROM upload WHERE id = ANY($1)")
.bind(&ids)
.execute(pool)
.await
{
Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()),
Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"),
}
}

View File

@@ -0,0 +1,122 @@
//! Stateless signed URLs for the authenticated media gateway.
//!
//! Media (`<img>`/`<video>` sources) cannot carry an `Authorization` header, so
//! access is granted by an HMAC-SHA256 signature embedded in the URL. The
//! feed/upload DTOs are serialized for an already-authenticated event member, so
//! that is where fresh signatures are minted; the gateway handler verifies them
//! without any DB/session state.
//!
//! HMAC is implemented over the in-tree `sha2` crate (no new dependency). The
//! key is the app's `jwt_secret`.
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Validity window of a signed URL.
const TTL_SECS: i64 = 24 * 3600;
/// Issue-time bucket. Expiry (and therefore the URL) is stable within this
/// window so the browser caches image bytes across feed polls instead of
/// re-downloading on every refresh.
const BUCKET_SECS: i64 = 3600;
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut key_block = [0u8; BLOCK];
if key.len() > BLOCK {
let digest = Sha256::digest(key);
key_block[..32].copy_from_slice(&digest);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= key_block[i];
opad[i] ^= key_block[i];
}
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(msg);
let inner_digest = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_digest);
outer.finalize().into()
}
fn sign(secret: &str, kind: &str, id: Uuid, exp: i64) -> String {
let msg = format!("{kind}:{id}:{exp}");
let mac = hmac_sha256(secret.as_bytes(), msg.as_bytes());
let mut hex = String::with_capacity(64);
for b in mac {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
/// Build a signed, time-boxed gateway URL for one of an upload's artifacts.
/// `kind` is `original` | `preview` | `thumbnail`.
pub fn signed_url(secret: &str, kind: &str, id: Uuid, now: i64) -> String {
let exp = ((now / BUCKET_SECS) * BUCKET_SECS) + TTL_SECS;
let sig = sign(secret, kind, id, exp);
format!("/media/{kind}/{id}?exp={exp}&sig={sig}")
}
/// Verify a signed URL: signature must match and the expiry must not have
/// passed. Signature comparison is constant-time.
pub fn verify(secret: &str, kind: &str, id: Uuid, exp: i64, sig: &str, now: i64) -> bool {
if exp < now {
return false;
}
let expected = sign(secret, kind, id, exp);
constant_time_eq(expected.as_bytes(), sig.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_verify_roundtrip() {
let secret = "test_secret_at_least_32_chars_long_xxxx";
let id = Uuid::new_v4();
let now = 1_700_000_000;
let url = signed_url(secret, "original", id, now);
// Extract exp + sig from the query string.
let (_, query) = url.split_once('?').unwrap();
let mut exp = 0i64;
let mut sig = String::new();
for pair in query.split('&') {
let (k, v) = pair.split_once('=').unwrap();
match k {
"exp" => exp = v.parse().unwrap(),
"sig" => sig = v.to_string(),
_ => {}
}
}
assert!(verify(secret, "original", id, exp, &sig, now));
// Tampered kind / id / sig must fail.
assert!(!verify(secret, "preview", id, exp, &sig, now));
assert!(!verify(secret, "original", Uuid::new_v4(), exp, &sig, now));
assert!(!verify(secret, "original", id, exp, "deadbeef", now));
// Wrong secret must fail.
assert!(!verify("other_secret_at_least_32_chars_long_yy", "original", id, exp, &sig, now));
// Expired must fail.
assert!(!verify(secret, "original", id, exp, &sig, exp + 1));
}
}

View File

@@ -1 +1,10 @@
pub mod compression;
pub mod config;
pub mod export;
pub mod jobs;
pub mod maintenance;
pub mod media_fs;
pub mod media_token;
pub mod password;
pub mod rate_limiter;
pub mod sse_tickets;

View File

@@ -0,0 +1,25 @@
//! bcrypt hashing/verification offloaded to the blocking pool.
//!
//! bcrypt cost-12 is ~250400ms of synchronous CPU. Called directly in an async
//! handler it pins a Tokio worker for that whole time, so a handful of
//! concurrent joins/recovers/admin-logins can starve every other request
//! (feed, SSE, upload). Routing the work through `spawn_blocking` keeps the
//! async reactor responsive.
use crate::error::AppError;
/// Hash a secret with the given bcrypt cost, off the async reactor.
pub async fn hash(plain: String, cost: u32) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || bcrypt::hash(&plain, cost))
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
/// Verify a secret against a bcrypt hash, off the async reactor. Returns `false`
/// on any error (mirrors the previous `unwrap_or(false)` fail-closed behavior).
pub async fn verify(plain: String, hash: String) -> bool {
tokio::task::spawn_blocking(move || bcrypt::verify(&plain, &hash).unwrap_or(false))
.await
.unwrap_or(false)
}

View File

@@ -0,0 +1,125 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Thread-safe sliding-window rate limiter backed by an in-memory HashMap.
/// Each key (e.g. `"join:{ip}"` or `"upload:{user_id}"`) tracks timestamps
/// of recent requests and rejects new ones once the window is full.
#[derive(Clone)]
pub struct RateLimiter {
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
}
impl RateLimiter {
pub fn new() -> Self {
Self {
windows: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Returns `true` if the request is allowed, `false` if rate-limited.
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
self.check_with_retry(key, max, window).is_ok()
}
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
/// `retry_after_secs` is how long until the oldest slot in the window expires.
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> {
let now = Instant::now();
let key = key.into();
let mut map = self.windows.lock().unwrap();
let timestamps = map.entry(key).or_default();
timestamps.retain(|&t| now.duration_since(t) < window);
if timestamps.len() < max {
timestamps.push(now);
Ok(())
} else {
// The oldest timestamp expires at oldest + window; compute remaining seconds
let oldest = timestamps[0];
let elapsed = now.duration_since(oldest);
let remaining = window.saturating_sub(elapsed);
Err(remaining.as_secs().max(1))
}
}
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
pub fn clear(&self) {
self.windows.lock().unwrap().clear();
}
/// Drop keys whose windows are empty after expiring old timestamps. Called from a
/// background task (see [`crate::services::maintenance`]) so that long-lived
/// processes don't accumulate one HashMap entry per IP that ever connected.
///
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
/// of which endpoint's window it was tracked under (the longest window today is
/// 24h for export downloads). If we ever add longer windows, raise this constant.
pub fn prune(&self) {
let now = Instant::now();
let ceiling = Duration::from_secs(24 * 60 * 60);
let mut map = self.windows.lock().unwrap();
let before = map.len();
map.retain(|_, ts| {
ts.retain(|&t| now.duration_since(t) < ceiling);
!ts.is_empty()
});
let dropped = before.saturating_sub(map.len());
if dropped > 0 {
tracing::debug!("rate limiter pruned {dropped} idle keys");
}
}
}
/// Extract the client IP for rate-limit keys.
///
/// Prefers `X-Real-IP`, which our reverse proxy (Caddy) overwrites with the real
/// TCP peer (`header_up X-Real-IP {remote_host}`) — a client cannot spoof it.
/// Falls back to the **rightmost** `X-Forwarded-For` token (the entry the proxy
/// appended), never the leftmost: the leftmost is fully client-controlled, and
/// trusting it let an attacker rotate the key to bypass the join cap, the
/// recover throttle, and the admin-login floor.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
return ip.to_owned();
}
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next_back())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_owned())
}
#[cfg(test)]
mod tests {
use super::client_ip;
use axum::http::HeaderMap;
#[test]
fn prefers_x_real_ip() {
let mut h = HeaderMap::new();
h.insert("x-real-ip", "9.9.9.9".parse().unwrap());
h.insert("x-forwarded-for", "1.1.1.1, 9.9.9.9".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "9.9.9.9");
}
#[test]
fn xff_uses_rightmost_not_spoofable_leftmost() {
// Attacker prepends a forged entry; the proxy-appended real peer is last.
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "1.2.3.4, 203.0.113.7".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "203.0.113.7");
}
#[test]
fn falls_back_when_no_headers() {
assert_eq!(client_ip(&HeaderMap::new(), "fb"), "fb");
}
}

View File

@@ -0,0 +1,74 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rand::Rng;
/// Short-lived single-use tickets that let `EventSource` clients open the SSE
/// stream without putting the JWT in the URL (where it would leak via access
/// logs / referer / browser history).
///
/// Flow: client `POST /api/v1/stream/ticket` with `Authorization: Bearer <jwt>`,
/// server returns an opaque ticket, client passes it as `?ticket=...` on the
/// stream open. Tickets are consumed on use and expire after `TTL`.
const TTL: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct SseTicketStore {
inner: Arc<Mutex<HashMap<String, Entry>>>,
}
#[derive(Clone)]
struct Entry {
token_hash: String,
issued_at: Instant,
}
impl SseTicketStore {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Mint a new ticket bound to the caller's session (identified by token hash).
pub fn issue(&self, token_hash: String) -> String {
let ticket = random_ticket();
let mut map = self.inner.lock().unwrap();
map.insert(
ticket.clone(),
Entry {
token_hash,
issued_at: Instant::now(),
},
);
ticket
}
/// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is
/// not expired. Single-use: the ticket is removed regardless of whether it
/// was still fresh, so a replay can't slip through after expiry.
pub fn consume(&self, ticket: &str) -> Option<String> {
let mut map = self.inner.lock().unwrap();
let entry = map.remove(ticket)?;
if entry.issued_at.elapsed() > TTL {
return None;
}
Some(entry.token_hash)
}
/// Drop expired entries — called from the background maintenance task so a
/// long-running process doesn't accumulate stale tickets.
pub fn prune(&self) {
let mut map = self.inner.lock().unwrap();
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
}
}
fn random_ticket() -> String {
// 192 bits of randomness, base32-ish hex. Plenty of entropy and URL-safe.
let mut rng = rand::rng();
let mut bytes = [0u8; 24];
rng.fill(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}

View File

@@ -1,8 +1,19 @@
use std::sync::Arc;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio::sync::{broadcast, Semaphore};
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore;
/// Max concurrent in-flight `/upload` requests. Each holds its whole file in
/// memory while reading the multipart body, so this bounds aggregate upload RAM
/// to ~N × the 550 MB body cap (≈2.2 GB here) — keeping headroom for Postgres +
/// the app on an 8 GB box. The per-user rate limiter is a *count* limiter
/// (10/hr), not a concurrency cap, so this is the actual OOM guard. Tunable.
pub const UPLOAD_MAX_CONCURRENCY: usize = 4;
#[derive(Clone, Debug)]
pub struct SseEvent {
@@ -10,24 +21,48 @@ pub struct SseEvent {
pub data: String,
}
impl SseEvent {
/// Standardised constructor. Prefer this over building the struct inline so the
/// event-type strings stay consistent across handlers.
pub fn new(event_type: impl Into<String>, data: impl Into<String>) -> Self {
Self {
event_type: event_type.into(),
data: data.into(),
}
}
}
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: AppConfig,
pub sse_tx: broadcast::Sender<SseEvent>,
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore,
/// Caps concurrent upload-body buffering (see UPLOAD_MAX_CONCURRENCY).
pub upload_limiter: Arc<Semaphore>,
}
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);
// Independent image (2) and video (2) permit pools — see CompressionWorker.
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),
2,
2,
sse_tx.clone(),
);
Self {
pool,
config,
sse_tx,
compression,
rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(),
upload_limiter: Arc::new(Semaphore::new(UPLOAD_MAX_CONCURRENCY)),
}
}
}

View File

@@ -0,0 +1 @@
export const env={}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};

View File

@@ -0,0 +1 @@
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};

View File

@@ -0,0 +1 @@
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};

View File

@@ -0,0 +1 @@
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/eAGLaJx1.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":"1778876725548"}

View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/_app/immutable/entry/start.YjNZv4co.js" rel="modulepreload">
<link href="/_app/immutable/chunks/eAGLaJx1.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CcONa1Mr.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BJ__EZ0W.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BTH3knpg.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bb9JxzU7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/RsTAN2PN.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bxn6SmGf.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BRDva_z9.js" rel="modulepreload">
</head>
<body>
<div style="display: contents">
<script>
{
__sveltekit_19z1hjw = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.YjNZv4co.js"),
import("/_app/immutable/entry/app.BTH3knpg.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -1,4 +1,8 @@
# Auto-merged by `docker compose up`. Exposes Postgres for LOCAL tooling only —
# bound to 127.0.0.1 so that even when this committed file lands on the prod host
# the DB is NOT reachable on the public IP (a `0.0.0.0` publish would also bypass
# ufw). Never widen this to 0.0.0.0.
services:
db:
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"

View File

@@ -21,6 +21,13 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
# Force production mode here (not via .env, which is easy to forget) so the
# backend's JWT-secret guard actually fires on this deployment.
environment:
APP_ENV: production
# Backstop the in-app upload RAM caps: if anything slips the per-request
# bounds, the container is OOM-killed instead of the 8 GB host.
mem_limit: 3g
depends_on:
db:
condition: service_healthy
@@ -28,6 +35,13 @@ services:
- media_data:/media
expose:
- "3000"
# /health is unauthenticated; start_period covers the boot-time migrations.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend:
build:
@@ -36,9 +50,16 @@ services:
restart: unless-stopped
env_file: .env
depends_on:
- app
app:
condition: service_healthy
expose:
- "3001"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3001/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
caddy:
image: caddy:2-alpine
@@ -49,9 +70,13 @@ services:
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
# Gate on readiness so Caddy doesn't proxy to a not-yet-listening upstream
# (which otherwise shows brief 502s on boot/restart).
depends_on:
- app
- frontend
app:
condition: service_healthy
frontend:
condition: service_healthy
volumes:
postgres_data:

196
docs/CONCEPT_DIASHOW.md Normal file
View File

@@ -0,0 +1,196 @@
# Live Diashow Concept
> **Status: SHIPPED.** Implementation lives at
> [frontend/src/lib/diashow/](../frontend/src/lib/diashow/) and
> [frontend/src/routes/diashow/+page.svelte](../frontend/src/routes/diashow/+page.svelte).
> Treat this doc as the design reference; code is the source of truth.
## Goal
A fullscreen, auto-advancing slideshow that any user can start from their device. Suitable
for a venue projector or TV running off a single phone/laptop. Two behaviours combine in one
view:
1. **Live drain** — when a new post is uploaded mid-event, it appears on the next slide
transition.
2. **Shuffle fallback** — between new uploads (and they will be rare in quiet stretches), the
diashow rotates through everything posted so far, in shuffled order.
The user does **not** need to be Host or Admin. Any guest can start the diashow on their own
device. There is no global "the room's diashow" — each device runs its own (though they will
look very similar if started at the same time).
---
## Behavioural model
Two FIFO queues live in the client:
| Queue | Source | Drain priority |
|----------------|-------------------------------------------|----------------|
| `liveQueue` | SSE events for new processed uploads | First |
| `shuffleQueue` | Snapshot of all known uploads, shuffled | When live empty |
### Slide-advance algorithm
```ts
function nextSlide(): Slide | null {
// 1. Drain live posts first (FIFO).
if (liveQueue.length) return liveQueue.shift()!;
// 2. Refill shuffle queue from `allKnown` if drained.
if (!shuffleQueue.length) {
shuffleQueue = shuffle(
[...allKnown.values()].filter(s => !recentlyShown.has(s.id))
);
}
return shuffleQueue.shift() ?? null;
}
```
A small ring buffer `recentlyShown` (last ~5 IDs) prevents the same picture coming back
within seconds when the shuffle queue is rebuilt.
### Live insertion
```ts
sseClient.on('upload-processed', (msg) => {
if (allKnown.has(msg.upload_id)) return;
const slide = await fetchUpload(msg.upload_id); // or use payload directly
allKnown.set(slide.id, slide);
liveQueue.push(slide);
});
```
Listen on **`upload-processed`**, not `new-upload` — the preview/thumbnail must exist before
we try to display the slide.
### Deletion / hiding
```ts
sseClient.on('upload-deleted', ({ upload_id }) => {
allKnown.delete(upload_id);
liveQueue = liveQueue.filter(s => s.id !== upload_id);
shuffleQueue = shuffleQueue.filter(s => s.id !== upload_id);
if (currentSlide?.id === upload_id) advanceImmediately();
});
```
Hidden uploads (banned user with `uploads_hidden=true`) need either a new SSE event or to
piggyback `upload-deleted` for diashow purposes. Simplest path: emit `upload-deleted` for
hidden posts to all subscribers (the live feed already filters them via `v_feed`, so this is
a backwards-compatible signal).
---
## Initial pool
On start:
1. Call `GET /api/v1/feed?limit=200` (or paginate-and-drain in the background while the
diashow runs).
2. Push every returned upload into `allKnown`.
3. Build the first `shuffleQueue` from it.
4. Open the SSE stream and route `upload-processed` / `upload-deleted` into the queues.
If the event is empty, show a friendly placeholder:
*"Noch keine Beiträge — neue erscheinen automatisch."*
---
## Frontend surface
### Entry point
A small **Diashow / "Präsentation starten"** action visible:
- In the feed header (icon next to the list/grid toggle) on tablet/desktop layouts.
- In the Account page on mobile (less prominent — diashow is primarily a venue-screen
feature).
Tapping it navigates to the `/diashow` route (full-screen, bottom nav hidden).
### Route: `/diashow`
- Fullscreen request via `element.requestFullscreen()` after first user gesture.
- **Screen Wake Lock**: `navigator.wakeLock.request('screen')` to keep the screen on during
long shows. Renew on `visibilitychange` if needed.
- Default dwell: **6 seconds** per slide. Configurable via overlay control: 3 / 6 / 10 s.
- Tap or `Escape` reveals an overlay with: pause/resume, dwell selector, **transition
picker**, exit.
- Transitions: crossfade (≈400 ms) by default; Ken Burns, zoom, slide, etc. available as
pluggable components — see "Pluggable transitions" below.
- Videos: autoplay muted, fit-to-screen, advance on `ended` or after `max(dwell, 12 s)`,
whichever first.
- Preload the next slide's media into a hidden `<img>`/`<video>` to avoid flashing.
- **Media source respects the user's data mode**
(see [FEATURES.md §2.5](FEATURES.md)). In Saver mode the diashow loads `preview_url`;
in Original mode it loads the original. The data-usage warning is shown once when the
mode is toggled in My Account — the diashow itself stays silent.
### Pluggable transitions
Each transition is a **drop-in Svelte component** under
`frontend/src/lib/diashow/transitions/` (path finalised at implementation time). The
interface is intentionally tiny:
```ts
// pseudocode — the real shape lands with the feature
export interface SlideTransition {
id: string; // 'crossfade', 'kenburns', ...
label: string; // shown in the dwell/transition picker
durationMs: number; // default; can be overridden per-event
// The actual motion is implemented by mounting the component with `from` / `to` slides.
}
```
A small registry maps `id → component`; the settings popover renders that registry as a
dropdown. **Adding a new animation is one new file plus one line in the registry — no
other changes required.** This is the maintainability target called out in
[FEATURES.md §2.9](FEATURES.md) and [IDEAS.md](IDEAS.md) ("Animation pack").
The same pattern is a candidate for whole-event "themes" later — a bundle of (transition
+ dwell + optional background-music defaults).
### Edge cases
| Case | Behaviour |
|--------------------------------------------|--------------------------------------------------------|
| Empty event | Placeholder card; live SSE will trigger the first show |
| All known uploads are still compressing | Same placeholder — wait for `upload-processed` |
| Network drop / SSE reconnect | EventSource auto-reconnects; queues survive |
| Current slide gets deleted | Advance immediately |
| Event is closed (no new uploads possible) | Diashow keeps running on shuffle queue indefinitely |
| Banned user's content (`uploads_hidden`) | Removed via `upload-deleted` signal (see Deletion) |
---
## Backend changes
**Essentially none.** The diashow reuses:
- `GET /api/v1/feed` (initial pool)
- `GET /api/v1/stream` SSE (`upload-processed`, `upload-deleted`)
Optional small additions:
1. Emit `upload-deleted` (or a new `upload-hidden`) when a host bans a user with
`hide_uploads=true`, so that diashow clients can scrub the relevant slides without
reloading.
2. Consider raising the cap on `GET /api/v1/feed?limit=` for diashow clients (or paginate
the initial pool in the background — preferred, no API change needed).
---
## Future extensions (not in scope for v1)
The big ones live in [IDEAS.md](IDEAS.md) under "Diashow extensions" — most notably the
**global / synchronised diashow** where multiple screens share one server-side cursor.
Short list of others kept here for context:
- **Curated highlights mode** — only show uploads tagged with a specific hashtag, or
Host-pinned "Story" uploads (depends on the story-highlights feature).
- **Audio bed** — host can pick a background track; mute videos so they don't fight the
music.
- **Slide caption / uploader chyron** — small lower-third with the uploader's name and
caption. Out by default to keep the visual clean.

288
docs/CONCEPT_HTML_VIEWER.md Normal file
View File

@@ -0,0 +1,288 @@
# HTML Viewer Export Concept
> **Status: IMPLEMENTED.** Viewer source: [frontend/export-viewer/](../frontend/export-viewer/).
> Pre-built output committed to [backend/static/export-viewer/](../backend/static/export-viewer/).
> Backend export pipeline: [backend/src/services/export.rs](../backend/src/services/export.rs).
>
> Outstanding follow-ups:
> - The export-viewer's `tailwind.config.js` does not yet extend the main app's config. Visual
> drift risk — see "Shared Tailwind Config" section below.
> - Service-worker (offline PWA caching) is still "Future" — fine for v1 since the ZIP is
> already fully offline by virtue of relative paths.
## Overview
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
of the live EventSnap feed. Opening `index.html` in any modern browser shows the full event
gallery — list view, grid view, search, filter, lightbox — with no internet connection or
server required.
It **replaces the current HTML export job type**. The old HTML export produced a raw
minijinja-rendered template; the new viewer supersedes it entirely. The existing `html`
job variant in the backend is repurposed to run this flow instead of being kept alongside
it.
It is distinct from the ZIP archive export (which is raw media files). The viewer is a
polished, navigable web app bundled with the event's content.
---
## User-Facing Behavior
- Download `event_name_viewer.zip`, unzip, open `index.html`
- Full list view (chronological, newest first) and grid view with search/filter
- Likes, comments, and reaction counts shown (static snapshot from export time)
- Read-only: no uploads, no auth, no dashboards
- Works offline, no CDN or external resources
---
## Architecture
### Separate Static SvelteKit App
A new mini SvelteKit project lives at `frontend/export-viewer/` within the same monorepo.
It uses `adapter-static` and is kept completely independent of the main app.
**Why separate rather than a shared route:**
- The viewer must be distributable as a standalone static bundle; the main app uses
`adapter-node` and cannot be mixed
- Keeping it separate avoids auth, store, and routing dependencies leaking in
- Simpler to reason about: the viewer has exactly two concerns (list view, grid view)
**Why same repo:**
- Shares Tailwind config and design tokens → visual parity with the main app
- Single `pnpm` workspace, no separate CI needed
- Backend can reference the pre-built output by relative path
### Pre-Built Output Committed to Repo
The viewer is built once and its output committed to `backend/static/export-viewer/`.
The backend export job **does not run a Node build** at runtime — it just copies the
pre-built assets and injects event data alongside them.
When the viewer source changes, a developer rebuilds it locally (`pnpm build` in
`frontend/export-viewer/`) and commits the updated `backend/static/export-viewer/` output.
---
## ZIP Structure
```
event_name_viewer.zip/
├── index.html ← entry point; open this in any browser
├── _app/
│ └── immutable/
│ ├── viewer.[hash].js ← all Svelte/app logic, single bundle
│ └── viewer.[hash].css ← all styles including Tailwind output
├── data.json ← injected by backend at export time
└── media/
├── abc123_thumb.jpg ← ~400 px wide, used in grid cells
├── abc123_full.jpg ← original or capped (see Media Strategy)
├── def456_thumb.jpg
└── def456.mp4 ← videos included as-is
```
No external font CDN, no Google Fonts, no remote scripts. All assets are local.
---
## data.json Schema
Generated by the backend export job. The viewer fetches this file on startup via
`fetch('./data.json')` (relative path, works from filesystem).
```json
{
"event": {
"name": "Sommerfest 2025",
"exported_at": "2025-07-15T20:00:00Z"
},
"posts": [
{
"id": "abc123",
"uploader": "MaxMustermann",
"caption": "Tolle Stimmung! #party #spaß",
"tags": ["party", "spaß"],
"timestamp": "2025-07-15T18:30:00Z",
"likes": 12,
"comments": [
{
"author": "AnnaSchulz",
"text": "So schön!",
"timestamp": "2025-07-15T18:35:00Z"
}
],
"media": [
{
"type": "image",
"thumb": "media/abc123_thumb.jpg",
"full": "media/abc123_full.jpg",
"width": 1920,
"height": 1080
}
]
}
]
}
```
All post data (likes, comments, tags) reflects the state at export time. No live updates.
---
## Media Strategy
### Images
| Variant | Purpose | Max dimension | Format |
|---------|---------|---------------|--------|
| `_thumb` | Grid cells, list post thumbnail | 400 px wide | JPEG q75 |
| `_full` | Lightbox / full-screen view | Original, or 2000 px cap if >5 MB | JPEG q85 |
The backend applies compression only when the original exceeds a threshold (e.g. >5 MB for
images). Below that threshold the original is used as `_full` unchanged.
The full-resolution original is always available via the separate ZIP archive export.
### Videos
Included as-is (no server-side transcoding). The viewer uses a standard `<video>` element.
The `_thumb` variant for videos is a JPEG frame extracted at the 1-second mark.
---
## SvelteKit SSG Configuration
```
// frontend/export-viewer/src/routes/+layout.ts
export const prerender = true;
export const ssr = false;
```
`ssr = false` produces a pure client-side SPA: SvelteKit emits a minimal `index.html` shell
and the JavaScript bundle hydrates it entirely in the browser. This is correct for a ZIP
distribution where no server exists to handle SSR.
```
// frontend/export-viewer/svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
fallback: 'index.html',
pages: '../../backend/static/export-viewer',
assets: '../../backend/static/export-viewer',
})
}
};
```
The build output is written directly into `backend/static/export-viewer/` so the backend
can reference it without a copy step.
### Shared Tailwind Config
```
// frontend/export-viewer/tailwind.config.js
import baseConfig from '../tailwind.config.js';
export default { ...baseConfig, content: ['./src/**/*.{svelte,ts}'] };
```
Imports the main app's Tailwind config to guarantee visual parity. Only the `content` glob
is overridden.
---
## Viewer Feature Set
| Feature | Included | Notes |
|---------|----------|-------|
| List view (chronological, newest first) | ✓ | Full-width cards, same layout as live app |
| Grid view (3-column) | ✓ | Square cells, video duration badge |
| List/grid toggle | ✓ | Same toggle icons as live app |
| Search bar (grid view only) | ✓ | Appears only in grid view |
| Tag filter chips | ✓ | Built from tags in data.json |
| Uploader filter | ✓ | Dropdown from uploaders in data.json |
| Autocomplete suggestions | ✓ | From data.json — no network requests |
| Lightbox (tap to expand) | ✓ | Swipe left/right navigates filtered set |
| Like counts (static) | ✓ | Snapshot from export time |
| Comment list (static) | ✓ | Expandable under each post |
| Like/comment actions | ✗ | Read-only export |
| Upload button / FAB | ✗ | |
| Account / Host / Admin | ✗ | |
| Authentication | ✗ | No JWT, no PIN |
| Service Worker (offline cache) | Future | Could be added later for PWA behavior |
---
## Backend Export Job Flow
The `html` job variant is repurposed. The old minijinja template rendering path is removed
and replaced entirely by the steps below.
```
1. Query all posts, media, reactions, and comments for the event from the DB
2. Copy pre-built viewer assets:
backend/static/export-viewer/ → tmp/{job_id}/
3. Generate data.json:
- Build the JSON structure from queried data
- Write to tmp/{job_id}/data.json
4. Process and copy media:
For each media file:
a. Copy original; if image >5 MB, also produce compressed _full variant
b. Generate _thumb (resize to 400 px wide via image library)
c. For video, extract JPEG frame for _thumb
d. Write to tmp/{job_id}/media/
5. Create ZIP:
zip -r event_name_viewer.zip tmp/{job_id}/
6. Store ZIP path, mark job as complete
7. Clean up tmp/{job_id}/
```
The backend needs an image processing dependency (e.g. `image` crate in Rust) for thumbnail
generation and compression. Video frame extraction requires `ffmpeg` available in the
deployment environment (already used for video handling if applicable, otherwise add to
docker-compose).
---
## Monorepo Structure After Implementation
```
EventSnap/
├── backend/
│ ├── static/
│ │ └── export-viewer/ ← pre-built viewer output (committed)
│ │ ├── index.html
│ │ └── _app/...
│ └── src/
│ └── handlers/
│ └── export.rs ← export job assembles ZIP
├── frontend/
│ ├── export-viewer/ ← new mini SvelteKit project
│ │ ├── package.json
│ │ ├── svelte.config.js ← adapter-static, output → backend/static/export-viewer
│ │ ├── tailwind.config.js ← extends ../tailwind.config.js
│ │ └── src/
│ │ └── routes/
│ │ ├── +layout.ts ← prerender=true, ssr=false
│ │ └── +page.svelte ← list/grid feed, lightbox, search
│ └── src/ ← existing main app (unchanged)
└── docs/
├── CONCEPT_MOBILE_UI.md
└── CONCEPT_HTML_VIEWER.md
```
---
## Open Questions for Implementation
1. **Image processing library**: The `image` crate handles JPEG resize/compress; is it
already a backend dependency, or does it need to be added?
2. **Video thumbnail extraction**: Is `ffmpeg` available in the Docker environment?
If not, a fallback (no video thumb, use a placeholder) is needed.
3. **Viewer rebuild workflow**: Add a `make build-viewer` or `pnpm --filter export-viewer build`
step to the developer workflow docs and CI so the committed output stays in sync.
4. **ZIP file naming**: `{event_slug}_viewer_{date}.zip` or a fixed name?

471
docs/CONCEPT_MOBILE_UI.md Normal file
View File

@@ -0,0 +1,471 @@
# Mobile-First UI/UX Redesign Concept
> **Status: IMPLEMENTED (v0.15).** This document captures the design intent. The redesign
> has shipped — see [BottomNav.svelte](../frontend/src/lib/components/BottomNav.svelte),
> [UploadSheet.svelte](../frontend/src/lib/components/UploadSheet.svelte),
> [CameraCapture.svelte](../frontend/src/lib/components/CameraCapture.svelte),
> [feed/+page.svelte](../frontend/src/routes/feed/+page.svelte),
> [account/+page.svelte](../frontend/src/routes/account/+page.svelte),
> [host/+page.svelte](../frontend/src/routes/host/+page.svelte),
> [admin/+page.svelte](../frontend/src/routes/admin/+page.svelte). Use this doc as the design
> reference; treat code as the source of truth for current behaviour.
## Overview
EventSnap is intended for mobile use at live events. This document describes the full
mobile-first design covering navigation, the feed/gallery, account page, host dashboard,
and admin dashboard.
---
## 1. Navigation: Bottom Tab Bar
Replace all per-page top-right icon links with a single **persistent bottom tab bar** present
on every page. The bar sits at the very bottom with proper `padding-bottom` for iPhone home
indicator (safe-area-inset-bottom).
### Tab Composition by Role
| Role | Tabs |
|-------|------|
| Guest | 🏠 Feed · [📷+] · 👤 Account |
| Host | 🏠 Feed · [📷+] · 👤 Account |
| Admin | 🏠 Feed · [📷+] · 👤 Account |
All roles see the same three tabs. Role-specific dashboard links (Host, Admin) live inside
the Account page — not as separate tabs. This keeps the bar simple and avoids conditional
tab rendering.
### Visual Style
- Frosted glass background: `bg-white/85 backdrop-blur-md`
- Thin top border: `border-t border-gray-200`
- Subtle shadow upward
- Active tab: colored icon + small label below
- Inactive tab: gray icon, small gray label
### Upload FAB (Floating Action Button)
The center tab is an elevated circular button, not a flat tab icon:
- Circle ~56 px diameter, `bg-blue-600`
- Icon: camera outline with a small `+` badge overlaid at bottom-right
- Raised above the bar with a drop shadow
- Press: slight scale-down (`scale-95`) + haptic feedback where available
- Communicates "capture new or upload existing"
---
## 2. Feed / Gallery Page
### Header
```
┌─────────────────────────────────────────┐
│ Sommerfest 2025 ≡ ⊞ │
└─────────────────────────────────────────┘
```
- Event name left-aligned
- List/grid view toggle icons right-aligned (≡ list, ⊞ grid)
- Header collapses on downward scroll (only toggle remains visible), expands on upward scroll
---
### View A — Chronological List (default)
Full-width post cards, newest at top, infinite scroll.
```
┌─────────────────────────────────────────┐
│ 👤 MaxMustermann · vor 2 Min │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ │ [photo / video] │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ Tolle Stimmung! #party #spaß │
│ ❤️ 12 💬 3 │
└─────────────────────────────────────────┘
```
- Media: full-width, native aspect ratio, capped at 80 vh
- Avatar: colored initial circle, no photo
- Timestamp: relative ("vor 2 Min", "vor 1 Std")
- Tap media → fullscreen lightbox, swipe left/right navigates feed
- No search bar in list view
---
### View B — Grid View
Transition animation when toggling: list collapses, grid fades/scales in (~200 ms).
#### Search Bar (grid view only)
```
┌─────────────────────────────────────────┐
│ 🔍 Nutzer oder #Tag suchen… ×
└─────────────────────────────────────────┘
```
- Appears below the header only in grid view
- Slides in as part of the view transition
- `×` clears current input
- Auto-focuses when grid view is activated
#### Autocomplete Dropdown
Appears immediately on focus and updates on every keystroke. Data source: the already-loaded
posts in memory — **no extra API calls**.
Two suggestion lists are derived at load time:
- `allTags`: unique hashtags from all post captions, sorted by frequency descending
- `allUploaders`: unique display names, sorted alphabetically
| User input | Suggestions shown |
|------------|-------------------|
| (focus, empty) | Top 3 tags by frequency + top 3 uploaders |
| `#` | All tags, frequency-sorted |
| `#par` | Tags with prefix "par": `#party`, `#parade` |
| `Max` | Uploaders matching "max" (case-insensitive) |
| `a` | Uploaders containing "a" + tags containing "a" |
Dropdown layout:
```
┌─────────────────────────────────────────┐
│ 👤 Nutzer │
│ MaxMustermann │
│ AnnaSchulz │
│ # Tags │
│ #party #tanz #spaß │
└─────────────────────────────────────────┘
```
Max ~5 total suggestions. Tapping a suggestion adds it as an active filter chip and clears
the search bar for another entry.
#### Active Filter Chips
```
┌─────────────────────────────────────────┐
│ 👤 MaxMustermann × # party ×
│ Alle Filter löschen │ ← shown when 2+ chips active
└─────────────────────────────────────────┘
```
Filter combination logic:
| Combination | Logic |
|-------------|-------|
| Two tags: `#party` + `#tanz` | OR — posts with either tag |
| Two uploaders: Max + Anna | OR — posts from either |
| Uploader + tag: Max + `#party` | AND — posts by Max that also have `#party` |
#### Grid Layout
```
┌───────┬───────┬───────┐
│ │ │ │
│ │ │ │ 3-column, equal square cells
├───────┼───────┼───────┤ small gap (2 px)
│ │ ▶ │ │ ← video: small ▶ badge + duration
│ │ 0:42 │ │
└───────┴───────┴───────┘
```
- Tap cell → fullscreen lightbox, swipe navigates filtered set only
- Virtualized grid for performance on large events
---
## 3. Upload Flow
### Step 1 — Source Selection (Bottom Sheet)
Tapping the FAB slides up a bottom sheet (~300 ms spring animation).
Frosted glass, rounded top corners, drag handle at top. Tap outside or swipe down to dismiss.
```
┌──────────────────────────────────┐
│ ▬ (drag handle) │
│ │
│ 📸 Kamera │
│ Jetzt aufnehmen │
│ │
│ 🖼 Galerie │
│ Foto oder Video wählen │
│ │
│ [ Abbrechen ] │
└──────────────────────────────────┘
```
### Step 2a — Camera
Triggers `<input type="file" accept="image/*,video/*" capture="environment">`.
Native camera opens. After capture → Step 3.
### Step 2b — Gallery
Triggers `<input type="file" accept="image/*,video/*" multiple>`.
Native gallery picker with multi-select (up to ~10 items). After selection → Step 3.
### Step 3 — Preview & Metadata Screen
Full-screen, pushes in from right. Bottom nav hidden (immersive).
```
┌──────────────────────────────────┐
× Abbrechen Hochladen → │
├──────────────────────────────────┤
│ │
│ ┌────┐ ┌────┐ ┌────┐ → │ ← horizontal scroll, tap to preview
│ │img │ │img │ │ × │ │ × on each thumbnail to remove
│ └────┘ └────┘ └────┘ │
│ │
│ Beschreibung (optional) │
│ ┌────────────────────────────┐ │
│ │ │ │ ← auto-focused
│ └────────────────────────────┘ │
│ │
│ # Schnell-Tags │
│ [#Feier] [#Spaß] [#Party] … │ ← tap to append to caption
│ │
├──────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ 📤 Hochladen │ │ ← sticky, disabled until ≥1 file
│ └────────────────────────────┘ │
└──────────────────────────────────┘
```
### Step 4 — Background Upload + Feedback
- Tapping "Hochladen" immediately returns to the feed (optimistic UX)
- Slim progress bar above the bottom tab bar while queue is active
- FAB gets a small spinning ring badge while uploads are in progress
- On completion: brief toast near the bottom ("✓ Hochgeladen")
- Rate-limit countdown banner anchored above the bottom bar (existing behavior)
---
## 4. Account Page
Single entry point for profile info and role-based dashboard navigation.
```
┌─────────────────────────────────────────┐
│ Mein Account │
├─────────────────────────────────────────┤
│ │
│ ┌───────┐ │
│ │ M │ MaxMustermann │
│ └───────┘ 🏷 Gast │
│ Sommerfest 2025 │
│ 7 Uploads │
│ │
├── Dashboards ───────────────────────────┤ (entire section absent for guests)
│ │
│ ⭐ Host-Dashboard → │ (host + admin only)
│ 🛡 Admin-Dashboard → │ (admin only)
│ │
├── Konto ────────────────────────────────┤
│ │
│ ✏️ Anzeigename ändern → │
│ 🔑 PIN ändern → │
│ 🚪 Event verlassen → │ (red text, confirm sheet)
│ │
└─────────────────────────────────────────┘
│ 🏠 Feed · [📷+] · 👤 Account │
└─────────────────────────────────────────┘
```
- "Dashboards" section is entirely absent in the DOM for plain guests — not just hidden
- "Event verlassen" triggers a bottom-sheet confirmation before action
- Avatar: colored circle with initial letter
---
## 5. Host Dashboard
Accessed via Account → ⭐ Host-Dashboard. Full-screen page, bottom nav visible.
```
┌─────────────────────────────────────────┐
│ ← 🎉 Host-Dashboard │
├─────────────────────────────────────────┤
│ │
│ ── Statistiken ────────────────────── │
│ ┌──────────┐ ┌──────────┐ │
│ │ 24 │ │ 156 │ │
│ │ Nutzer │ │ Uploads │ │
│ └──────────┘ └──────────┘ │
│ │
│ ── Event-Einstellungen ────────────── │ ← collapsible section
│ │
│ Neue Uploads sperren │
│ ○────────────● Gesperrt │ ← toggle
│ Keine neuen Uploads möglich │
│ │
│ ── Nutzerverwaltung ───────────────── │ ← collapsible section
│ │
│ 🔍 Nutzer suchen… │
│ ┌───────────────────────────────────┐ │
│ │ 👤 MaxMustermann Gast [🚫] │ │
│ │ 👤 AnnaSchulz Gast [🚫] │ │
│ │ 👤 GesperrterNutzer [↩] │ │ ← banned: undo icon
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
│ 🏠 Feed · [📷+] · 👤 Account │
└─────────────────────────────────────────┘
```
- Sections have a chevron toggle to collapse/expand (helps on small phones)
- Ban/unban: icon tap + bottom sheet confirmation ("Nutzer wirklich sperren?")
- User list virtualized for large events
---
## 6. Admin Dashboard
Most complex page. Uses an **inner tab bar** directly below the header to divide the four
functional areas. The inner tabs are independent of the bottom nav.
```
┌─────────────────────────────────────────┐
│ ← 🛡 Admin-Dashboard │
├─────────────────────────────────────────┤
│ [Stats] [Config] [Export] [Nutzer] │ ← inner tab bar (scrollable if needed)
├─────────────────────────────────────────┤
│ │
│ [Tab content] │
│ │
└─────────────────────────────────────────┘
│ 🏠 Feed · [📷+] · 👤 Account │
└─────────────────────────────────────────┘
```
### Stats Tab
```
┌──────────┐ ┌──────────┐
│ 156 │ │ 24 │
│ Uploads │ │ Nutzer │
└──────────┘ └──────────┘
┌──────────┐ ┌──────────┐
│ 2.1 GB │ │ 3 │
│ Speicher │ │ Gesperrt │
└──────────┘ └──────────┘
```
2×2 metric card grid. Values large and prominent. Optionally expandable to show time-series
charts on tap.
### Config Tab
```
Upload-Limit / Nutzer
┌────────────────────────────────┐
│ 10 │
└────────────────────────────────┘
Zeitfenster (Sek.)
┌────────────────────────────────┐
│ 60 │
└────────────────────────────────┘
Max. Dateigröße (MB)
┌────────────────────────────────┐
│ 50 │
└────────────────────────────────┘
┌────────────────────────────────┐
│ 💾 Speichern │ ← sticky at bottom of tab scroll area
└────────────────────────────────┘
```
Each setting: full-width label + input. Save button always reachable without scrolling.
### Export Tab
```
── Galerie ──────────────────────────
[ 🔓 Galerie freigeben ]
── Export-Jobs ──────────────────────
[ 🔄 Aktualisieren ]
┌───────────────────────────────────┐
│ HTML-Viewer ● Fertig [↓ ZIP] │
│ JSON-Export ⏳ Läuft… │
│ ZIP-Archiv ✗ Fehler [↺] │
└───────────────────────────────────┘
[ + Neuer Export-Job ]
```
- Status chips: green (Fertig), amber (Läuft), red (Fehler)
- Download button inline per completed job
- Only the jobs list refreshes on "Aktualisieren" — no full page re-render
### Nutzer Tab
Same structure as Host Nutzerverwaltung, with any additional admin-only actions
(e.g. role assignment) added as extra controls per row.
---
## Touch gestures vs. desktop buttons (planned extension)
Where a gesture is more ergonomic on mobile than a button, EventSnap prefers the gesture
on touch and mirrors it as an explicit button on desktop. Inspired by Instagram, WhatsApp
and Telegram — long-press for context, swipe to dismiss, double-tap to react.
| Surface | Touch gesture | Desktop equivalent |
|-----------------------------------------|-------------------------------------|------------------------------------------|
| Post card | Long-press → context bottom sheet | ⋯ kebab in the card corner |
| Comment row | Long-press → bottom sheet | ⋯ next to the comment timestamp |
| User row (Host / Admin dashboards) | Long-press → bottom sheet | Inline buttons (ban, promote, reset PIN) |
| Lightbox | Swipe left / right | ←/→ arrow keys + on-screen chevrons |
| Lightbox | Swipe down to close | Esc + ✕ button |
| Bottom sheet | Swipe down to dismiss | Click backdrop or × in the sheet header |
| Feed | Pull to refresh | Refresh icon next to the view toggle |
| Post (any) | Double-tap → like | Click the heart icon |
**Discoverability rule:** every gesture must have a visible button equivalent on the same
page. Gestures are never the *only* path to an action. Helps with stylus users,
accessibility, and people who don't know the gesture vocabulary.
**Context bottom-sheet pattern** (used by every long-press above):
```
┌──────────────────────────────────┐
│ ▬ (drag handle) │
│ │
│ 🗑 Löschen │ ← destructive action red
│ 📥 Original anzeigen │
│ 🔗 Teilen │
│ 🚩 Melden │ (only on others' content)
│ │
│ [ Abbrechen ] │
└──────────────────────────────────┘
```
Each sheet is composed from a shared `<ContextSheet>` component (planned) with a single
`actions: ContextAction[]` prop. Adding a new gesture context = define the actions array
where needed. Drop-in, one file.
## Design Principles Summary
| Principle | Application |
|-----------|-------------|
| Thumb zone | All primary actions in bottom ~20% of screen |
| One-hand operation | FAB centered, bottom sheets dismissable with swipe |
| Minimal taps to upload | Source → picker → preview → upload: 4 taps |
| Immediate feedback | Optimistic return to feed, background upload |
| Progressive disclosure | Caption/tags optional; CTA always reachable |
| No role clutter in nav | Role links only in Account, bar stays clean |
| Collapsible sections | Long management pages stay usable on small phones |
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
| Gestures over chrome | Long-press for context menus, swipe to dismiss, double-tap to react — always with a button fallback for desktop and accessibility |

313
docs/FEATURES.md Normal file
View File

@@ -0,0 +1,313 @@
# EventSnap — Feature Set & Capability Matrix
This document is the authoritative, code-cross-checked summary of what EventSnap can do today
and what is planned. For the design rationale of each area see [PROJECT.md](../PROJECT.md);
for journeys / step-by-step flows see [USER_JOURNEYS.md](USER_JOURNEYS.md).
Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ out of scope**
---
## 1. Capability matrix by role
| Capability | Guest | Host | Admin | Notes |
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
| **Onboarding & sessions** | | | | |
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
| First-visit guided tour (4 steps) | ✓ | ✓ | ✓ | Dismissed once, flag in `localStorage` |
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
| Log out / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
| Admin password login (separate route) | | | ✓ | 1-day token; lives in `sessionStorage` |
| Reset another user's PIN (one-time display modal) | | ✓* | ✓ | Host: guests only. Admin: hosts + guests. New PIN shown once to the requester; user signs in with it; PIN is stored on their device on next login. \* Host cannot reset another Host's PIN |
| | | | | |
| **Posting** | | | | |
| Pick photos/videos from device library (multi-select) | ✓ | ✓ | ✓ | Bottom-sheet source picker |
| In-app camera capture (`getUserMedia`) | ✓ | ✓ | ✓ | Front/back toggle, photo, `MediaRecorder` video |
| Caption + `#hashtag` extraction | ✓ | ✓ | ✓ | Optional; hashtags parsed server-side |
| Edit own caption / hashtags after upload | ✓ | ✓ | ✓ | `PATCH /api/v1/upload/{id}` |
| Delete own upload | ✓ | ✓ | ✓ | Long-press on the card (or the kebab menu on desktop) → **Löschen** in the context sheet. Comment-style trash icon also available on each post elsewhere as it's added. |
| Delete own comment | ✓ | ✓ | ✓ | Trash icon in lightbox |
| Background upload queue (survives reload) | ✓ | ✓ | ✓ | IndexedDB-persisted, sequential, retry |
| Rate-limit auto-resume banner | ✓ | ✓ | ✓ | Countdown above bottom nav; resumes when window opens |
| Chunked / resumable upload for > 100 MB | ◯ | ◯ | ◯ | Planned (v1.x) |
| | | | | |
| **Feed & social** | | | | |
| Chronological list feed (full-width cards) | ✓ | ✓ | ✓ | Default view, infinite scroll |
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
| Fullscreen lightbox with swipe | ✓ | ✓ | ✓ | Swipe navigates the filtered set |
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
| Read comments on any post | ✓ | ✓ | ✓ | |
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
| Real-time feed via SSE | ✓ | ✓ | ✓ | `new-upload`, `new-comment`, `like-update`, `upload-processed`, `pin-reset`, `event-updated`, etc. |
| Pause SSE when app is backgrounded | ✓ | ✓ | ✓ | Page Visibility API; reconnect on foreground |
| Delta-fetch (`/feed/delta?since=`) on reconnect | ✓ | ✓ | ✓ | Runs on every visibility-restore; merges new + deleted uploads |
| Individual file download button per post | ✓ | ✓ | ✓ | "Original anzeigen" in the post context sheet — streams via `/api/v1/upload/{id}/original` |
| | | | | |
| **Live diashow** (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) | | | | |
| Start fullscreen auto-advancing slideshow | ✓ | ✓ | ✓ | Two queues: live (SSE) drains first, shuffle as fallback. Crossfade + Ken Burns transitions; pluggable. Respects data mode. |
| | | | | |
| **Moderation (Host)** | | | | |
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
| Delete any upload | | ✓ | ✓ | |
| Delete any comment | | ✓ | ✓ | |
| Promote guest to Host | | ✓ | ✓ | |
| Demote Host to guest | | ✓ | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
| Unlock new uploads | | ✓ | ✓ | |
| Release gallery → trigger export generation | | ✓ | ✓ | Enqueues both ZIP and HTML-viewer jobs |
| | | | | |
| **Instance configuration (Admin)** | | | | |
| Live disk-usage / user / upload / banned stats | | | ✓ | Stats tab; queries `sysinfo` |
| Edit per-file limits (image MB / video MB) | | | ✓ | Config tab; hot-reloadable from DB |
| Edit per-endpoint rate limits | | | ✓ | Upload/hour, feed/min, export/day |
| Toggle **all** rate limits on/off | | | ✓ | Master switch — when off, every limiter passes through |
| Toggle **individual** rate limits on/off | | | ✓ | Per-endpoint switch (upload / feed / export / join) |
| Toggle quota enforcement on/off (master + per-area) | | | ✓ | Master switch + per-area (storage / upload count). When off, nothing is enforced |
| Edit quota tolerance | | | ✓ | Live `(free_disk × tolerance) / active_uploaders` formula enforced on upload |
| Edit estimated guest count | | | ✓ | |
| Edit compression-worker concurrency | | | ✓ | |
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
| Inspect export job list & progress | | | ✓ | |
| Low-disk alert (< 10 GB free) | | | ◯ | Planned |
| Event banner / cover image | | | ◯ | DB column exists, no UI |
| | | | | |
| **Quota visibility (Guest-facing)** | | | | |
| Show current per-user quota estimate | ✓ | ✓ | ✓ | "Du hast X MB von Y MB genutzt." in My Account and on the upload screen. Computed from the live formula. Hidden when quota enforcement is toggled off |
| | | | | |
| **Export** | | | | |
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
| Per-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
| | | | | |
| **Banned guest** (subset) | | | | |
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
| Can browse the feed | ✓ | | | |
| Can still download the export once released | ✓ | | | Spec design choice |
---
## 2. Feature areas in detail
### 2.0 Touch-first interactions (mobile) vs. buttons (desktop)
EventSnap is mobile-first. Where it makes the UI cleaner, primary actions are reached via
**gestures** on touch devices, with conventional **buttons** mirrored on tablet/desktop:
- **Long-press on a post** → context bottom sheet ("Löschen", "Original anzeigen", report,
share). On desktop the same actions are a kebab/⋯ menu in the card's corner.
- **Long-press on a comment** → context sheet with "Löschen" (own comments only) and
"Kopieren".
- **Swipe left/right in the lightbox** → navigate the filtered set.
- **Swipe down on a bottom sheet** → dismiss.
- **Pull-to-refresh on the feed** → force a delta-fetch even when SSE is up.
- **Double-tap on a post** → like (Instagram-style), with a heart-burst animation. Tap the
heart icon as the explicit alternative.
Design rule: **gestures should always have a discoverable button equivalent** somewhere on
the page, so the app stays usable on a stylus, mouse, or for users who don't know the
gesture vocabulary. Take inspiration from Instagram, WhatsApp, and Telegram for the
"feels right" baseline — long-press for context, swipe to dismiss, double-tap to react.
### 2.1 Authentication and identity
EventSnap's identity model is "**a name + a 4-digit PIN, scoped to one event**". There is no
email, no password, no account portal.
- **Joining.** On the join page the user types a display name. The server creates a `user`
row, generates a 4-digit PIN, stores `bcrypt(pin)`, signs a 30-day JWT, and returns the
PIN in clear text **once** in the response. The client persists the JWT and the PIN to
`localStorage`.
- **PIN visibility.** The PIN is shown to the user *prominently* once at registration, and
remains visible in the My-Account page (read directly from `localStorage` — never sent
back from the server).
- **Returning on the same device.** A valid JWT in `localStorage` → straight to the feed.
- **Returning on a new device.** Type the name on the join page → server detects the
existing user → user is prompted for their PIN. `bcrypt.verify` → new JWT, fresh device
is now bound to the same account.
- **Lockout.** 3 wrong PIN attempts → 15-minute lockout per user (`pin_locked_until` column,
migration 006).
- **Name collisions.** Names are unique per event (case-insensitive, migration 007). If
someone tries to join with a name already taken, the join page automatically presents the
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
link routing to `/recover` for users who already know they want to sign in.
- **PIN reset by Host / Admin.** Planned. If a guest loses their PIN and `localStorage` is
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
operator. The operator shows / sends the new PIN to the user, who then signs in via
`/recover` — the PIN is persisted to `localStorage` on that device on a successful
recovery, exactly like a brand-new join. Host cannot reset another Host's PIN; only
Admins can.
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by an admin. **Hosts
may also demote other Hosts to guests** (planned) — but never themselves, to avoid
locking the event out of moderation. Admins can demote anyone except admins.
### 2.2 Posting pipeline
The upload pipeline is built for flaky mobile networks:
1. **Source picker** (bottom sheet from the FAB): camera or gallery.
2. **Preview screen** — staged files appear as thumbnails; user can remove individuals, add
a caption (with `#hashtags`), and tap quick-tag chips derived from the caption.
3. **Submit** — the client immediately returns to the feed (optimistic UX). Files enter an
IndexedDB-persisted queue.
4. **Queue worker** — runs sequentially (one upload at a time), per-file progress via XHR.
Survives reloads and app backgrounding. A red badge on the FAB indicates active uploads.
5. **Server-side processing** — multipart received → MIME-sniffed via `infer` → size
validated → original stored → compression worker (bounded by a `tokio::sync::Semaphore`)
resizes to an 800-px preview (images via the `image` crate + `oxipng` for PNG) or
extracts a frame at the 1-second mark (videos via `ffmpeg`). Status is tracked in the new
`compression_status` column (migration 008).
6. **Real-time fan-out**`new-upload` SSE first (no preview yet), then `upload-processed`
when the preview/thumbnail is ready, so clients can swap a placeholder for the real image
without re-fetching the feed.
7. **Rate-limit-aware client** — when the server returns HTTP 429 with `Retry-After`, the
queue parks remaining items and shows an inline countdown banner; uploads resume
automatically.
### 2.3 Feed
- **Two layouts** — chronological list (default) and 3-column grid. Toggle in the header.
- **List view** has no search; it's the consumption-focused mode (like an Instagram feed).
- **Grid view** has the search bar — autocomplete suggestions are computed in-memory from
the loaded uploads, so typing never hits the server.
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
hashtag + uploader combine with AND. Matches the redesign concept exactly.
- **Lightbox** — fullscreen view, swipe navigates the *filtered* set, with embedded
like/comment UI.
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
### 2.4 Host / Admin tooling
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
Nutzerverwaltung. Ban modal asks explicitly whether to hide the user's existing uploads
from the public feed. Promote/demote, lock/unlock, release-gallery are one-tap.
- **Admin dashboard** — same dashboard plus three more inner tabs (Stats, Config, Export,
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
guest count, and compression concurrency — all stored in the `config` table and read on
each request, so changes take effect without a restart. Disk widget pulls from the
`sysinfo` crate live.
### 2.5 Data mode (planned)
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
guest can be on Saver on their phone and Original on their laptop.
| Mode | Default? | Feed loads... | Lightbox / diashow loads... | Warning shown? |
|------------|:--------:|-----------------------------|------------------------------|:-------------:|
| Datensparer (Saver) | ✓ | preview (compressed) | preview | no |
| Original | | original | original | yes — "kann mobile Datennutzung erhöhen" once on enable |
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
### 2.6 Rate limits and quotas — toggleable (planned)
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
- **Master switch — all rate limits.** When off, every limiter middleware short-circuits to
pass-through. Useful for testing or trusted internal events.
- **Per-endpoint switches.** Upload / feed / export / join each have their own toggle. The
numeric input becomes informational while the toggle is off.
- **Master switch — quotas.** When off, no quota check ever runs.
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
independently.
When a feature is toggled off, the relevant UI in the guest-facing app should adapt: e.g.
the "Du hast X von Y MB genutzt" widget hides itself when storage quota is disabled. The
quota estimate is computed from the same formula the server uses
(`(free_disk × tolerance) / max(active_uploaders, 1)`) — surfaced in My Account *and* on
the upload preview screen so guests know before they pick files.
### 2.7 Privacy note (Datenschutzhinweis, planned)
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
Config). It's stored as a single config key (plain text, whitespace and newlines
preserved, no HTML). Guests see it in their **My Account** page, rendered inside a
preformatted block — no parsing, no markdown, just exactly what the admin typed. The
first-visit onboarding guide gains a one-line nudge: *"Datenschutzhinweis findest du in
deinem Account."*
Rationale: many real events (in Germany especially) need a per-event privacy statement
without the operator wanting to ship a separate static page or rebuild the app.
### 2.8 Export
Two artifacts, both generated on demand after the host taps "Release gallery":
- **Gallery.zip** — full-quality originals only, structured into `Photos/` and `Videos/`,
filenames `{date}_{time}_{username}_{id}.{ext}`, streamed via `async-zip` with no full
archive in memory.
- **Memories.zip** — the offline HTML viewer. Pre-built SvelteKit-static app from
[frontend/export-viewer/](../frontend/export-viewer/), bundled with a generated
`data.json` snapshot and a `media/` folder of thumbnails + full-size variants. Open
`index.html` in any browser — no server required, no internet required. List/grid views,
lightbox, hashtag chips, like counts, comments — all visually matched to the live app.
The export page shows live progress (SSE) while jobs run, then becomes a download button
when complete.
---
### 2.9 Maintainability and extensibility
EventSnap is small enough to be a single-developer project; it should stay easy to extend.
A few principles to keep adding features cheap:
- **Diashow transitions are drop-in components.** Each animation implements a small
interface and lives under `frontend/src/lib/diashow/transitions/`. Adding a transition is
one file + one entry in a registry.
- **Feature toggles live in the `config` table.** Today's rate-limit and quota switches
follow the same pattern any new opt-in feature would use — no redeploy to flip
behaviour.
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
diashow state — composable rather than copy-pasted into each route.
- **Migrations are append-only.** Never edit a shipped migration; always add a new pair.
- **Background jobs share one pipeline.** Export and compression already publish progress
via the `export_job` row + SSE; future long-running work (analytics, archival) should
plug into the same shape.
See [IDEAS.md](IDEAS.md) for a longer riff on these patterns.
## 3. Out of scope (intentionally not built)
These are explicit non-goals from [PROJECT.md §4](../PROJECT.md):
- Native iOS / Android apps
- Multiple simultaneous events (multi-tenancy)
- Email-based auth / password reset
- Push notifications
- User-to-user direct messaging
- Payment / monetisation
- CI/CD pipeline
- "Save to camera roll" automation on iOS/Android — guests download the ZIP and use their
platform file manager
---
## 4. See also
- [USER_JOURNEYS.md](USER_JOURNEYS.md) — step-by-step flows for every supported scenario.
- [CONCEPT_MOBILE_UI.md](CONCEPT_MOBILE_UI.md) — design reference for the mobile layout.
- [CONCEPT_HTML_VIEWER.md](CONCEPT_HTML_VIEWER.md) — export-viewer design.
- [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md) — planned diashow design.
- [IDEAS.md](IDEAS.md) — speculative extensions (global diashow, reactions, multi-tenancy, ...).
- [PROJECT.md](../PROJECT.md) — full architectural blueprint and rationale.
- [TEST_GUIDE.md](../TEST_GUIDE.md) — manual smoke-test script for the main flows.

199
docs/IDEAS.md Normal file
View File

@@ -0,0 +1,199 @@
# EventSnap — Ideas & Future Extensions
A dumping ground for design ideas that are **not yet on the roadmap**. Everything here is a
v2+ candidate, brainstormed once the core experience is stable. For shipped or actively
planned scope see [FEATURES.md](FEATURES.md) and the `CONCEPT_*.md` design docs.
The bar to land here is low: "would be cool one day" qualifies. The bar to graduate to a
`CONCEPT_*.md` is much higher (design committed, ready to build).
---
## Diashow extensions
### Global / synchronised diashow
Multiple devices show **the same slide at the same time** (e.g. a projector in the main
hall plus tablets behind the bar plus a screen by the photo booth).
Sketch:
- Server holds a single authoritative "current slide" cursor for the event.
- New SSE event `diashow-tick` broadcasts `{ slide_id, started_at, next_at }`.
- Each subscribed client renders locally — server only chooses ordering and pace.
- Live-queue / shuffle-queue logic (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) lives
server-side instead of client-side.
- A "leader device" can claim the diashow, or the server runs it head­lessly. Host UI lets
Host start / stop the global diashow.
- Plays well with venues that already have multiple displays — no need for HDMI splitters
or chromecast hacks.
### Audio bed
- Host uploads or selects a background track (per-event).
- Videos in the diashow auto-mute so they don't fight the music.
- Optional ducking when a video has speech.
### Curated diashow mode
- Diashow filtered by a hashtag (`#highlights`) or to a Host-pinned set ("Story" feature).
- Useful for the end-of-evening recap reel.
### Animation pack
- More transitions out of the box: zoom, slide, mosaic, dip-to-black, push.
- Per-event "theme" preset — wedding-elegant, party-energetic, minimal, gallery-classic.
- Builds on the maintainability principle below: each transition is a drop-in Svelte
component, so growing the pack is trivial.
### Lower-third metadata
- Subtle chyron at the bottom of each slide: uploader name + timestamp + caption.
- Off by default; toggle in the diashow settings popover.
### Smart pacing
- Detect video duration and let videos play their full length (with a cap), pause stills
for the remainder. Avoids choppy 6-second cuts on a clip with key content at 0:08.
- "Action density" heuristic — slow down for portraits, speed up for landscapes.
---
## Social
### Per-guest gallery
A first-class "All posts by Anna" view, navigable from a guest's avatar — not just a
filter chip. Doubles as a personal "what did I post?" page.
### Story-style highlights
Host curates a best-of timeline pinned at the top of the feed (already in PROJECT.md's
"Should Have"). Tap-through, fullscreen, ~5 s per story, like Instagram. Could double as
the source for the curated-diashow mode above.
### Reactions beyond like
Multiple emoji reactions (❤️ 😂 😍 🎉 🥲) instead of just like. The DB design already keys
the `like` table on `(upload_id, user_id)` — generalising to `(upload_id, user_id, kind)`
is a small migration.
### Mentions and reply-threads in comments
- `@anna` in a comment becomes a tap-through to her profile / posts.
- Threaded replies under each top-level comment.
- Combined with PWA push, drives engagement.
### Collaborative captions
Co-authored captions when multiple uploaders are in the same photo — second tagger
contributes their `#hashtags` to the same post.
---
## Notifications
- PWA push for new comments on a guest's own posts.
- Per-user opt-in; granular per-event preference (mute event, mute uploader, etc.).
- Email digest after the event (1 message per guest) — optional, controversial vs. the
"no email" identity model. Could be opt-in only.
---
## Capture & posting
- **Live-photo mode** — capture a 12 s clip alongside each still (Apple-style). Diashow
could animate stills using the live clip as the Ken Burns source.
- **Boomerang / GIF capture** — short looping clips.
- **Client-side filters and stickers** — Instagram-style.
- **Voice notes** attached to a photo — "first dance" voice memo + the photo.
- **Bulk-upload presets** — pre-fill a caption for a batch ("Photos from the ceremony").
---
## Privacy & moderation
- **Per-post visibility** — "only visible to people with this hashtag" or
"private to my friend group".
- **Pre-moderation queue** — Host approves posts before they hit the public feed (default
off; for sensitive events).
- **Auto-blur** of detected faces of non-guests, or NSFW detection.
- **Per-uploader watermark** on full-quality downloads.
---
## Multi-tenancy
- **Multiple events per instance** — picked by URL slug. Today the binary is single-event.
- **Org accounts** — a wedding photographer running 4 weddings a month against the same
deployment.
- **Per-event admin** vs. **instance admin** roles.
---
## Internationalisation
- Localisation beyond German — English, French, Spanish, ...
- Admin picks UI language during setup; per-user override.
- Strings extracted into a small JSON catalogue — works well with `svelte-i18n` or similar.
---
## Export
- **Year-in-pictures PDF** — host-curated layout, printable.
- **ICS calendar attachment** of the event, included in the export ZIP.
- **Direct upload to a guest's chosen cloud** (iCloud, Google Photos) — needs OAuth, adds
a third-party integration where today there are none.
---
## Resilience / infrastructure
- **Distributed rate limiting** (Redis) for multi-instance / multi-event deploys.
- **Object-storage backend** (S3 / MinIO) behind a feature flag — out of scope for the
single-VPS use case but easy to add if multi-tenancy is ever pursued.
- **Read replicas** for very large events.
---
## Maintainability principles to keep adding features cheap
The codebase is small today and should stay friendly to extension. A few patterns to lean
into as the surface grows:
- **Diashow transitions as drop-in components.** Each transition implements a tiny
interface (`enter`, `leave`, optional `duration`). Adding a new animation is one file in
`frontend/src/lib/diashow/transitions/` and one line in a registry. Same idea for
hashtag-filter operators.
- **Per-feature toggle flags in the `config` table.** Today rate limits and quotas are
individually toggleable (see [FEATURES.md](FEATURES.md)). The same pattern fits for any
future opt-in feature — no need to redeploy to flip behaviour.
- **Background-task trait on the server.** Export, compression, and (future) analytics
jobs would all share a `BackgroundJob` interface that wires into the existing
`export_job` progress + SSE pipeline. New long-running work plugs in by implementing the
trait — no bespoke worker code per feature.
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
diashow state — each lives in its own store under `frontend/src/lib/`. New UI features
consume the stores; cross-feature behaviour is composed, not copy-pasted.
- **DTOs in one file** ([frontend/src/lib/types.ts](../frontend/src/lib/types.ts)),
mirrored to the Rust DTOs. Changing a contract is exactly two edits.
- **Migration-first schema evolution** — never edit an old migration; always add a new
`0NN_*.up.sql` / `.down.sql` pair. Already the discipline; just keep it.
---
## Speculative / "would be cool"
Lower bar of plausibility — keep these around as conversation seeds:
- **AI-generated event summary** at release time (3-paragraph recap, key moments,
funniest comment).
- **AI auto-tagging** — suggested hashtags based on image content, opt-in per upload.
- **Guest-of-honour mode** — special UI for the couple / birthday person showing
*everything they're in*, prioritised by face detection.
- **Live caption translation** for international weddings — auto-translate comments
inline.
- **Sound-reactive diashow** — slides advance in sync with music BPM picked up via the
device mic.
- **Photo-booth integration** — a fixed iPad at the venue posts to the feed with a single
tap, no PIN.

View File

@@ -0,0 +1,392 @@
# Mobile Testing Guide — EventSnap v0.15.0
## Setup
### Dev Servers
| Service | URL | Notes |
|---------|-----|-------|
| Frontend | `http://localhost:5173` | Vite dev server, hot-reload |
| Backend API | `http://localhost:3000` | Rust/Axum |
| Database | `localhost:5432` | PostgreSQL (Docker) |
The frontend dev server proxies `/api` and `/media` to the backend automatically.
**Mobile device access:** Connect your phone to the same Wi-Fi network.
Find your machine's local IP (`ip a | grep 192.168` or `hostname -I`), then open
`http://<your-ip>:5173` on your phone.
### Browser DevTools Mobile Emulation (quick testing without a phone)
1. Open Chrome → DevTools (`F12`) → Toggle device toolbar (`Ctrl+Shift+M`)
2. Select **iPhone 14 Pro** or **Pixel 7** from the device dropdown
3. Reload the page — safe-area insets and viewport are emulated
4. To test touch gestures: enable "Touch" in the three-dot menu inside the device toolbar
---
## Test Accounts
Use the following to get all three roles:
| Role | How to get it |
|------|--------------|
| Guest | Join at `/join` with any name |
| Host | Promote a guest via Host Dashboard, or set role in DB |
| Admin | POST to `/api/v1/admin/login` or navigate to `/admin/login` |
Admin password: `admin123` (set in `.env`)
---
## Section 1 — Bottom Navigation Bar
**Goal:** Verify the tab bar is present, thumb-accessible, and correct per role.
### 1.1 Bar Presence & Safe Area
- [ ] Open `/feed` — a bottom tab bar with **Galerie**, a blue circle FAB, and **Konto** appears
- [ ] On a real iPhone/Safari: the bar does **not** overlap the home indicator (safe-area padding)
- [ ] On Chrome DevTools with an iPhone device: the bar is above the viewport bottom
- [ ] Scroll down on a long feed — the bar stays **fixed** at the bottom at all times
- [ ] The bar has a frosted-glass blur effect (`bg-white/90 backdrop-blur-md`)
### 1.2 Active Tab Indicator
- [ ] On `/feed` — the Galerie icon is **blue**; Konto icon is gray
- [ ] Tap **Konto** — navigates to `/account`; Konto icon turns blue, Galerie goes gray
- [ ] Tap **Galerie** — navigates back to `/feed`
### 1.3 Role Gating
- [ ] Log in as a **guest** — bar shows Galerie · FAB · Konto (3 items)
- [ ] Log in as a **host** — same 3 items (dashboard links are inside Account, not the bar)
- [ ] Log in as **admin** — same 3 items
### 1.4 Auth Pages Hide the Bar
- [ ] Visit `/join`**no** bottom bar
- [ ] Visit `/recover`**no** bottom bar
- [ ] Visit `/admin/login`**no** bottom bar
---
## Section 2 — Upload FAB & Bottom Sheet
**Goal:** Verify the FAB opens the upload sheet and both source options work.
### 2.1 FAB Appearance
- [ ] The FAB is a blue circle elevated ~12 px above the tab bar
- [ ] A camera icon with an implicit "+" meaning is shown
- [ ] When uploads are in the queue (pending or uploading), a **red badge number** appears on the FAB
### 2.2 Sheet Opens & Closes
- [ ] Tap the FAB — a bottom sheet slides up smoothly (~300 ms) from below
- [ ] Sheet shows: **Galerie** (blue icon), **Kamera** (purple icon), **Abbrechen**
- [ ] Tap the gray backdrop — sheet slides **back down** and closes
- [ ] Tap **Abbrechen** — sheet closes
- [ ] Swipe the drag handle downward — sheet closes *(if touch gestures are enabled)*
### 2.3 Gallery Source
- [ ] Tap **Galerie** — the native file picker opens
- [ ] Select 13 images or videos
- [ ] Sheet closes; you are navigated to `/upload` (the composer page)
- [ ] Thumbnail strip at the top shows your selected files
- [ ] **Bottom nav is gone** on this page (immersive full-screen)
### 2.4 Camera Source
- [ ] Tap the FAB → **Kamera**
- [ ] Browser asks for camera permission — grant it
- [ ] Full-screen camera UI appears (existing CameraCapture component)
- [ ] Take a photo
- [ ] Camera closes; you are navigated to `/upload` with the captured image in the strip
### 2.5 Upload Composer Page
- [ ] Back `×` button top-left → returns to `/feed`, clears pending files
- [ ] Thumbnail strip scrolls horizontally when >3 files
- [ ] Each thumbnail has a small `×` to remove it — tapping removes that file only
- [ ] Caption `<textarea>` is **auto-focused** (keyboard opens on mobile)
- [ ] Type `#party #spaß` in the caption — **quick-tag chips** appear below the textarea in real time
- [ ] Quick-tag chips are read-only (they reflect what's already in the caption)
- [ ] The **"Hochladen"** sticky button at the bottom shows the file count: "2 Dateien hochladen"
- [ ] Button is **disabled** when the strip is empty
- [ ] Also a smaller "Hochladen" button in the header (convenient on desktop/landscape)
- [ ] Tap **Hochladen** — files are queued, you are returned to `/feed`
- [ ] A **slim blue progress bar** appears just above the bottom tab bar while uploading
- [ ] FAB shows a **red badge** during upload; badge disappears when done
- [ ] A brief "Fertig" / completed state appears in the UploadQueue (check queue store)
---
## Section 3 — Feed: List View
**Goal:** Verify the default chronological list view.
### 3.1 Default State
- [ ] Open `/feed`**list view** is active by default (≡ icon highlighted in the toggle)
- [ ] Posts appear as full-width cards in reverse-chronological order (newest first)
### 3.2 List Card Anatomy
For each card, verify:
- [ ] **Avatar circle** with the uploader's initial letter and a deterministic color
- [ ] **Display name** + **relative timestamp** ("vor 2 Min.", "vor 1 Std.", etc.)
- [ ] **Media**: full-width image, or video with a play button overlay
- [ ] **Caption** below the media (truncated to 3 lines with `...` if long)
- [ ] **Like count** (❤️) and **Comment count** (💬) action buttons
- [ ] Tapping the ❤️ toggles the like optimistically (count changes immediately)
- [ ] Tapping 💬 or the media opens the **Lightbox Modal** (existing behavior, unchanged)
### 3.3 Hashtag Chips (List View Only)
- [ ] Below the main header, hashtag filter chips are visible in **list view**
- [ ] Tap a hashtag chip — feed re-fetches filtered by that tag
- [ ] Tap **Alle** — returns to unfiltered feed
- [ ] Chips are **not visible** when grid view is active
### 3.4 Infinite Scroll
- [ ] Scroll to the bottom — more posts load automatically
- [ ] A spinner appears briefly while loading more
- [ ] Scroll sentinel triggers ~200 px before the actual bottom
### 3.5 Real-Time Updates (SSE)
- [ ] Open the feed on two devices/tabs simultaneously
- [ ] Upload a photo on one — it appears at the **top** of the other's list view in real time
- [ ] Like a post on one — the count updates on the other
---
## Section 4 — Feed: Grid View & Search
**Goal:** Verify the 3-column grid, search bar, autocomplete, and filter chips.
### 4.1 Switching to Grid View
- [ ] Tap the ⊞ grid icon in the header — view switches to a 3-column grid
- [ ] The ≡/⊞ toggle shows ⊞ as active (white background, shadow)
- [ ] Hashtag chips **disappear**; a **search bar** slides in below the header
### 4.2 Grid Layout
- [ ] Grid is **3 columns** with equal square cells (no 2-column fallback on mobile)
- [ ] Videos show a ▶ play button overlay
- [ ] Tapping a cell opens the Lightbox Modal
- [ ] Grid background is seamless (0.5px gap between cells)
### 4.3 Search Bar
- [ ] Search bar shows: 🔍 icon, placeholder "Nutzer oder #Tag suchen…", × clear button
- [ ] Tapping the bar focuses it and opens the keyboard
- [ ] × button appears only when there is text in the input; tapping it clears the query
### 4.4 Autocomplete — On Focus (Empty)
- [ ] Focus the search bar with no text — a dropdown appears with:
- Up to 3 uploader names (person icon)
- Up to 3 popular tags (#)
- [ ] The dropdown disappears when the input loses focus (150 ms delay)
### 4.5 Autocomplete — Tag Suggestions
- [ ] Type `#` — only **tag suggestions** appear (no users), sorted by frequency
- [ ] Type `#par` — only tags starting with "par" remain (e.g. `#party`, `#parade`)
- [ ] Tap a suggestion — it's added as a **blue filter chip** below the search bar; input clears
### 4.6 Autocomplete — User Suggestions
- [ ] Type a partial name (e.g. `max`) — users matching "max" appear first, then tags containing "max"
- [ ] Tap a user suggestion — chip added: shows the name without `#` prefix
### 4.7 Filter Chips
- [ ] After selecting a tag filter — grid shows only posts with that tag in the caption
- [ ] Select a second tag — grid shows posts with **either** tag (OR logic)
- [ ] Select a user **and** a tag — grid shows posts by that user **that also** have that tag (AND across types)
- [ ] Each chip has an **× remove button**; tapping it removes only that chip
- [ ] When 2+ chips are active: **"Alle löschen"** link appears; tapping clears all filters
- [ ] When no results match: "Keine Treffer für die gewählten Filter." + "Filter zurücksetzen" button
### 4.8 Switching Back to List View
- [ ] Tap ≡ — list view returns; search bar gone; hashtag chips reappear
- [ ] Active grid filters are **reset** when switching back to list (no stale state)
---
## Section 5 — Account Page
**Goal:** Verify the profile card, dashboard links, and leave-confirm flow.
### 5.1 Profile Card
- [ ] Open `/account` via the Konto tab
- [ ] **Avatar circle** shows your initial letter in a deterministic color
- [ ] **Display name** and **role badge** (Gast / Gastgeber / Admin) shown
- [ ] Session expiry date shown in small text below
### 5.2 Dashboard Links (Host/Admin Only)
- [ ] Log in as a **guest** — no "Dashboards" section visible at all
- [ ] Log in as a **host** — "Dashboards" section shows ⭐ **Host-Dashboard** → chevron
- [ ] Tapping it navigates to `/host`
- [ ] No Admin-Dashboard link visible
- [ ] Log in as **admin** — both links appear:
- [ ] ⭐ Host-Dashboard → `/host`
- [ ] 🛡 Admin-Dashboard → `/admin`
### 5.3 PIN Card
- [ ] Amber card shows the 4-digit PIN in large monospace font
- [ ] **Kopieren** button copies to clipboard; label changes to "Kopiert!" for 2 seconds
- [ ] If no PIN is stored: fallback message shown
### 5.4 Konto Section
- [ ] **Gerät wechseln / PIN nutzen** → navigates to `/recover`
- [ ] **Event verlassen** (red text) → tapping opens a **leave-confirm bottom sheet**
- [ ] Sheet shows: "Event verlassen?", "Du wirst abgemeldet…", red "Abmelden" + "Abbrechen"
- [ ] Tap backdrop — sheet closes, you remain logged in
- [ ] Tap **Abbrechen** — same
- [ ] Tap **Abmelden** — you are logged out and redirected to `/join`
### 5.5 No Stale Nav Links
- [ ] **No** "Zur Galerie" link in the header (navigation is via the bottom bar)
---
## Section 6 — Host Dashboard
**Goal:** Verify the back arrow, collapsible sections, and all existing host actions still work.
### 6.1 Navigation
- [ ] Open Host Dashboard via Account → ⭐ Host-Dashboard
- [ ] Page shows a **← back arrow** in the top-left header
- [ ] Tapping it navigates to `/account`
- [ ] **No** shield/gallery header icons (removed)
- [ ] Bottom tab bar is still visible
### 6.2 Statistiken Section (Collapsible)
- [ ] Section is **expanded** by default with a downward chevron
- [ ] Shows a 2×2 grid of stat cards: Gäste, Uploads, Uploads status (Offen/Gesperrt), Freigegeben (Ja/Nein)
- [ ] Numbers are large and readable on mobile
- [ ] Tap the **Statistiken** header button — section collapses (smooth max-height animation)
- [ ] Chevron rotates 180° to point upward when collapsed
- [ ] Tap again — section expands
### 6.3 Event-Einstellungen Section (Collapsible)
- [ ] Collapse/expand works same as above
- [ ] Shows **"Uploads sperren"** (amber) / **"Uploads wieder öffnen"** (green) button
- [ ] Shows **"Galerie freigeben"** (blue) / "Galerie bereits freigegeben" (disabled gray)
- [ ] Tap "Uploads sperren" — toast confirms, button switches to "Uploads wieder öffnen"
- [ ] Existing functionality unchanged
### 6.4 Nutzerverwaltung Section (Collapsible)
- [ ] **Search bar** at top of section filters the user list in real time (client-side)
- [ ] Each user row shows name, role badge, banned badge (if applicable), upload count/bytes
- [ ] **Sperren** button triggers the existing ban modal (confirm + hide-uploads checkbox)
- [ ] **Entsperren** appears for banned users
- [ ] **Host** button promotes a guest to host role
- [ ] **Degradieren** appears for hosts (admin only)
- [ ] Toast notifications appear above the bottom bar (not obscured by it)
---
## Section 7 — Admin Dashboard
**Goal:** Verify the inner tab bar, all 4 tabs, and the new Nutzer tab.
### 7.1 Navigation
- [ ] Open Admin Dashboard via Account → 🛡 Admin-Dashboard
- [ ] Page shows **← back arrow** → `/account`
- [ ] **No** star/gallery header icons
- [ ] Bottom tab bar visible
### 7.2 Inner Tab Bar
- [ ] A second tab bar appears **below the main header**, sticky on scroll
- [ ] 4 tabs: **Stats · Config · Export · Nutzer**
- [ ] Active tab has a blue bottom border and blue text
- [ ] Inactive tabs are gray
- [ ] Tabs are scrollable horizontally (try narrowing viewport)
- [ ] Switching tabs is instant with no page reload
### 7.3 Stats Tab
- [ ] Shows a **2×2 grid** of metric cards: Gäste, Uploads, Kommentare, Speicher %
- [ ] Values are large (`text-3xl`)
- [ ] Below the grid: a full-width disk usage bar with color coding
- Blue ≤ 74%, Amber 7589%, Red ≥ 90%
- [ ] Exact used/total/free values shown
### 7.4 Config Tab
- [ ] Shows stacked label + full-width input for each of the 8 config keys
- [ ] Inputs are `type="number"` with large touch targets
- [ ] A **"Speichern"** button is **sticky at the bottom** of the tab (always visible, even on long scroll)
- [ ] Edit a value → tap Speichern → toast "Konfiguration gespeichert."
- [ ] Tap Speichern with no changes → toast "Keine Änderungen."
### 7.5 Export Tab
- [ ] **"Galerie freigeben"** button triggers gallery release
- [ ] **"Aktualisieren"** button refreshes the jobs list only (no full page flash)
- [ ] Export jobs listed with status chips: Ausstehend (gray) / Läuft (blue) / Fertig (green) / Fehlgeschlagen (red)
- [ ] Running jobs show a progress bar
- [ ] Failed jobs show the error message in red
### 7.6 Nutzer Tab (New)
- [ ] Users are loaded from `/host/users` (admin shares host permissions)
- [ ] **Search bar** filters list in real time
- [ ] Same ban/unban/promote/demote actions as Host dashboard
- [ ] After an action (e.g. ban) only the users list refreshes, not the whole page
---
## Section 8 — Toast Position
- [ ] On host/admin pages, toasts appear at `bottom-24` (above the bottom nav bar)
- [ ] Toasts are **not** obscured by the nav bar
---
## Section 9 — Desktop Usability (Second Citizen)
**Goal:** Confirm all pages are still usable on a wide viewport.
### 9.1 Layout Centering
- [ ] On a 1280px+ viewport, all pages center their content at `max-w-2xl` or `max-w-3xl`
- [ ] Bottom tab bar spans full width but content columns remain centered
- [ ] No content is clipped or overflows horizontally
### 9.2 Feed Desktop
- [ ] List view: cards are centered, readable at 672px max width
- [ ] Grid view: 3 columns at max-width — cells are larger and look good
- [ ] Search bar is full-width within the max-width container
### 9.3 Upload Composer Desktop
- [ ] Upload page is full-height, centered column
- [ ] Both the header "Hochladen" button AND the sticky bottom button are present
- [ ] Desktop users can click the header button (more convenient without reaching to bottom)
### 9.4 Host / Admin Desktop
- [ ] Host collapsible sections work with mouse clicks
- [ ] Admin inner tabs work with mouse clicks; all 4 tabs visible without scrolling at 1280px
- [ ] Config tab sticky save is visible on desktop scroll
---
## Section 10 — Edge Cases
### 10.1 Upload with No Files Selected
- [ ] Navigate directly to `/upload` in the browser
- [ ] No files pending → "Keine Dateien ausgewählt" screen shown with "Zurück" button
- [ ] "Hochladen" button is disabled
### 10.2 Rate Limiting
- [ ] Upload rapidly beyond the configured limit (default: 10/hour)
- [ ] A `429` response is received
- [ ] The countdown banner appears above the bottom nav: "Upload-Limit erreicht. Wird in X Sek. automatisch fortgesetzt."
- [ ] After the countdown, the queue resumes automatically
### 10.3 SSE Reconnect
- [ ] Stop the backend briefly and restart
- [ ] The feed reconnects (SSE) — new uploads appear once the backend is back
### 10.4 Back Navigation from Upload
- [ ] Pick files → navigate to `/upload`
- [ ] Tap `×` → files are discarded (`clearPending()` runs, object URLs are revoked)
- [ ] Navigate back to `/upload` directly — "Keine Dateien ausgewählt" shown (not stale files)
### 10.5 Grid Filter Persistence
- [ ] Set a filter chip in grid view
- [ ] Switch to list view — filter is cleared (list always shows full unfiltered feed)
- [ ] Switch back to grid — search bar is empty, no stale chips
---
## Known Limitations (Not Bugs)
| Item | Status |
|------|--------|
| "Anzeigename ändern" in Account | Deferred — shown as disabled; requires `/me` PATCH endpoint |
| Upload count in Account profile card | Deferred — requires `/me` GET endpoint |
| CSS collapse animation on host sections | Uses `max-h` trick; may be slightly sluggish for very large user lists |
| Autocomplete results | Derived from currently-loaded posts only; new posts via SSE update the pool automatically |

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

@@ -0,0 +1,51 @@
# Security & Hardening Backlog
Tracks the deliberately-deferred items from the 2026-06-27 audit and its review passes. The
Critical→Medium findings and the cheap "bucket 🅰" LOWs are fixed on
`fix/audit-2026-06-27-critical-medium`. What remains is recorded here so the decisions are
explicit, not forgotten.
## 🅱 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.
This is a functional hole, not polish. Needs a host-facing "remove" action wired to those
endpoints (a `ContextSheet` action gated on host role).
- **Feed reactivity** — a single global SSE event (`like-update`/`new-comment`/`upload-processed`)
triggers `loadFeed(true)`, which full-replaces the list with the first 20, collapsing a
scrolled feed; and owner-deleted uploads aren't broadcast to other clients (only host deletes
are). Patch the affected item from the SSE payload instead of full-reloading; emit
`upload-deleted` from the owner `delete_upload` path too.
- **Media HMAC domain separation** — the signed-media tokens reuse `jwt_secret` as the HMAC key.
Correct today (different message structure), but a dedicated derived key
(`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't
weaken the other.
- **Quota mount-detection + low-disk guard** — `compute_storage_quota` picks the disk via
`starts_with` (root is a wildcard prefix → 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.
## 🅲 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.
## By-design notes (documented, not bugs)
- **Banned user retains ≤24h media access via already-held signed URLs.** The 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: tightening this would require per-request identity on every `<img>` load, which
the `<img>`-can't-send-a-Bearer constraint precludes. Shorten the TTL in `media_token.rs` if a
stricter bound is ever needed.
- **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser
caching and the stable bucketed URL (so warm fetches don't hit the backend at all). Could be one
JOIN if it ever shows up in profiling.

292
docs/USER_JOURNEYS.md Normal file
View File

@@ -0,0 +1,292 @@
# EventSnap — User Journeys
This document walks through every supported user scenario step-by-step. For a quick "who
can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
[TEST_GUIDE.md](../TEST_GUIDE.md).
---
## 1. First-time guest (the happy path)
1. Guest scans the QR code / opens the event link.
2. Lands on the **join page** (`/join`), sees the event name. A small
*"Ich habe bereits einen Account"* link is visible below the form for returning users
— it routes to `/recover`.
3. Types display name → taps **Beitreten**.
4. Server creates the account, generates a 4-digit PIN, stores `bcrypt(PIN)`, signs a
30-day JWT.
5. A **PIN modal** appears: large monospace digits, a **Kopieren** button, a warning that
this PIN is the only way to sign in on another device. PIN is also written to
`localStorage`.
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
7. The **first-visit onboarding overlay** appears: dismissible steps (welcome, upload,
hashtags, PIN, and a brief pointer to the **Datenschutzhinweis** in My Account).
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
## 2. Returning guest, same device
1. App finds a valid JWT in `localStorage`.
2. Redirected straight to `/feed`, no input required.
## 3. Returning guest, new device or cleared storage
1. Guest opens the event link on the new device → join page.
2. Types the **same name** they used before.
3. Server detects the existing account → the join page transforms into a recovery prompt:
*"„Name" ist bereits vergeben"* with a **PIN input** and an **Anmelden** button, plus
an **Anderen Namen wählen** escape hatch.
4. Guest types their PIN → `bcrypt.verify` succeeds → new JWT issued for the existing
`user_id`. PIN is written to `localStorage` on this device too.
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
## 4. PIN forgotten — Host or Admin resets it (planned)
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
1. Guest approaches the Host: *"I can't sign in on my new phone."*
2. Host opens the **Host Dashboard → Nutzerverwaltung** and finds the user.
3. Host taps **PIN zurücksetzen** on that row.
4. A confirmation prompt explains what happens; on confirm the server generates a fresh
4-digit PIN, replaces `recovery_pin_hash` with the new bcrypt, clears any active
`pin_locked_until`, and returns the new plaintext PIN in the response.
5. A **modal shows the new PIN ONCE** — large, with a copy button. The Host shows the
screen to the guest or sends it via another channel (SMS, slip of paper, …). Closing
the modal forgets the plaintext on the operator's device too.
6. Guest goes to `/recover` (or taps "Ich habe bereits einen Account" on `/join`), enters
their name + the new PIN, signs in, and the PIN is persisted to `localStorage` on
their device — exactly like a fresh join.
**Permission rules:**
- Host can reset PINs for **guests** only.
- Admin can reset PINs for **hosts and guests** (not other admins; admins use the
password login).
- Anyone whose PIN was reset retains all their uploads, comments, and likes — only the
PIN changes.
**If no Host or Admin is reachable**, the guest can still re-join under a new name (a
clean account; their previous uploads remain attributed to the abandoned account, which
the Host can clean up later).
## 5. Posting a photo / video
1. Guest taps the central **📷+ FAB** in the bottom nav.
2. A **bottom sheet** slides up offering **Kamera** (in-app capture) or **Galerie** (file
picker, multi-select).
3a. **Camera path** — [CameraCapture](../frontend/src/lib/components/CameraCapture.svelte)
opens the back camera (`facingMode: 'environment'`), with toggle for front camera,
photo button, and a video-record button using `MediaRecorder`.
3b. **Gallery path** — native picker, multiple selection.
4. **Preview screen** (`/upload`) shows staged files as horizontal thumbnails. The user can:
- Remove individual files.
- Type a caption with `#hashtags`.
- Tap quick-tag chips (derived from the caption) to copy a hashtag into the caption.
5. Taps **Hochladen** → returns immediately to the feed (optimistic UX). The slim progress
bar above the bottom nav and the red badge on the FAB indicate active uploads.
6. The client uploads files **one at a time** (XHR with progress) from an IndexedDB queue.
7. Each upload triggers a server-side compression job; once the preview is ready the feed
updates via `upload-processed` SSE — placeholders swap for actual previews.
## 6. Posting under rate limits
1. Hit the per-hour upload limit (default 10 / hour, configurable).
2. Server returns **HTTP 429** with a `Retry-After` header on the next upload attempt.
3. Client parks pending items in **Wartend** state and shows an amber banner:
*"Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."*
4. Countdown ticks down. When it reaches 0, the queue resumes automatically.
## 7. Liking and commenting
1. Tap the heart icon on a card or in the lightbox → like is recorded; count increments
optimistically; server returns the canonical count via `like-update` SSE.
2. Tap the comment icon → opens the lightbox with the comments list.
3. Type a comment → `POST /api/v1/upload/{id}/comment`. Hashtags inside the comment are
parsed and attached.
4. The user can delete their own comments (trash icon next to them).
## 8. Filtering the gallery
1. Toggle to **grid view** (icon top-right of the feed header).
2. A search bar appears below the header (auto-focused).
3. Type a name or `#hashtag` — autocomplete suggestions are derived **in memory** from the
loaded uploads.
4. Tap a suggestion → it becomes an **active filter chip** and the search bar clears.
5. Filter logic:
- Multiple hashtag chips: OR
- Multiple uploader chips: OR
- One uploader + one hashtag: AND
6. Open a post → swipe in the lightbox navigates the **filtered set**, not the full feed.
## 9. Hosting the event — moderation
1. Host opens **My Account** → taps **⭐ Host-Dashboard**.
2. **Stats section** — guest count, upload count, lock status, release status.
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
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`.
- **Entsperren** lifts the ban.
- **Host** promotes a guest to host.
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
only an Admin can demote themselves out of moderation. Admins see Degradieren on
every Host row.
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
rows.
6. **Deleting content** — Host can delete any upload or comment via the moderation routes
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
reachable by long-pressing the content (planned, see §15).
## 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).
## 11. Admin — instance configuration
1. Admin opens `/admin/login`, types the admin password (compared against
`ADMIN_PASSWORD_HASH`). Receives a separate 1-day admin JWT (in `sessionStorage`).
2. Admin dashboard has four inner tabs:
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
export), quota tolerance, estimated guest count, compression-worker concurrency,
plus the **Datenschutzhinweis** free-text editor and **on/off toggles** for the rate
limiters and quotas (planned — see §16). Whitelist on the server side rejects
unknown keys. Values are read from the `config` table on each request — no restart
needed.
- **Export**: list of past export jobs with status badges (pending / running / done /
failed) and progress bars; refresh button re-polls.
- **Nutzer**: same user list as Host, with the additional Demote action and (planned)
PIN-reset on host rows.
## 12. Releasing the export and downloading
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
2. Server sets `event.export_released_at` and enqueues two background jobs.
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
to disk via `async-zip`. Progress updates via `export-progress` SSE.
4. HTML-viewer job: copies the pre-built viewer assets from
[backend/static/export-viewer/](../backend/static/export-viewer/) (embedded via
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
variants for each upload, and assembles `Memories.zip`.
5. Both jobs complete → server broadcasts `export-available` SSE.
6. Any user opens `/export`:
- Before release: friendly "Export not yet available" banner.
- During generation: progress bars per artifact.
- After completion: two cards (**ZIP-Archiv** and **HTML-Viewer**) with download
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
browser download.
7. Downloads are rate-limited per IP (default 3 / day).
## 13. Diashow (planned)
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
on mobile).
2. Navigates to `/diashow` — fullscreen, bottom nav hidden, screen wake-lock acquired.
3. Initial pool fetched from `GET /api/v1/feed`. Slides crossfade every ~6 s.
4. New uploads (`upload-processed` SSE) push to a live queue; the next slide transition
pops from the live queue first, otherwise from a shuffled queue.
5. `upload-deleted` removes that ID from both queues; if it's the current slide, advance
immediately.
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
## 14. Picking a data mode (planned)
1. Guest opens **My Account** → scrolls to **Datennutzung**.
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
3. Selecting **Original** shows a one-time warning bottom-sheet:
*"Original-Dateien werden geladen — das kann deine mobile Datennutzung deutlich
erhöhen. Trotzdem aktivieren?"* with **Abbrechen** / **Aktivieren** buttons.
4. Choice persists in `localStorage` (per-device). The feed, lightbox, and diashow all
read this flag and load originals instead of compressed previews when Original is on.
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
bundled `_thumb` / `_full` variants.
## 15. Leaving an event
1. User opens **My Account** → taps **🚪 Event verlassen**.
2. Bottom-sheet confirmation: "Event verlassen?" with **Abmelden** and **Bleiben**.
3. Confirming calls `DELETE /api/v1/session` (invalidates the session row), clears the JWT
and PIN from `localStorage`, and redirects to the join page.
## 16. Reading the Datenschutzhinweis (planned)
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
and newlines preserved exactly as the Admin typed them). No HTML, no markdown — the
admin's plain text is shown verbatim.
3. The first-visit onboarding overlay carries a one-line reminder of where to find this:
*"Datenschutzhinweis findest du in deinem Account."*
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
tall textarea with a save button. Saved to a single `config` key.
## 17. Mobile-first gestures (planned)
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
ergonomic. Buttons are always present as fallback for desktop and accessibility.
| Gesture | Action |
|-------------------------------------------|-------------------------------------------------------|
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
| Long-press on a post (other) | Bottom sheet → Original anzeigen, Teilen, Melden (planned) |
| Long-press on a comment (own) | Bottom sheet → Löschen |
| Long-press on a comment (other) | Bottom sheet → Kopieren |
| Long-press on a user row (Host) | Bottom sheet → Sperren, Promote/Demote, PIN zurücksetzen |
| Swipe left/right in the lightbox | Navigate the filtered set |
| Swipe down on any bottom sheet | Dismiss |
| Pull-to-refresh on the feed | Force a delta-fetch |
| Double-tap on a post | Like (heart-burst animation) |
On desktop the same actions surface as kebab/⋯ menus, click-able icons in card corners,
and keyboard shortcuts in the lightbox (← → for navigate, Esc to close).
Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press for
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
land).
## 18. Admin toggles a rate limit or quota off (planned)
1. Admin opens **Admin Dashboard → Config**.
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
export / join).
3. Admin flips, e.g., **Upload-Limit aktiv** off. The numeric input for "uploads per hour"
stays visible but greyed out (still editable for when the toggle goes back on).
4. **Speichern** persists to the `config` table. The next upload request bypasses the
limiter entirely.
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
bytes / upload count).
6. When the storage-quota toggle is off, the **"X von Y MB genutzt"** widget in the
guest's My Account and upload screen hides itself (no quota → no number to show).
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
Toggling off is the explicit escape hatch for testing or trusted internal events.
---
## Edge cases worth knowing
| Case | Behaviour |
|-------------------------------------------------------|---------------------------------------------------------------------------------|
| Browser tab backgrounded for > 5 min | SSE closes on `visibilitychange: hidden`; reopens on visible |
| Upload finishes while user is on `/account` | Feed updates anyway — the queue + SSE are global stores |
| Event "closed" while files are still in the queue | Server rejects with a friendly error; client surfaces it in the queue UI |
| Network drops mid-upload | Queue retries the file; retry button available on permanent failure |
| New device but the PIN was lost | Either re-join under a new name, or Host manually re-links (no self-service) |
| Two guests pick the same name | Second one is offered the PIN-recovery form (case-insensitive UNIQUE, mig. 007) |
| Compression fails for a file | Server emits `upload-error` SSE; the upload is still listed but marked degraded |
| User deletes their own post (once UI is shipped) | Soft delete (`deleted_at`); SSE `upload-deleted`; vanishes from feed everywhere |

6
e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
playwright-report/
test-results/
.cache/
.env.test
*.log

14
e2e/Caddyfile.test Normal file
View File

@@ -0,0 +1,14 @@
# Caddyfile used only by the E2E test stack. Listens on the in-container :3101
# (mapped to host :3101) and proxies API + media to the backend, everything else
# to the SvelteKit frontend container — same layout as production but stripped
# of HTTPS/Let's Encrypt.
:3101 {
encode zstd gzip
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000
reverse_proxy /health app:3000
reverse_proxy frontend:3001
}

287
e2e/README.md Normal file
View File

@@ -0,0 +1,287 @@
# EventSnap E2E Suite
Playwright-driven end-to-end tests for the EventSnap stack. The suite spins
up an isolated docker-compose stack on ports `:3101` (Caddy → frontend +
backend) and `:55432` (Postgres), and exercises the SvelteKit frontend
against a real Rust backend with rate limits and quotas disabled.
**Phases 1, 2, and 3-mobile-gestures are landed**:
- **Phase 1** — happy-path coverage of every documented user journey, plus a
smoke matrix across nine browser/UA profiles to catch engine-level
divergences.
- **Phase 2** — adversarial inputs (XSS, SQL-injection, JWT forgery, MIME
spoofing, oversize, brute-force) and browser chaos (storage purge,
offline/slow-3G, multi-tab, clock skew, no-JS, quota exhaustion).
- **Phase 3 (gestures only)** — touch-target audit, safe-area structural
check, long-press → ContextSheet, double-tap → like, viewport reflow,
plus `test.fixme` stubs for planned gestures (lightbox swipe, swipe-down
dismiss, pull-to-refresh).
Phase 3 real-device compat (Android emulator + Samsung Internet via
`connectOverCDP`, BrowserStack), visual regression, and a11y audits are
sketched in the **Roadmap** at the bottom.
## Quickstart
```bash
cd e2e
npm install
npm run install:browsers # one-time: ~500 MB across chromium/firefox/webkit
# 1. Boot the test stack (rebuilds backend + frontend Docker images)
npm run stack:up
# 2. Wait ~20s for migrations + warmup, then run tests
npm run test:e2e # full Phase 1 suite on chromium-desktop
npm run test:e2e:smoke # cross-UA smoke matrix (~9 projects × 1 test)
npm run test:e2e:ui # interactive Playwright UI mode
# 3. After: tear the stack down (deletes volumes)
npm run stack:down
```
The CI workflow at `.github/workflows/e2e.yml` runs both jobs on every PR.
## What's tested
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
or a security/chaos scenario. One folder per area:
| Folder | Phase | Journeys / Topic | Tests | Notes |
|---|---|---|---|---|
| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. |
| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. |
| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. |
### Phase 2 — adversarial (`specs/07-adversarial/`)
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
+ four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
caption overflow). Asserts `window.__xssFired` never gets set and no
`dialog` event fires.
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
payload sits in localStorage as the user's display name, rendering through
`/account` keeps it as text.
- **`file-upload-attacks.spec.ts`** — 9 tests. ELF body claimed as JPEG,
oversize image vs `max_image_size_mb`, zero-byte, missing file field,
path-traversal filename, NUL filename, `application/*` declared category
bypass, SVG-with-script.
- **`auth-tampering.spec.ts`** — 8 tests. `alg:none` forging admin role,
signature tamper, payload tamper with original signature, logged-out
session reuse, header without `Bearer `, missing Authorization,
PIN brute-force lockout, admin password brute-force (documented finding —
no lockout today, bcrypt cost is the only defense).
- **`authorization-deep.spec.ts`** — 6 tests. Cross-user comment delete,
banned user across like/comment/feed-read, host→admin escalation attempts.
- **`ddos.spec.ts`** — 4 small-scale abuse tests. 20 parallel /join, 10 MB
comment body, 10 concurrent SSE streams, malformed JSON.
### Phase 2 — browser chaos (`specs/08-browser-chaos/`)
- **`storage-purge.spec.ts`** — 5 tests. `localStorage.clear()` mid-session,
cookies cleared (JWT in localStorage still works), sessionStorage cleared,
admin force-relogin, PIN intentionally survives clearAuth.
- **`indexeddb.spec.ts`** — 2 tests. Drop all IDB databases mid-session;
stub IDB to undefined before navigation.
- **`offline-network.spec.ts`** — 4 tests. `setOffline(true)` → reconnect,
slow-3G via `page.route` delay, intermittent 503s, 429 from server (no
infinite retry storm).
- **`multi-tab.spec.ts`** — 3 tests. Same user two tabs, two users two
contexts (storage isolated), logout in tab A doesn't sync to tab B
(documented gap).
- **`environment.spec.ts`** — 5 tests. JS disabled, localStorage quota
exhausted, hostile CSS hiding nav, clock skew ±1h / -2d.
Pending tests covering features that need a Node-side multipart upload helper
are marked `test.fixme` and will activate when that helper lands.
## Browser & UA matrix
| Project | Engine | UA / Device | Why |
|---|---|---|---|
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
Only the `@smoke` happy-path runs across all projects (controlled by
`grep` in `playwright.config.ts`). The full Phase 1 suite is
`chromium-desktop`-only by default to keep CI under 15 min.
### Samsung Internet — three escalation tiers
Samsung Internet ships on every Galaxy phone (~5% of mobile traffic in DE).
It's **Blink-based**, so Tier-A catches ~90% of regressions. Real Samsung
divergences (Smart Switch save-data mode, dark-mode injection, custom
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
- **Tier A** *(this repo, free, in CI)*: Playwright Chromium with the
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
project in `playwright.config.ts`.
- **Tier B** *(free, manual, future)*: Android Studio emulator on Linux →
install Samsung Internet APK → enable `--remote-debugging-port=9222`
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
`docs/samsung-emulator.md` (to be written).
- **Tier C** *(paid, optional)*: BrowserStack or LambdaTest cloud devices.
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
## Test isolation
Every test runs against a **freshly truncated database**:
1. `global-setup.ts` waits for `/health`, logs in admin, and disables every
rate-limit and quota toggle via `PATCH /admin/config`.
2. The auto-fixture `truncate` in `fixtures/test.ts` calls
`POST /api/v1/admin/__truncate` before every test.
3. The truncate endpoint is only registered when the backend is started
with `EVENTSNAP_TEST_MODE=1` (see `backend/src/main.rs` and
`backend/src/handlers/test_admin.rs`). Production builds return 404.
Single-worker by design (`workers: 1` in the config). Per-worker isolated
DBs are a Phase-2+ change.
## Architecture
```
e2e/
├── docker-compose.test.yml # Isolated test stack: db :55432, caddy :3101
├── Caddyfile.test # Proxies /api/* /media/* /health to backend
├── playwright.config.ts # UA matrix + smoke grep
├── global-setup.ts # admin login, rate-limit disable
├── global-teardown.ts # (no-op; use `npm run stack:down`)
├── fixtures/
│ ├── api-client.ts # Typed wrapper over /api/v1/*
│ ├── db.ts # Direct Postgres escape hatch (locked-PIN, etc.)
│ ├── test.ts # Central test.extend (guest, host, signIn fixtures)
│ └── media/ # sample.jpg, sample.mp4, not-an-image.jpg
├── helpers/
│ ├── sse-listener.ts # Async SSE iterator with waitForEvent()
│ ├── storage-helpers.ts # localStorage/sessionStorage helpers
│ └── fake-media.ts # Camera permissions (Chromium only)
├── page-objects/
│ ├── join-page.ts # /join
│ ├── recover-page.ts # /recover
│ ├── admin-login-page.ts # /admin/login
│ ├── feed-page.ts # /feed + bottom nav
│ ├── upload-sheet.ts # UploadSheet.svelte + /upload
│ ├── lightbox.ts # LightboxModal.svelte
│ ├── account-page.ts # /account
│ ├── host-dashboard.ts # /host
│ ├── admin-dashboard.ts # /admin
│ └── export-page.ts # /export
└── specs/
├── __smoke/ # @smoke cross-UA matrix (1 spec)
├── 01-auth/
├── 02-upload/
├── 03-feed/
├── 04-host/
├── 05-admin/
└── 06-export/
```
## Debugging a failure
- `npm run test:e2e:ui` — interactive UI with time-travel and selector probe.
- `npm run test:e2e:headed` — watch the browser run live.
- `npm run test:e2e:debug` — Playwright inspector with breakpoints.
- `npm run stack:logs` — tail backend + Postgres logs during a failure.
- `playwright-report/index.html` — opens the HTML report (auto-generated on every run).
- Trace files (`test-results/**/trace.zip`) drag-and-drop into `https://trace.playwright.dev`.
## Conventions
- **One assertion per `expect`**. Bundling multiple expects in one statement
loses the line-level failure context.
- **Wait on data, not time**. Use `expect.poll` for DB checks; never `waitForTimeout` in production specs.
- **`@smoke` tag** on each suite's happiest path so the matrix run stays under 2 min.
- **`test.fixme`** for features that need infrastructure not yet built (Node-side multipart upload helper, real video fixtures, etc.). Fixme tests don't fail the suite but show up in the report.
- **Page objects own selectors**. Specs never use raw locators.
- **German text in assertions** is fine — it's not going to change frequently. When it does, the page object is the only file to update.
## Roadmap
### Phase 2 — Adversarial & browser chaos ✅ landed
See the **What's tested** table above and the per-file breakdown.
Known findings surfaced (documented in tests, not silent failures):
1. `/admin/login` has no rate-limit or lockout — bcrypt cost is the only defense.
2. `localStorage` 'storage' event is not listened for, so logout in tab A
doesn't synchronously sign out tab B (the next 401 from any API call
clears it).
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
+ CSP on `/media/*` if SVGs are ever expected as user content.
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
Runs only on the `chromium-mobile` project (Pixel 7 device descriptor with
`hasTouch` and `isMobile`). The `chromium-desktop` project explicitly
ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config.ts).
- **`touch-targets.spec.ts`** — 4 tests. Audits ≥ 44×44 px on bottom nav,
FAB, join submit, admin-login submit, PIN-modal buttons. Uses
`expect.soft` so a single failure surfaces the actual bounding-box
dimensions instead of stopping the suite.
- **`safe-area.spec.ts`** — 4 tests. Asserts `env(safe-area-inset-bottom)`
is present in the inline style of every bottom-anchored UI element
(bottom nav, UploadSheet, ContextSheet), and that the nav stays flush
with the viewport bottom on a no-notch emulated device.
- **`gestures-longpress.spec.ts`** — 3 tests. A 600 ms hold on a
FeedListCard opens the ContextSheet; a 200 ms tap does not; the
click-suppression logic prevents the lightbox from also opening at
pointer-up. Driven via `page.mouse.down/up` because the `longpress`
action listens for pointer events (mouse/touch/pen unified).
- **`gestures-doubletap.spec.ts`** — 2 tests. Double-tap on a feed card
image button records a like; double-tap inside the lightbox triggers
the heart-burst animation and records a like. Assertions read the like
count back via `/api/v1/feed` so they don't couple to specific badge
markup.
- **`viewport-reflow.spec.ts`** — 5 tests. Portrait, landscape, narrow
(320×568), phablet (480×1024) — each asserts the bottom nav is
visible, the FAB stays roughly centered, and there's no horizontal
overflow on `<html>`. Plus a rotation test that confirms auth survives
a viewport resize.
- **`planned-gestures.spec.ts`** — 5 **`test.fixme`** stubs documenting
the contracts for gestures from journey §17 that aren't shipped yet
(lightbox swipe L/R, swipe-down to dismiss UploadSheet,
pull-to-refresh, long-press on a comment). Flip `test.fixme` to `test`
when wiring each gesture.
#### Driving gestures: the `helpers/touch.ts` module
- `longPress(page, locator, durationMs)` — holds the pointer down for
the duration. Default 600 ms beats the action's 500 ms threshold.
- `doubleTap(page, locator)` — two `mouse.down/up` pairs within the
`doubletap` action's 300 ms window.
- `swipe(page, from, to, steps)` — gradual mouse-driven move (used by
the fixme stubs once swipe gestures land).
- `inlineStyle(locator)` / `computedStyle(locator, prop)` — read raw
`style` attributes (where `env(...)` strings live) and computed
values.
### Phase 3 — Real-device compat & visual / a11y (not landed)
- Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
- Safe-area inset visual diff on iPhone notch.
- Touch-target ≥ 44 px audit.
- Tier B Samsung Internet via `connectOverCDP` on Android Studio emulator.
- Tier C BrowserStack integration (paid, optional).
- `@axe-core/playwright` accessibility audits.
- Visual regression with screenshot diffs.
### Out of scope (handed to other tools)
- Load testing → k6 / Vegeta.
- API contract testing → backend `cargo test` integration tests.
- Static asset auditing → Lighthouse CI.

View File

@@ -0,0 +1,77 @@
# Isolated EventSnap test stack. Mirrors production layout (Caddy → frontend +
# backend) but on its own ports, its own volumes, with rate-limits disabled and
# `EVENTSNAP_TEST_MODE=1` so the `/admin/__truncate` reset endpoint is live.
#
# Bring it up once before running the suite:
# npm run stack:up
# Tear it down (and wipe all volumes) after:
# npm run stack:down
#
# Port 3101 is the only externally exposed port: Caddy fronts everything.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: eventsnap_test
POSTGRES_PASSWORD: eventsnap_test
POSTGRES_DB: eventsnap_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U eventsnap_test -d eventsnap_test"]
interval: 3s
timeout: 3s
retries: 30
ports:
- "55432:5432" # exposed so the e2e harness can connect via pg for fixture setup
app:
build:
context: ../backend
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://eventsnap_test:eventsnap_test@db:5432/eventsnap_test
JWT_SECRET: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
# bcrypt hash for the literal string "admin-test-pw" (cost 4 — fast for tests).
# Generated once and committed. Verified with `bcrypt.compareSync`.
# The $ characters are doubled to escape compose interpolation.
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
EVENT_SLUG: e2e-test-event
EVENT_NAME: E2E Test Event
APP_PORT: "3000"
MEDIA_PATH: /media
SESSION_EXPIRY_DAYS: "30"
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
RUST_LOG: eventsnap_backend=info,tower_http=warn
volumes:
- media_data:/media
expose:
- "3000"
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
depends_on:
- app
environment:
PORT: "3001"
HOST: "0.0.0.0"
ORIGIN: "http://localhost:3101"
expose:
- "3001"
caddy:
image: caddy:2-alpine
depends_on:
- app
- frontend
volumes:
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
ports:
- "3101:3101"
volumes:
media_data:

152
e2e/fixtures/api-client.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Tiny typed wrapper around the EventSnap REST API for use inside tests.
* Used to seed data far faster than driving the UI through every join /
* upload, and to set up adversarial states (banned users, locked PINs) that
* the UI cannot reach.
*
* Auth: pass `token` on individual calls; no global state.
*/
export const ADMIN_PASSWORD = 'admin-test-pw';
export class ApiClient {
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
private async request<T>(
method: string,
path: string,
opts: { token?: string; body?: unknown; expectedStatus?: number | number[] } = {}
): Promise<{ status: number; body: T }> {
const headers: Record<string, string> = {};
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
const expected = opts.expectedStatus ?? [200, 201, 204];
const allowed = Array.isArray(expected) ? expected : [expected];
let body: unknown = undefined;
if (res.status !== 204) {
const text = await res.text();
try {
body = text.length > 0 ? JSON.parse(text) : undefined;
} catch {
body = text;
}
}
if (!allowed.includes(res.status)) {
throw new Error(
`API ${method} ${path}${res.status} (expected ${allowed.join('/')}). Body: ${JSON.stringify(body)}`
);
}
return { status: res.status, body: body as T };
}
// ── Auth ───────────────────────────────────────────────────────────────
async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
const { body } = await this.request<any>('POST', '/join', {
body: { display_name: displayName },
expectedStatus: [201],
});
return body;
}
async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) {
return this.request<any>('POST', '/recover', {
body: { display_name: displayName, pin },
expectedStatus: opts.expectedStatus ?? [200],
});
}
async adminLogin(password: string = ADMIN_PASSWORD): Promise<string> {
const { body } = await this.request<{ jwt: string }>('POST', '/admin/login', {
body: { password },
});
return body.jwt;
}
async logout(token: string) {
return this.request<void>('DELETE', '/session', { token, expectedStatus: [204] });
}
// ── Test-mode helpers ──────────────────────────────────────────────────
async truncate(adminToken: string) {
return this.request<void>('POST', '/admin/__truncate', {
token: adminToken,
expectedStatus: [204],
});
}
// ── Config ─────────────────────────────────────────────────────────────
async patchConfig(adminToken: string, patch: Record<string, string>) {
return this.request<void>('PATCH', '/admin/config', {
token: adminToken,
body: patch,
expectedStatus: [204],
});
}
async getConfig(adminToken: string): Promise<Record<string, string>> {
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', { token: adminToken });
return body;
}
// ── Host moderation ────────────────────────────────────────────────────
async listUsers(token: string) {
const { body } = await this.request<any[]>('GET', '/host/users', { token });
return body;
}
async setRole(token: string, userId: string, role: 'guest' | 'host') {
return this.request<void>('PATCH', `/host/users/${userId}/role`, {
token,
body: { role },
expectedStatus: [200, 204],
});
}
async banUser(token: string, userId: string, hideUploads = false) {
return this.request<void>('POST', `/host/users/${userId}/ban`, {
token,
body: { hide_uploads: hideUploads },
expectedStatus: [200, 204],
});
}
async closeEvent(token: string) {
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
}
async openEvent(token: string) {
return this.request<void>('POST', '/host/event/open', { token, expectedStatus: [200, 204] });
}
// ── Feed ───────────────────────────────────────────────────────────────
async getFeed(token: string) {
const { body } = await this.request<any>('GET', '/feed', { token });
return body;
}
async getStats(adminToken: string) {
const { body } = await this.request<any>('GET', '/admin/stats', { token: adminToken });
return body;
}
// ── Health ─────────────────────────────────────────────────────────────
async waitForHealth(retries = 60): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(`${this.baseUrl}/health`);
if (res.ok) return;
} catch {
/* keep retrying */
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Backend never became healthy at ${this.baseUrl}/health`);
}
}

84
e2e/fixtures/db.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* Direct PostgreSQL escape hatch for setting up states the public API doesn't
* expose — e.g. forcing a user into the locked-PIN state to assert the 429
* recovery path, or expiring sessions for chaos tests.
*
* Most tests should NOT use this: prefer `ApiClient` so the tests exercise
* the same code paths real users do. Reach for direct SQL only when the API
* can't get you where you need to go.
*/
import { Client } from 'pg';
const CONN = {
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 withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
const client = new Client(CONN);
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
export const db = {
async lockUserPin(userId: string, minutesFromNow = 15) {
await withClient((c) =>
c.query(
`UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
[userId, String(minutesFromNow)]
)
);
},
async expireSession(userId: string) {
await withClient((c) =>
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [userId])
);
},
async setUploadCompressionStatus(uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed') {
await withClient((c) =>
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
);
},
async countUploadsForUser(userId: string): Promise<number> {
return withClient(async (c) => {
const r = await c.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
[userId]
);
return Number(r.rows[0].count);
});
},
async setExportReleased(slug: string, released: boolean) {
await withClient((c) =>
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
slug,
released ? new Date() : null,
])
);
},
/** 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) => {
const ev = await c.query<{ id: string }>(`SELECT id FROM event WHERE slug = $1`, [eventSlug]);
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
await c.query(
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at)
VALUES ($1, $2::export_type, $3::export_status, $4, $5)
ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct`,
[ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null]
);
});
},
};

103
e2e/fixtures/test.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Central `test.extend` for EventSnap E2E tests. Specs import `test` from here
* (not `@playwright/test` directly) so they get the shared fixtures: API
* client, DB helper, fresh admin token, and convenience factories.
*
* Every test runs against a freshly truncated database (via the `truncate`
* auto-fixture). Because truncate wipes the `session` table, any admin JWT
* obtained before truncate becomes invalid afterwards — so the
* `truncate` fixture always does its own admin login, and the `adminToken`
* fixture re-logs-in per test instead of caching. bcrypt cost 4 → ~10 ms,
* negligible against the ~1 s per-test setup overhead.
*/
import { test as base, expect, type Page } from '@playwright/test';
import { ApiClient } from './api-client';
import { db } from './db';
type GuestHandle = {
jwt: string;
pin: string;
userId: string;
displayName: string;
};
type Fixtures = {
api: ApiClient;
db: typeof db;
adminToken: string;
truncate: void;
guest: (displayName?: string) => Promise<GuestHandle>;
host: GuestHandle;
/** Apply an existing guest's JWT + PIN to the page's localStorage and reload. */
signIn: (page: Page, handle: GuestHandle) => Promise<void>;
};
export const test = base.extend<Fixtures>({
api: async ({}, use) => {
await use(new ApiClient());
},
db: async ({}, use) => {
await use(db);
},
// Auto-fixture: runs before every test, truncates the DB so each test starts
// clean. Acquires its OWN admin token because the previous test's truncate
// wiped the session row that backs any cached token.
truncate: [
async ({ api }, use) => {
const token = await api.adminLogin();
await api.truncate(token);
await use();
},
{ auto: true, scope: 'test' },
],
// Fresh admin login per test that asks for it. Comes AFTER the truncate
// auto-fixture has run (truncate doesn't depend on adminToken, so the
// dependency-free truncate runs first; this fixture then logs in on a
// freshly reset DB).
adminToken: async ({ api }, use) => {
const token = await api.adminLogin();
await use(token);
},
guest: async ({ api }, use) => {
let counter = 0;
const factory = async (displayName?: string): Promise<GuestHandle> => {
const name = displayName ?? `Gast${++counter}_${Math.random().toString(36).slice(2, 6)}`;
const res = await api.join(name);
return { jwt: res.jwt, pin: res.pin, userId: res.user_id, displayName: name };
};
await use(factory);
},
host: async ({ api, guest, adminToken }, use) => {
const h = await guest('TestHost');
await api.setRole(adminToken, h.userId, 'host');
// Role is encoded in the JWT — re-recover to get a fresh token with role=host.
const { body } = await api.recover(h.displayName, h.pin);
await use({ ...h, jwt: body.jwt });
},
signIn: async ({}, use) => {
const fn = async (page: Page, handle: GuestHandle) => {
// Visit any in-app URL first so localStorage is scoped to the right origin.
await page.goto('/');
await page.evaluate(
({ jwt, pin, userId, displayName }) => {
localStorage.setItem('eventsnap_jwt', jwt);
localStorage.setItem('eventsnap_pin', pin);
localStorage.setItem('eventsnap_user_id', userId);
localStorage.setItem('eventsnap_display_name', displayName);
localStorage.setItem('eventsnap_guide_seen', 'true');
},
handle
);
await page.goto('/feed');
};
await use(fn);
},
});
export { expect };

47
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Runs once before all tests. Waits for the test stack to be healthy, logs
* in as admin, and flips all rate-limit/quota toggles off so tests don't
* trip over them. Individual tests that *want* to assert rate-limit
* behaviour re-enable the relevant flags in `beforeAll` and restore them
* in `afterAll`.
*
* The admin token is written to `.cache/admin-token` so per-worker
* fixtures can read it instead of logging in repeatedly.
*/
import { ApiClient } from './fixtures/api-client';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const CACHE_DIR = join(process.cwd(), '.cache');
export default async function globalSetup() {
const api = new ApiClient();
console.log('[e2e] waiting for backend health…');
await api.waitForHealth(120);
console.log('[e2e] logging in admin…');
const adminToken = await api.adminLogin();
console.log('[e2e] resetting database…');
await api.truncate(adminToken);
// Re-login because truncate wiped the session row backing the previous token.
const freshAdminToken = await api.adminLogin();
console.log('[e2e] disabling rate limits & quotas for the test run…');
await api.patchConfig(freshAdminToken, {
rate_limits_enabled: 'false',
upload_rate_enabled: 'false',
feed_rate_enabled: 'false',
export_rate_enabled: 'false',
join_rate_enabled: 'false',
quota_enabled: 'false',
storage_quota_enabled: 'false',
upload_count_quota_enabled: 'false',
});
await mkdir(CACHE_DIR, { recursive: true });
await writeFile(join(CACHE_DIR, 'admin-token'), freshAdminToken, 'utf8');
console.log('[e2e] global setup complete');
}

8
e2e/global-teardown.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Runs once after all tests. Currently a no-op: we leave the test stack
* running so the developer can inspect the post-run state (open the report,
* tail logs). Use `npm run stack:down` to actually tear the volumes down.
*/
export default async function globalTeardown() {
// intentionally empty
}

15
e2e/helpers/fake-media.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Camera/microphone faking. Chromium has the `--use-fake-device-for-media-stream`
* launch arg (set in playwright.config.ts) which makes `getUserMedia` succeed
* with a generated green-rectangle stream. WebKit and Firefox do not — those
* UA projects skip camera-path tests entirely.
*/
import type { BrowserContext } from '@playwright/test';
export async function grantCameraPermissions(context: BrowserContext, origin: string) {
await context.grantPermissions(['camera', 'microphone'], { origin });
}
export function isFakeMediaSupported(browserName: string): boolean {
return browserName === 'chromium';
}

View File

@@ -0,0 +1,88 @@
/**
* Subscribe to /api/v1/stream and collect events. Tests can then assert "a
* `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).
*/
export type SseEvent = { type: string; data: any; receivedAt: number };
export class SseListener {
private controller = new AbortController();
private events: SseEvent[] = [];
private closed = false;
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)}`;
// 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');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
(async () => {
try {
while (!this.closed) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE messages are separated by \n\n
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const evt = parseSseMessage(part);
if (evt) this.events.push({ ...evt, receivedAt: Date.now() });
}
}
} catch {
/* aborted */
}
})();
}
async waitForEvent(
type: string,
predicate: (e: SseEvent) => boolean = () => true,
timeoutMs = 10_000
): Promise<SseEvent> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const hit = this.events.find((e) => e.type === type && predicate(e));
if (hit) return hit;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`);
}
allEvents(): SseEvent[] {
return [...this.events];
}
stop(): void {
this.closed = true;
this.controller.abort();
}
}
function parseSseMessage(raw: string): { type: string; data: any } | null {
const lines = raw.split('\n');
let type = 'message';
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith('event:')) type = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) return null;
const dataStr = dataLines.join('\n');
let data: any = dataStr;
try {
data = JSON.parse(dataStr);
} catch {
/* leave as string */
}
return { type, data };
}

View File

@@ -0,0 +1,46 @@
/**
* Helpers for inspecting and manipulating browser storage from Playwright.
* Centralised here so the storage-key strings match auth.ts in one place.
*/
import type { Page } from '@playwright/test';
export const STORAGE_KEYS = {
jwt: 'eventsnap_jwt',
pin: 'eventsnap_pin',
userId: 'eventsnap_user_id',
displayName: 'eventsnap_display_name',
guideSeen: 'eventsnap_guide_seen',
} as const;
export async function readStorage(page: Page) {
return page.evaluate((keys) => {
const out: Record<string, string | null> = {};
for (const [name, key] of Object.entries(keys)) {
out[name] = localStorage.getItem(key as string);
}
return out;
}, STORAGE_KEYS);
}
export async function clearLocalStorage(page: Page) {
if (page.url() === 'about:blank') await page.goto('/');
await page.evaluate(() => localStorage.clear());
}
export async function clearSessionStorage(page: Page) {
if (page.url() === 'about:blank') await page.goto('/');
await page.evaluate(() => sessionStorage.clear());
}
export async function clearAllStorage(page: Page) {
// localStorage is only accessible on a real origin — about:blank throws SecurityError.
// Navigate to the app origin first if we're on the blank starting page.
if (page.url() === 'about:blank') {
await page.goto('/');
}
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.context().clearCookies();
}

103
e2e/helpers/touch.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Touch-gesture primitives for Phase 3 mobile specs.
*
* The app uses two custom Svelte actions:
*
* 1. `longpress` — listens for `pointerdown`, starts a 500 ms timer,
* cancels on pointerup / pointermove > 10 px / pointercancel.
* [frontend/src/lib/actions/longpress.ts]
*
* 2. `doubletap` — listens for `pointerup` and times consecutive
* releases within 300 ms on roughly the same coordinate.
* [frontend/src/lib/actions/doubletap.ts]
*
* Both listen to pointer events, which fire from mouse, touch, and pen
* input. We drive them with `page.mouse` because it works identically
* across all Playwright engines and respects the project's mobile
* viewport — the action doesn't care whether the underlying device was
* touch or mouse.
*/
import type { Locator, Page } from '@playwright/test';
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox();
if (!box) throw new Error(`Cannot get bounding box for locator (not visible?)`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** Hold the pointer down on `locator` for `durationMs`. Default 600 ms beats the 500 ms long-press threshold. */
export async function longPress(page: Page, locator: Locator, durationMs = 600) {
const { x, y } = await centerOf(locator);
await page.mouse.move(x, y);
await page.mouse.down();
await page.waitForTimeout(durationMs);
await page.mouse.up();
}
/**
* Two rapid pointer-event pairs within the doubletap action's 300 ms window.
*
* Dispatches synthetic `pointerdown`/`pointerup` directly on the element via
* `page.evaluate` (not via `page.mouse`). Why: with real mouse events, the
* first tap fires the element's `onclick` synchronously — which on a
* FeedListCard image button means the lightbox opens *before* the second tap
* lands. That makes mouse-driven double-tap impossible to test in headless
* Chromium. Dispatching pointer events bypasses the synthetic click pipeline
* entirely, so we can exercise the `doubletap` action's contract directly.
*/
export async function doubleTap(page: Page, locator: Locator) {
await locator.evaluate((el: HTMLElement) => {
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const mk = (type: string) =>
new PointerEvent(type, {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
pointerType: 'touch',
isPrimary: true,
});
el.dispatchEvent(mk('pointerdown'));
el.dispatchEvent(mk('pointerup'));
// Second tap < 300 ms later.
return new Promise<void>((resolve) =>
setTimeout(() => {
el.dispatchEvent(mk('pointerdown'));
el.dispatchEvent(mk('pointerup'));
resolve();
}, 50)
);
});
}
/** Touch swipe from start to end coordinates via the touchscreen API (mobile viewports). */
export async function swipe(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
steps = 10
) {
// page.touchscreen.tap exists but doesn't expose move; fall back to dispatching
// raw touch events via page.evaluate when needed. For now, mouse-based pointer
// events are equivalent because the app's planned swipe handlers (when they
// land) will use pointer events too.
await page.mouse.move(from.x, from.y);
await page.mouse.down();
for (let i = 1; i <= steps; i++) {
const t = i / steps;
await page.mouse.move(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
}
await page.mouse.up();
}
/** Read computed style on a locator. Useful for asserting `padding-bottom` includes env(safe-area-inset-bottom). */
export async function computedStyle(locator: Locator, prop: string): Promise<string> {
return locator.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop);
}
/** Read the raw inline `style` attribute (env() vars expand only inside computed style, not here). */
export async function inlineStyle(locator: Locator): Promise<string> {
return locator.evaluate((el) => (el as HTMLElement).getAttribute('style') ?? '');
}

View File

@@ -0,0 +1,48 @@
/**
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
* driving the UI. Crucial for MIME-spoofing, oversize, polyglot, and
* filename-injection tests.
*
* Field shape matches [backend/src/handlers/upload.rs]:
* - file (binary; carries filename + content_type in the part headers)
* - caption (text, optional)
* - hashtags (CSV text, optional)
*/
// Node 22+ ships FormData and Blob as globals — no import needed.
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
export type UploadOptions = {
filename?: string;
contentType?: string;
caption?: string;
hashtags?: string;
};
export async function uploadRaw(token: string, body: Uint8Array | Buffer, opts: UploadOptions = {}) {
const form = new FormData();
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
form.append('file', blob as any, opts.filename ?? 'upload.bin');
if (opts.caption !== undefined) form.append('caption', opts.caption);
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
return fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form as any,
});
}
/** Convenience: read a fixture from disk and upload it. */
export async function uploadFile(token: string, path: string, opts: UploadOptions = {}) {
const { readFile } = await import('node:fs/promises');
const body = await readFile(path);
return uploadRaw(token, body, opts);
}
/** Tiny valid JPEG header — magic bytes only, useful for "claim image but is N MB of zeros" tests. */
export const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
/** Tiny valid PNG magic bytes. */
export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */
export const ELF_MAGIC = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);

286
e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,286 @@
{
"name": "eventsnap-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eventsnap-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^22.10.0",
"@types/pg": "^8.11.10",
"pg": "^8.13.1",
"typescript": "^5.7.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

26
e2e/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "eventsnap-e2e",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Playwright E2E suite for EventSnap. Spins up an isolated docker-compose test stack and exercises the SvelteKit frontend end-to-end.",
"scripts": {
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test --grep @smoke",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "PWDEBUG=1 playwright test",
"test:e2e:report": "playwright show-report",
"stack:up": "docker compose -f docker-compose.test.yml up -d --build",
"stack:down": "docker compose -f docker-compose.test.yml down -v",
"stack:logs": "docker compose -f docker-compose.test.yml logs -f",
"install:browsers": "playwright install --with-deps chromium firefox webkit"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^22.10.0",
"pg": "^8.13.1",
"@types/pg": "^8.11.10",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,29 @@
import type { Page, Locator } from '@playwright/test';
export class AccountPage {
readonly page: Page;
readonly displayName: Locator;
readonly pinDisplay: Locator;
readonly leaveButton: Locator;
readonly leaveConfirmButton: Locator;
readonly privacyNote: Locator;
constructor(page: Page) {
this.page = page;
this.displayName = page.locator('[data-testid="account-display-name"]');
this.pinDisplay = page.locator('[data-testid="account-pin"]');
this.leaveButton = page.getByRole('button', { name: /event verlassen/i });
this.leaveConfirmButton = page.getByRole('button', { name: /^abmelden$/i });
this.privacyNote = page.locator('[data-testid="privacy-note"]');
}
async goto() {
await this.page.goto('/account');
}
async leaveEvent() {
await this.leaveButton.click();
await this.leaveConfirmButton.click();
await this.page.waitForURL('**/join');
}
}

View File

@@ -0,0 +1,33 @@
import type { Page, Locator } from '@playwright/test';
export class AdminDashboard {
readonly page: Page;
readonly tabStats: Locator;
readonly tabConfig: Locator;
readonly tabExport: Locator;
readonly tabUsers: Locator;
readonly userCount: Locator;
readonly uploadCount: Locator;
readonly imageSizeInput: Locator;
readonly videoSizeInput: Locator;
readonly privacyNoteTextarea: Locator;
readonly saveConfigButton: Locator;
constructor(page: Page) {
this.page = page;
this.tabStats = page.getByRole('button', { name: /stats/i });
this.tabConfig = page.getByRole('button', { name: /config/i });
this.tabExport = page.getByRole('button', { name: /^export$/i });
this.tabUsers = page.getByRole('button', { name: /nutzer/i });
this.userCount = page.locator('[data-testid="stat-user-count"]');
this.uploadCount = page.locator('[data-testid="stat-upload-count"]');
this.imageSizeInput = page.locator('input[name="max_image_size_mb"]');
this.videoSizeInput = page.locator('input[name="max_video_size_mb"]');
this.privacyNoteTextarea = page.locator('textarea[name="privacy_note"]');
this.saveConfigButton = page.getByRole('button', { name: /speichern/i }).first();
}
async goto() {
await this.page.goto('/admin');
}
}

View File

@@ -0,0 +1,24 @@
import type { Page, Locator } from '@playwright/test';
export class AdminLoginPage {
readonly page: Page;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.passwordInput = page.getByTestId('admin-password-input');
this.submitButton = page.getByTestId('admin-login-submit');
this.errorMessage = page.getByTestId('admin-login-error');
}
async goto() {
await this.page.goto('/admin/login');
}
async login(password: string) {
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,21 @@
import type { Page, Locator } from '@playwright/test';
export class ExportPage {
readonly page: Page;
readonly notAvailableBanner: Locator;
readonly zipDownloadButton: Locator;
readonly htmlDownloadButton: Locator;
readonly htmlGuideModalContinue: Locator;
constructor(page: Page) {
this.page = page;
this.notAvailableBanner = page.locator('[data-testid="export-not-available"]');
this.zipDownloadButton = page.getByRole('button', { name: /zip.*herunter|herunter.*zip/i }).first();
this.htmlDownloadButton = page.getByRole('button', { name: /html.*herunter|herunter.*html/i }).first();
this.htmlGuideModalContinue = page.getByRole('button', { name: /^herunterladen$/i });
}
async goto() {
await this.page.goto('/export');
}
}

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