38 Commits
v0.6.0 ... main

Author SHA1 Message Date
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
202 changed files with 18515 additions and 534 deletions

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

9
.gitignore vendored
View File

@@ -8,10 +8,19 @@ backend/target/
frontend/node_modules/ frontend/node_modules/
frontend/.svelte-kit/ frontend/.svelte-kit/
frontend/build/ frontend/build/
frontend/export-viewer/node_modules/
frontend/export-viewer/.svelte-kit/
# Media uploads (mounted volume in production) # Media uploads (mounted volume in production)
media/ 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 # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

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. Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
### Status ### 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) │ │ Axum HTTP Server (Rust — Single Binary) │
│ │ │ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │ │ │ REST API │ │ SSE Engine │ │ Media Static Server │ │
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │ │ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
│ │ │ │ stream │ │ output, embedded) │ │ │ │ │ │ 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 ### 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 ```yaml
services: services:
app: db: # postgres:16-alpine, persisted in postgres_data volume
build: ./backend # Multi-stage Rust Dockerfile app: # ./backend — Rust API on :3000, mounts media_data:/media
env_file: .env frontend: # ./frontend — SvelteKit (adapter-node) on :3001
depends_on: [db] caddy: # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
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:
``` ```
### Caddyfile ### Caddyfile
@@ -345,11 +323,14 @@ COMPRESSION_WORKER_CONCURRENCY=2
|-------|---------|---------| |-------|---------|---------|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete | | `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted | | `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 | | `upload-deleted` | `{ upload_id }` | Upload deleted |
| `event-closed` | `{}` | Host locks uploads | | `event-closed` | `{}` | Host locks uploads |
| `event-opened` | `{}` | Host unlocks uploads | | `event-opened` | `{}` | Host unlocks uploads |
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete | | `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=` **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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Role | Permissions |
|------|------------| |------|------------|
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) | | 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, lock/unlock uploads, release gallery export | | 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 + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation | | 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 | | Banned Guest | View feed only — cannot upload, like, comment, or export |
### Compliance ### Compliance
@@ -473,37 +454,33 @@ Full-quality originals only. File naming: `{date}_{time}_{username}_{original_fi
### Export Type 2: HTML Offline Viewer (`Memories.zip`) ### 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/
Memories.html ← single entry point (all CSS + JS inlined; no external deps) index.html ← entry point; open this in any browser
README.txt ← plain-text setup guide (in German, as the UI language) _app/
Photos/ ... immutable/... ← hashed JS/CSS bundles (viewer SPA)
Videos/ ... 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): **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.
```
Willkommen in der Event-Galerie!
So geht's: **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.
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.
Viel Freude mit den Erinnerungen! 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.")
```
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.")
--- ---
@@ -625,7 +602,9 @@ CREATE TABLE "user" (
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN) recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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'), ('export_rate_per_day', '3'),
('quota_tolerance', '0.75'), ('quota_tolerance', '0.75'),
('estimated_guest_count', '100'), ('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; ON CONFLICT (key) DO NOTHING;
``` ```
@@ -1173,7 +1163,11 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
| Decision | Chosen | Rationale | | 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 | | 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 | | 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 | | 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) | | `uuid` | UUID v7 (time-sortable) |
| `serde` / `serde_json` | Serialisation | | `serde` / `serde_json` | Serialisation |
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) | | `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 | | `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
| `async-zip` | Streaming ZIP export (no in-memory buffer) | | `async-zip` | Streaming ZIP export (no in-memory buffer) |
| `minijinja` | HTML export template rendering (`Memories.html`) | | `minijinja` | HTML export template rendering (`Memories.html`) |

View File

@@ -150,12 +150,12 @@ See [.env.example](.env.example) for the full list with descriptions and default
┌───▼────┐ ┌───▼────┐
│ db │ │ db │
│ :5432 │ │ :5432 │
│(Postgres│ │(Postgres)
└────────┘ └────────┘
``` ```
- `/api/*` and `/media/*` → Rust backend - `/api/*` and `/media/*` → Rust backend
- Everything else → SvelteKit frontend - Everything else → SvelteKit frontend (`adapter-node`)
- Named volumes: `postgres_data`, `media_data`, `caddy_data` - 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 ## Development Roadmap
Done:
- [x] Project blueprint & architecture - [x] Project blueprint & architecture
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose) - [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
- [ ] DB schema + SQLx migrations - [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
- [ ] Auth flow (join, JWT, PIN recovery) - [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
- [ ] Upload pipeline (multipart → compression worker → SSE broadcast) - [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
- [ ] Client upload queue (IndexedDB, progress, retry) - [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
- [ ] Gallery feed (grid, SSE, hashtag filters) - [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
- [ ] Camera capture (`getUserMedia`) - [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
- [ ] Host Dashboard - [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
- [ ] Admin Dashboard - [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
- [ ] Export engine (ZIP + offline HTML) - [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
- [ ] Rate limiting middleware - [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
- [ ] End-to-end test event (10+ real devices) - [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", "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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -896,8 +907,9 @@ dependencies = [
"dotenvy", "dotenvy",
"futures", "futures",
"image", "image",
"include_dir",
"infer",
"jsonwebtoken", "jsonwebtoken",
"minijinja",
"oxipng", "oxipng",
"rand 0.9.2", "rand 0.9.2",
"serde", "serde",
@@ -907,6 +919,7 @@ dependencies = [
"sysinfo", "sysinfo",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower_governor", "tower_governor",
@@ -1003,6 +1016,12 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -1576,6 +1595,25 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" 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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -1589,6 +1627,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "infer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199"
dependencies = [
"cfb",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -1831,12 +1878,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1853,16 +1894,6 @@ dependencies = [
"unicase", "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]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"

View File

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

View File

@@ -1,5 +1,5 @@
# --- Build stage --- # --- 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 RUN apk add --no-cache musl-dev pkgconfig openssl-dev
@@ -11,6 +11,8 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
rm -rf src rm -rf src
COPY src ./src COPY src ./src
COPY static ./static
COPY migrations ./migrations
RUN touch src/main.rs && cargo build --release RUN touch src/main.rs && cargo build --release
# --- Runtime stage --- # --- Runtime stage ---

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,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::extract::State;
use axum::http::StatusCode; use axum::http::{HeaderMap, StatusCode};
use axum::Json; use axum::Json;
use chrono::{Duration, Utc}; use chrono::Utc;
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -12,6 +14,8 @@ use crate::error::AppError;
use crate::models::event::Event; use crate::models::event::Event;
use crate::models::session::Session; use crate::models::session::Session;
use crate::models::user::{User, UserRole}; use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState; use crate::state::AppState;
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -29,14 +33,35 @@ pub struct JoinResponse {
pub async fn join( pub async fn join(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<JoinRequest>, Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> { ) -> 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
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
{
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
let display_name = body.display_name.trim(); 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( return Err(AppError::BadRequest(
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(), "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( let event = Event::find_or_create(
&state.pool, &state.pool,
@@ -45,6 +70,14 @@ pub async fn join(
) )
.await?; .await?;
// Reject if a user with this name (case-insensitive) already exists
if User::name_taken(&state.pool, event.id, display_name).await? {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
// Generate a 4-digit PIN // Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = let pin_hash =
@@ -62,7 +95,7 @@ pub async fn join(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token); 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?; Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
Ok(( Ok((
@@ -90,10 +123,33 @@ pub struct RecoverResponse {
pub async fn recover( pub async fn recover(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RecoverRequest>, Json(body): Json<RecoverRequest>,
) -> Result<Json<RecoverResponse>, AppError> { ) -> Result<Json<RecoverResponse>, AppError> {
let display_name = body.display_name.trim(); 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) let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await? .await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
@@ -108,13 +164,20 @@ pub async fn recover(
} }
for user in &users { for user in &users {
// Check PIN lockout // Check PIN lockout. If the lockout has expired, also reset the failed-attempt
// counter so the user gets a fresh 3-strike window — otherwise the counter
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
// after waiting out the cooldown. Without this reset, a once-locked account
// is effectively permanently fragile.
if let Some(locked_until) = user.pin_locked_until { if let Some(locked_until) = user.pin_locked_until {
if Utc::now() < locked_until { if Utc::now() < locked_until {
return Err(AppError::TooManyRequests( return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte 15 Minuten.".into(), "Zu viele Versuche. Bitte warte 15 Minuten.".into(),
None,
)); ));
} }
// Lockout window expired — wipe the counter and the timestamp.
User::reset_pin_attempts(&state.pool, user.id).await?;
} }
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash) let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
@@ -134,7 +197,7 @@ pub async fn recover(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token); 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?; Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
return Ok(Json(RecoverResponse { return Ok(Json(RecoverResponse {
@@ -145,9 +208,22 @@ pub async fn recover(
// Wrong PIN — increment failure count // Wrong PIN — increment failure count
let attempts = User::increment_failed_pin(&state.pool, user.id).await?; 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 { if attempts >= 3 {
let lockout = Utc::now() + Duration::minutes(15); let lockout = Utc::now() + chrono::Duration::minutes(15);
User::lock_pin(&state.pool, user.id, lockout).await?; User::lock_pin(&state.pool, user.id, lockout).await?;
tracing::warn!(
user_id = %user.id,
event_id = %event.id,
ip = %ip,
"recover: account locked for 15 minutes"
);
} }
} }
@@ -166,6 +242,7 @@ pub struct AdminLoginResponse {
pub async fn admin_login( pub async fn admin_login(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<AdminLoginRequest>, Json(body): Json<AdminLoginRequest>,
) -> Result<Json<AdminLoginResponse>, AppError> { ) -> Result<Json<AdminLoginResponse>, AppError> {
if state.config.admin_password_hash.is_empty() { if state.config.admin_password_hash.is_empty() {
@@ -174,10 +251,31 @@ pub async fn admin_login(
)); ));
} }
// 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");
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 = bcrypt::verify(&body.password, &state.config.admin_password_hash) let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
.unwrap_or(false); .unwrap_or(false);
if !valid { if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password");
return Err(AppError::Unauthorized("Falsches Passwort.".into())); return Err(AppError::Unauthorized("Falsches Passwort.".into()));
} }
@@ -194,8 +292,13 @@ pub async fn admin_login(
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) { let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
u u
} else { } else {
// Create admin user with a dummy PIN (admin authenticates via password) // Admin authenticates via password, but the schema still requires a PIN
let dummy_hash = bcrypt::hash("0000", 4) // 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 = bcrypt::hash(&dummy_pin, 4)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?; let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1") sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
@@ -207,6 +310,8 @@ pub async fn admin_login(
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))? .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( let token = jwt::create_token(
admin_user.id, admin_user.id,
event.id, event.id,
@@ -217,7 +322,7 @@ pub async fn admin_login(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token); 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?; Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse { jwt: token })) Ok(Json(AdminLoginResponse { jwt: token }))

View File

@@ -13,6 +13,13 @@ pub struct Claims {
pub role: UserRole, pub role: UserRole,
pub exp: i64, pub exp: i64,
pub iat: 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( pub fn create_token(
@@ -29,6 +36,7 @@ pub fn create_token(
role, role,
iat: now.timestamp(), iat: now.timestamp(),
exp: (now + Duration::days(expiry_days)).timestamp(), exp: (now + Duration::days(expiry_days)).timestamp(),
jti: Uuid::new_v4(),
}; };
jsonwebtoken::encode( jsonwebtoken::encode(
&Header::default(), &Header::default(),

View File

@@ -43,11 +43,15 @@ impl FromRequestParts<AppState> for AuthUser {
.map_err(|e| AppError::Internal(e.into()))? .map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?; .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Update last_seen_at in the background (fire-and-forget) // 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 pool = state.pool.clone();
let session_id = session.id; let session_id = session.id;
tokio::spawn(async move { 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 { Ok(Self {

View File

@@ -1,6 +1,10 @@
use std::path::PathBuf; 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)] #[derive(Clone, Debug)]
pub struct AppConfig { pub struct AppConfig {
@@ -16,11 +20,29 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
pub fn from_env() -> Result<Self> { 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")?;
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
if is_prod {
return Err(anyhow!(
"Refusing to start in production with the well-known dev JWT_SECRET — \
rotate it (openssl rand -hex 64)."
));
}
tracing::warn!(
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
);
} else if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
Ok(Self { Ok(Self {
database_url: std::env::var("DATABASE_URL") database_url: std::env::var("DATABASE_URL")
.context("DATABASE_URL must be set")?, .context("DATABASE_URL must be set")?,
jwt_secret: std::env::var("JWT_SECRET") jwt_secret,
.context("JWT_SECRET must be set")?,
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS") session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string()) .unwrap_or_else(|_| "30".to_string())
.parse() .parse()

View File

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

View File

@@ -1,6 +1,5 @@
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { pub enum AppError {
@@ -8,7 +7,9 @@ pub enum AppError {
Unauthorized(String), Unauthorized(String),
Forbidden(String), Forbidden(String),
NotFound(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), Internal(anyhow::Error),
} }
@@ -19,7 +20,8 @@ impl AppError {
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), 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"), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
} }
} }
@@ -30,7 +32,8 @@ impl AppError {
| Self::Unauthorized(msg) | Self::Unauthorized(msg)
| Self::Forbidden(msg) | Self::Forbidden(msg)
| Self::NotFound(msg) | Self::NotFound(msg)
| Self::TooManyRequests(msg) => msg.clone(), | Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
Self::Internal(err) => { Self::Internal(err) => {
tracing::error!("internal error: {err:#}"); tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string() "Ein interner Fehler ist aufgetreten.".to_string()
@@ -42,13 +45,29 @@ impl AppError {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, code) = self.status_and_code(); 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 message = self.message();
let body = json!({
let mut body = serde_json::json!({
"error": code, "error": code,
"message": message, "message": message,
"status": status.as_u16(), "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::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json; use axum::Json;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -6,6 +9,8 @@ use uuid::Uuid;
use crate::auth::middleware::AuthUser; use crate::auth::middleware::AuthUser;
use crate::error::AppError; use crate::error::AppError;
use crate::services::config;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState; use crate::state::AppState;
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -53,8 +58,25 @@ struct FeedRow {
pub async fn feed( pub async fn feed(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
headers: HeaderMap,
Query(q): Query<FeedQuery>, Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> { ) -> 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 limit = q.limit.unwrap_or(20).min(100);
let rows = if let Some(hashtag) = &q.hashtag { let rows = if let Some(hashtag) = &q.hashtag {

View File

@@ -0,0 +1,411 @@
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::upload::Upload;
use crate::models::user::UserRole;
use crate::state::{AppState, SseEvent};
// ── 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?;
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?;
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!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
pin_failed_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
)
.bind(&pin_hash)
.bind(user_id)
.execute(&state.pool)
.await?;
// Notify the *recipient* device(s) if they happen to be online so they can clear
// their cached local PIN. They'll save the new one on the next /recover.
let _ = state.sse_tx.send(SseEvent::new(
"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 upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}
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()))?;
if event.export_released_at.is_some() {
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
}
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.await?;
}
// Spawn export workers
crate::services::export::spawn_export_jobs(
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,80 @@
//! 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,
}
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;
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,
}))
}

View File

@@ -1,4 +1,8 @@
pub mod admin;
pub mod feed; pub mod feed;
pub mod host;
pub mod me;
pub mod social; pub mod social;
pub mod sse; pub mod sse;
pub mod test_admin;
pub mod upload; 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::http::StatusCode;
use axum::Json; use axum::Json;
use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -51,12 +52,24 @@ pub async fn toggle_like(
Ok(StatusCode::NO_CONTENT) 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( pub async fn list_comments(
State(state): State<AppState>, State(state): State<AppState>,
_auth: AuthUser, _auth: AuthUser,
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<CommentDto>>, AppError> { ) -> Result<Json<Vec<CommentDto>>, AppError> {
let comments = Comment::list_for_upload(&state.pool, upload_id).await?; let comments =
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
Ok(Json(comments)) Ok(Json(comments))
} }
@@ -79,7 +92,8 @@ pub async fn add_comment(
} }
let text = body.body.trim(); 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( return Err(AppError::BadRequest(
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(), "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::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use futures::stream::Stream; use futures::stream::Stream;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use crate::auth::jwt; use crate::auth::middleware::AuthUser;
use crate::error::AppError; use crate::error::AppError;
use crate::models::session::Session; use crate::models::session::Session;
use crate::state::AppState; use crate::state::AppState;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SseQuery { pub struct SseQuery {
pub token: String, pub ticket: String,
} }
/// SSE stream endpoint. Accepts JWT via query param since EventSource #[derive(Serialize)]
/// doesn't support custom headers. 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( pub async fn stream(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<SseQuery>, Query(q): Query<SseQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> { ) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
// Verify token let token_hash = state
let _claims = jwt::verify_token(&q.token, &state.config.jwt_secret) .sse_tickets
.map_err(|_| AppError::Unauthorized("Token ungültig.".into()))?; .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) Session::find_by_token_hash(&state.pool, &token_hash)
.await .await
.map_err(|e| AppError::Internal(e.into()))? .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::extract::{Multipart, Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json; use axum::Json;
@@ -9,18 +11,40 @@ use crate::error::AppError;
use crate::models::hashtag::{self, Hashtag}; use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::{Upload, UploadDto}; use crate::models::upload::{Upload, UploadDto};
use crate::models::user::User; use crate::models::user::User;
use crate::services::config;
use crate::state::AppState; use crate::state::AppState;
const MAX_CAPTION_LENGTH: usize = 2000;
pub async fn upload( pub async fn upload(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<(StatusCode, Json<UploadDto>), AppError> { ) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let upload_rate_on = config::get_bool(&state.pool, "upload_rate_enabled", true).await;
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),
));
}
}
// Check if user is banned // Check if user is banned
let user = User::find_by_id(&state.pool, auth.user_id) let user = User::find_by_id(&state.pool, auth.user_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned { if user.is_banned {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Du bist gesperrt.".into())); return Err(AppError::Forbidden("Du bist gesperrt.".into()));
} }
@@ -29,12 +53,13 @@ pub async fn upload(
.await? .await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() { if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Uploads sind gesperrt.".into())); return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
} }
// Read config limits from DB // Read config limits from DB
let max_image_mb: i64 = get_config_i64(&state.pool, "max_image_size_mb", 20).await; let max_image_mb: i64 = config::get_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_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_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None; let mut file_name: Option<String> = None;
@@ -74,6 +99,35 @@ pub async fn upload(
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
let size = data.len() as i64; let size = data.len() as i64;
// 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
)));
}
}
// Validate file MIME type using magic bytes
let detected_mime = infer::get(&data);
if let Some(detected) = detected_mime {
let detected_type = detected.mime_type();
// Ensure detected type is compatible with declared MIME type
let declared_category = mime.split('/').next().unwrap_or("");
let detected_category = detected_type.split('/').next().unwrap_or("");
// Only reject if categories don't match (e.g., image vs video)
if declared_category != "application" && declared_category != detected_category {
return Err(AppError::BadRequest(format!(
"Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}",
mime, detected_type
)));
}
}
// Validate file size // Validate file size
let max_bytes = if mime.starts_with("video/") { let max_bytes = if mime.starts_with("video/") {
max_video_mb * 1024 * 1024 max_video_mb * 1024 * 1024
@@ -87,6 +141,24 @@ pub async fn upload(
))); )));
} }
// Per-user storage quota — dynamic formula based on available disk space and the
// number of active uploaders. Gated by master + per-area toggles so the admin can
// disable it on trusted instances.
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes {
let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit {
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
}
}
// Determine file extension // Determine file extension
let ext = file_name let ext = file_name
.as_deref() .as_deref()
@@ -165,10 +237,10 @@ pub async fn upload(
created_at: upload.created_at, created_at: upload.created_at,
}; };
let _ = state.sse_tx.send(crate::state::SseEvent { let _ = state.sse_tx.send(crate::state::SseEvent::new(
event_type: "new-upload".to_string(), "new-upload",
data: serde_json::to_string(&dto).unwrap_or_default(), serde_json::to_string(&dto).unwrap_or_default(),
}); ));
Ok((StatusCode::CREATED, Json(dto))) Ok((StatusCode::CREATED, Json(dto)))
} }
@@ -226,12 +298,116 @@ pub async fn delete_upload(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 { /// Drain a multipart body so the HTTP connection stays clean when returning an early error.
let row: Option<(String,)> = /// Without draining, the client may still be sending the body after we've sent our response,
sqlx::query_as("SELECT value FROM config WHERE key = $1") /// which can corrupt the keep-alive connection for subsequent requests.
.bind(key) async fn drain_multipart(mut mp: Multipart) {
.fetch_optional(pool) while let Ok(Some(mut field)) = mp.next_field().await {
.await while field.chunk().await.ok().flatten().is_some() {}
.unwrap_or(None); }
row.and_then(|r| r.0.parse().ok()).unwrap_or(default) }
/// 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,
}
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
/// and `window.open`, defeating the purpose of having the alias.
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let absolute = state.config.media_path.join(&upload.original_path);
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&absolute)
.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 filename = absolute
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, upload.mime_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
} }

View File

@@ -1,7 +1,9 @@
use anyhow::Result; use anyhow::Result;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post}; use axum::routing::{delete, get, patch, post};
use axum::Router; use axum::Router;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod auth; mod auth;
@@ -29,7 +31,22 @@ async fn main() -> Result<()> {
let config = AppConfig::from_env()?; let config = AppConfig::from_env()?;
let pool = db::create_pool(&config.database_url).await?; 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(),
);
// Ensure media directories exist // Ensure media directories exist
tokio::fs::create_dir_all(&config.media_path).await.ok(); tokio::fs::create_dir_all(&config.media_path).await.ok();
@@ -40,12 +57,20 @@ async fn main() -> Result<()> {
.route("/api/v1/recover", post(auth::handlers::recover)) .route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login)) .route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout)) .route("/api/v1/session", delete(auth::handlers::logout))
// Upload // Upload — body limit disabled; size validation is done inside the handler
.route("/api/v1/upload", post(handlers::upload::upload)) .route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::disable()))
.route( .route(
"/api/v1/upload/{id}", "/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload), patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
) )
.route(
"/api/v1/upload/{id}/original",
get(handlers::upload::get_original),
)
// 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 // Feed
.route("/api/v1/feed", get(handlers::feed::feed)) .route("/api/v1/feed", get(handlers::feed::feed))
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta)) .route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
@@ -58,7 +83,51 @@ async fn main() -> Result<()> {
) )
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment)) .route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
// SSE // 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));
// 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
};
// Serve media files from disk // Serve media files from disk
let media_service = ServeDir::new(&config.media_path); let media_service = ServeDir::new(&config.media_path);
@@ -67,6 +136,7 @@ async fn main() -> Result<()> {
.route("/health", get(|| async { "ok" })) .route("/health", get(|| async { "ok" }))
.merge(api) .merge(api)
.nest_service("/media", media_service) .nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?; let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;

View File

@@ -40,15 +40,35 @@ impl Comment {
.await .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>( sqlx::query_as::<_, CommentDto>(
"SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name, c.body, c.created_at "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 FROM comment c
JOIN \"user\" u ON u.id = c.user_id JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL WHERE c.upload_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC", 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(upload_id)
.bind(before)
.bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
} }
@@ -69,4 +89,25 @@ impl Comment {
.await?; .await?;
Ok(()) 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> { pub fn extract_hashtags(text: &str) -> Vec<String> {
const MAX_TAG_LEN: usize = 40;
text.split_whitespace() text.split_whitespace()
.filter(|w| w.starts_with('#') && w.len() > 1) .filter_map(|w| w.strip_prefix('#'))
.map(|w| w.trim_start_matches('#').to_lowercase()) .map(|t| {
.filter(|t| !t.is_empty()) 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() .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

@@ -14,6 +14,7 @@ pub struct Upload {
pub mime_type: String, pub mime_type: String,
pub original_size_bytes: i64, pub original_size_bytes: i64,
pub caption: Option<String>, pub caption: Option<String>,
pub compression_status: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>, pub deleted_at: Option<DateTime<Utc>>,
} }
@@ -68,6 +69,23 @@ impl Upload {
.await .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( pub async fn set_preview_path(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
@@ -94,14 +112,76 @@ impl Upload {
Ok(()) Ok(())
} }
/// 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.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1") let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
)
.bind(id) .bind(id)
.execute(pool) .fetch_optional(&mut *tx)
.await?; .await?;
if let Some((user_id, bytes)) = 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?;
}
tx.commit().await?;
Ok(()) Ok(())
} }
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
/// matched (already deleted, wrong event, or unknown id) so host handlers
/// can return a clean 404 instead of silently no-op'ing.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = 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",
)
.bind(id)
.bind(event_id)
.fetch_optional(&mut *tx)
.await?;
let deleted = if let Some((user_id, bytes)) = 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?;
true
} else {
false
};
tx.commit().await?;
Ok(deleted)
}
pub async fn update_caption( pub async fn update_caption(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
@@ -114,4 +194,17 @@ impl Upload {
.await?; .await?;
Ok(()) 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; use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "user_role", rename_all = "lowercase")] #[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole { pub enum UserRole {
Guest, Guest,
@@ -11,6 +12,16 @@ pub enum UserRole {
Admin, 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)] #[derive(Debug, sqlx::FromRow)]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
@@ -58,7 +69,7 @@ impl User {
display_name: &str, display_name: &str,
) -> Result<Vec<Self>, sqlx::Error> { ) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>( 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(event_id)
.bind(display_name) .bind(display_name)
@@ -66,6 +77,21 @@ impl User {
.await .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> { pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
let row: (i16,) = sqlx::query_as( let row: (i16,) = sqlx::query_as(
"UPDATE \"user\" "UPDATE \"user\"

View File

@@ -3,24 +3,27 @@ use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::Semaphore; use tokio::sync::{broadcast, Semaphore};
use uuid::Uuid; use uuid::Uuid;
use crate::models::upload::Upload; use crate::models::upload::Upload;
use crate::state::SseEvent;
#[derive(Clone)] #[derive(Clone)]
pub struct CompressionWorker { pub struct CompressionWorker {
semaphore: Arc<Semaphore>, semaphore: Arc<Semaphore>,
pool: PgPool, pool: PgPool,
media_path: PathBuf, media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
} }
impl CompressionWorker { impl CompressionWorker {
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize) -> Self { pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
Self { Self {
semaphore: Arc::new(Semaphore::new(concurrency)), semaphore: Arc::new(Semaphore::new(concurrency)),
pool, pool,
media_path, media_path,
sse_tx,
} }
} }
@@ -29,8 +32,22 @@ impl CompressionWorker {
let worker = self.clone(); let worker = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await; let _permit = worker.semaphore.acquire().await;
if let Err(e) = worker.do_process(upload_id, &original_path, &mime_type).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) => {
tracing::error!("compression failed for upload {upload_id}: {e:#}"); 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": e.to_string() }).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
}
} }
}); });
} }
@@ -41,6 +58,8 @@ impl CompressionWorker {
original_path: &str, original_path: &str,
mime_type: &str, mime_type: &str,
) -> Result<()> { ) -> Result<()> {
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
let original = self.media_path.join(original_path); let original = self.media_path.join(original_path);
if mime_type.starts_with("image/") { if mime_type.starts_with("image/") {
@@ -53,6 +72,7 @@ impl CompressionWorker {
tracing::info!("thumbnail generated for upload {upload_id}"); tracing::info!("thumbnail generated for upload {upload_id}");
} }
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
Ok(()) Ok(())
} }
@@ -112,7 +132,11 @@ impl CompressionWorker {
let thumb_filename = format!("{upload_id}.jpg"); let thumb_filename = format!("{upload_id}.jpg");
let thumb_path = thumbs_dir.join(&thumb_filename); 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([ .args([
"-i", "-i",
original.to_str().unwrap_or_default(), original.to_str().unwrap_or_default(),
@@ -125,13 +149,36 @@ impl CompressionWorker {
"-y", "-y",
thumb_path.to_str().unwrap_or_default(), thumb_path.to_str().unwrap_or_default(),
]) ])
.output() .stdout(std::process::Stdio::piped())
.await .stderr(std::process::Stdio::piped())
.context("failed to run ffmpeg")?; .kill_on_drop(true)
.spawn()
.context("failed to spawn ffmpeg")?;
if !output.status.success() { let status = match tokio::time::timeout(
let stderr = String::from_utf8_lossy(&output.stderr); std::time::Duration::from_secs(120),
anyhow::bail!("ffmpeg failed: {stderr}"); 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}")) 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,617 @@
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();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_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, &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,
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?;
let tmp_path = exports_dir.join("Gallery.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,
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
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_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
let tmp_path = exports_dir.join("Memories.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,104 @@
//! 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::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,
) {
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();
}
});
}
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

@@ -1 +1,7 @@
pub mod compression; pub mod compression;
pub mod config;
pub mod export;
pub mod jobs;
pub mod maintenance;
pub mod rate_limiter;
pub mod sse_tickets;

View File

@@ -0,0 +1,83 @@
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 from X-Forwarded-For (Caddy sets this) or fall back
/// to a provided socket address string.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_owned())
.unwrap_or_else(|| fallback.to_owned())
}

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

@@ -3,6 +3,8 @@ use tokio::sync::broadcast;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::services::compression::CompressionWorker; use crate::services::compression::CompressionWorker;
use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SseEvent { pub struct SseEvent {
@@ -10,24 +12,39 @@ pub struct SseEvent {
pub data: String, 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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub pool: PgPool, pub pool: PgPool,
pub config: AppConfig, pub config: AppConfig,
pub sse_tx: broadcast::Sender<SseEvent>, pub sse_tx: broadcast::Sender<SseEvent>,
pub compression: CompressionWorker, pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore,
} }
impl AppState { impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self { pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256); let (sse_tx, _) = broadcast::channel(256);
let compression = let compression =
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2); CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone());
Self { Self {
pool, pool,
config, config,
sse_tx, sse_tx,
compression, compression,
rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(),
} }
} }
} }

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>

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 |

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');
}
}

View File

@@ -0,0 +1,47 @@
import type { Page, Locator } from '@playwright/test';
/**
* FeedPage — the main `/feed` route. Selectors rely on the bottom nav's
* aria-labels ("Galerie", "Hochladen", "Konto") and on data-testids that
* may be added to FeedListCard / FeedGrid later. Wherever we use a
* permissive locator (e.g. `.feed-card`) it is documented; tests that
* depend on a specific card should narrow by image alt or caption text.
*/
export class FeedPage {
readonly page: Page;
readonly feedTab: Locator;
readonly fab: Locator;
readonly accountTab: Locator;
readonly fabBadge: Locator;
constructor(page: Page) {
this.page = page;
this.feedTab = page.getByRole('link', { name: 'Galerie' });
this.fab = page.getByRole('button', { name: 'Hochladen' });
this.accountTab = page.getByRole('link', { name: 'Konto' });
// FAB badge — sibling span inside the FAB button.
this.fabBadge = this.fab.locator('span.bg-red-500');
}
async goto() {
await this.page.goto('/feed');
}
async openUploadSheet() {
await this.fab.click();
}
/** Locator for any feed card. Tests should refine with text or image alt. */
cards(): Locator {
return this.page.locator('[data-testid="feed-card"], article, .feed-card');
}
cardByCaption(text: string): Locator {
return this.page.locator('article', { hasText: text }).first();
}
/** Count of currently visible feed cards. */
async cardCount(): Promise<number> {
return this.cards().count();
}
}

View File

@@ -0,0 +1,36 @@
import type { Page, Locator } from '@playwright/test';
export class HostDashboard {
readonly page: Page;
readonly statsSection: Locator;
readonly lockEventButton: Locator;
readonly unlockEventButton: Locator;
readonly releaseGalleryButton: Locator;
readonly userSearchInput: Locator;
constructor(page: Page) {
this.page = page;
this.statsSection = page.locator('[data-testid="host-stats"]');
this.lockEventButton = page.getByRole('button', { name: /uploads sperren|event sperren|sperren/i }).first();
this.unlockEventButton = page.getByRole('button', { name: /uploads freigeben|entsperren/i }).first();
this.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i });
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
}
async goto() {
await this.page.goto('/host');
}
userRow(displayName: string): Locator {
return this.page.locator('tr,li,div', { hasText: displayName }).filter({ has: this.page.getByRole('button') }).first();
}
async banUser(displayName: string, hideUploads = false) {
const row = this.userRow(displayName);
await row.getByRole('button', { name: /sperren/i }).first().click();
if (hideUploads) {
await this.page.getByRole('checkbox', { name: /ausblenden/i }).check();
}
await this.page.getByRole('button', { name: /bestätigen|sperren/i }).last().click();
}
}

10
e2e/page-objects/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export { JoinPage } from './join-page';
export { RecoverPage } from './recover-page';
export { AdminLoginPage } from './admin-login-page';
export { FeedPage } from './feed-page';
export { UploadSheet } from './upload-sheet';
export { Lightbox } from './lightbox';
export { AccountPage } from './account-page';
export { HostDashboard } from './host-dashboard';
export { AdminDashboard } from './admin-dashboard';
export { ExportPage } from './export-page';

View File

@@ -0,0 +1,61 @@
import type { Page, Locator } from '@playwright/test';
export class JoinPage {
readonly page: Page;
readonly nameInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly pinModal: Locator;
readonly pinDisplay: Locator;
readonly pinCopyButton: Locator;
readonly continueButton: Locator;
readonly linkToRecover: Locator;
// Inline recovery state (shown when the name is taken)
readonly recoveryPinInput: Locator;
readonly recoverySubmit: Locator;
readonly recoveryError: Locator;
readonly tryDifferentNameButton: Locator;
constructor(page: Page) {
this.page = page;
this.nameInput = page.getByTestId('join-name-input');
this.submitButton = page.getByTestId('join-submit');
this.errorMessage = page.getByTestId('join-error');
this.pinModal = page.getByTestId('pin-modal');
this.pinDisplay = page.getByTestId('pin-display');
this.pinCopyButton = page.getByTestId('pin-copy');
this.continueButton = page.getByTestId('continue-to-feed');
this.linkToRecover = page.getByTestId('link-to-recover');
this.recoveryPinInput = page.getByTestId('recovery-pin-input');
this.recoverySubmit = page.getByTestId('recovery-submit');
this.recoveryError = page.getByTestId('recovery-error');
this.tryDifferentNameButton = page.getByTestId('try-different-name');
}
async goto() {
await this.page.goto('/join');
}
async fillName(name: string) {
await this.nameInput.fill(name);
}
async submit() {
await this.submitButton.click();
}
/** End-to-end: type a name and submit, then either resolve when the PIN modal appears or surface the name-taken UI. */
async joinAs(name: string): Promise<{ pin: string }> {
await this.fillName(name);
await this.submit();
await this.pinModal.waitFor({ state: 'visible' });
const pin = (await this.pinDisplay.textContent())?.trim() ?? '';
return { pin };
}
/** Continue to the gallery after the PIN modal appears. */
async continueToFeed() {
await this.continueButton.click();
await this.page.waitForURL('**/feed');
}
}

View File

@@ -0,0 +1,49 @@
import type { Page, Locator } from '@playwright/test';
export class Lightbox {
readonly page: Page;
readonly closeButton: Locator;
readonly nextButton: Locator;
readonly prevButton: Locator;
readonly likeButton: Locator;
readonly likeCount: Locator;
readonly commentInput: Locator;
readonly commentSubmit: Locator;
readonly commentsList: Locator;
constructor(page: Page) {
this.page = page;
this.closeButton = page.getByRole('button', { name: /schließen|close/i });
this.nextButton = page.getByRole('button', { name: /nächst|next/i });
this.prevButton = page.getByRole('button', { name: /vorherig|previous/i });
this.likeButton = page.getByRole('button', { name: /gefällt|like|heart/i }).first();
this.likeCount = page.locator('[data-testid="like-count"]').first();
this.commentInput = page.getByPlaceholder(/kommentar|comment/i);
this.commentSubmit = page.getByRole('button', { name: /senden|send|post/i }).first();
this.commentsList = page.locator('[data-testid="comments-list"]');
}
async close() {
await this.closeButton.click();
}
async like() {
await this.likeButton.click();
}
async addComment(text: string) {
await this.commentInput.fill(text);
await this.commentSubmit.click();
}
/** Swipe gesture support for mobile spec. */
async swipeLeft() {
const box = await this.page.locator('body').boundingBox();
if (!box) return;
const y = box.y + box.height / 2;
await this.page.mouse.move(box.x + box.width * 0.8, y);
await this.page.mouse.down();
await this.page.mouse.move(box.x + box.width * 0.2, y, { steps: 10 });
await this.page.mouse.up();
}
}

View File

@@ -0,0 +1,27 @@
import type { Page, Locator } from '@playwright/test';
export class RecoverPage {
readonly page: Page;
readonly nameInput: Locator;
readonly pinInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.nameInput = page.getByTestId('recover-name-input');
this.pinInput = page.getByTestId('recover-pin-input');
this.submitButton = page.getByTestId('recover-submit');
this.errorMessage = page.getByTestId('recover-error');
}
async goto() {
await this.page.goto('/recover');
}
async recover(name: string, pin: string) {
await this.nameInput.fill(name);
await this.pinInput.fill(pin);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,55 @@
import type { Page, Locator } from '@playwright/test';
/**
* UploadSheet — the bottom-sheet UploadSheet.svelte + the `/upload` preview
* page. Camera button is only useful on Chromium because fake-media is a
* Chromium-only launch flag.
*
* Note on the actual flow: tapping the FAB opens UploadSheet.svelte; tapping
* "Galerie" inside it opens the native file picker which sets the staged
* files into the pending-upload-store; the app then navigates to /upload
* where the user can edit the caption and submit.
*/
export class UploadSheet {
readonly page: Page;
readonly fileInput: Locator;
readonly cameraButton: Locator;
readonly galleryButton: Locator;
readonly captionInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
// The file input lives inside the UploadSheet bottom sheet AND inside the
// /upload preview page. Both work with setInputFiles. `.first()` keeps strict
// mode happy when both happen to exist mid-transition.
this.fileInput = page.locator('input[type="file"]').first();
this.cameraButton = page.getByRole('button', { name: /kamera/i });
this.galleryButton = page.getByRole('button', { name: /galerie/i });
this.captionInput = page.getByTestId('upload-caption');
this.submitButton = page.getByTestId('upload-submit');
}
/** Stage one or more files via the hidden file input — bypasses the native picker. */
async stageFiles(paths: string[]) {
await this.fileInput.setInputFiles(paths);
}
async fillCaption(caption: string) {
await this.captionInput.fill(caption);
}
async submit() {
await this.submitButton.click();
}
/** Helper: open sheet, stage one file, fill caption, submit. Returns when navigation back to feed completes. */
async uploadOne(page: Page, filePath: string, caption = '') {
await this.stageFiles([filePath]);
// The app may auto-navigate to /upload; wait for the caption editor to be ready.
await this.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
if (caption) await this.fillCaption(caption);
await this.submit();
await page.waitForURL('**/feed', { timeout: 15_000 });
}
}

139
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,139 @@
import { defineConfig, devices } from '@playwright/test';
/**
* EventSnap E2E config.
*
* Defaults assume the test stack is already up (`npm run stack:up`). The
* stack is *not* started by webServer because Playwright would clobber the
* compose volumes between retries. CI calls `npm run stack:up` once before
* `npm run test:e2e` runs.
*
* UA matrix: the `@smoke` happy-path runs across every project to catch
* engine-level divergences. The rest of the suite only runs against
* `chromium-desktop` to keep the wall-clock reasonable.
*/
export default defineConfig({
testDir: './specs',
outputDir: './test-results',
fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel.
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
reporter: [
['list'],
['html', { open: 'never', outputFolder: './playwright-report' }],
],
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
timeout: 60_000,
expect: { timeout: 10_000 },
use: {
baseURL: process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
// Camera/mic permissions granted by default; the fake-media launch args
// (set per-project below for Chromium) supply the actual stream.
permissions: ['camera', 'microphone', 'clipboard-read', 'clipboard-write'],
},
projects: [
// ── Baseline desktop ─────────────────────────────────────────────────
{
name: 'chromium-desktop',
// 09-mobile/ specs only run on the chromium-mobile project below — they
// require `hasTouch: true` and a phone viewport.
testIgnore: ['**/09-mobile/**'],
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
// A video file would be loaded with --use-file-for-fake-video-capture
// — we ship a tiny .y4m in fixtures/media/ as a follow-up.
],
},
},
},
// ── Phase 3 mobile gestures ──────────────────────────────────────────
// Runs ONLY the 09-mobile/ specs. Uses the Pixel 7 device descriptor so
// hasTouch + isMobile + mobile viewport are all set, then dispatches
// pointer events via page.mouse / locator helpers. Real touch events
// come from `page.touchscreen` when needed.
{
name: 'chromium-mobile',
testMatch: ['**/09-mobile/**/*.spec.ts'],
use: { ...devices['Pixel 7'] },
},
// ── Mobile UA smoke matrix (runs only @smoke specs in CI) ────────────
{
name: 'chromium-pixel7',
use: { ...devices['Pixel 7'] },
grep: /@smoke/,
},
{
name: 'chromium-galaxy-s22',
use: {
...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor.
viewport: { width: 360, height: 780 },
userAgent:
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
},
grep: /@smoke/,
},
{
name: 'samsung-internet',
use: {
...devices['Galaxy S9+'],
viewport: { width: 360, height: 780 },
userAgent:
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
},
grep: /@smoke/,
},
{
name: 'edge-android',
use: {
...devices['Pixel 7'],
userAgent:
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0',
},
grep: /@smoke/,
},
{
name: 'chrome-ios',
use: {
...devices['iPhone 14 Pro'],
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.0.0 Mobile/15E148 Safari/604.1',
},
grep: /@smoke/,
},
{
name: 'webkit-iphone',
use: { ...devices['iPhone 14 Pro'] },
grep: /@smoke/,
},
{
name: 'firefox-android',
use: {
...devices['Pixel 7'],
defaultBrowserType: 'firefox',
userAgent:
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
},
grep: /@smoke/,
},
{
name: 'firefox-desktop',
use: { ...devices['Desktop Firefox'] },
grep: /@smoke/,
},
],
});

View File

@@ -0,0 +1,45 @@
/**
* USER_JOURNEYS.md §11 — admin login. Tests the /admin/login route and
* the redirect-while-already-logged-in shortcut.
*/
import { test, expect } from '../../fixtures/test';
import { AdminLoginPage } from '../../page-objects';
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
test.describe('Auth — admin login', () => {
test('correct password → /admin dashboard', async ({ page }) => {
const login = new AdminLoginPage(page);
await login.goto();
await login.login(ADMIN_PASSWORD);
await page.waitForURL('**/admin', { timeout: 10_000 });
// Admin JWT should now be in localStorage (admin uses the same key, just with role=admin in the payload)
const role = await page.evaluate(() => {
const token = localStorage.getItem('eventsnap_jwt');
if (!token) return null;
try {
return JSON.parse(atob(token.split('.')[1])).role;
} catch {
return null;
}
});
expect(role).toBe('admin');
});
test('wrong password → error, no token written', async ({ page }) => {
const login = new AdminLoginPage(page);
await login.goto();
await login.login('definitely-not-the-password');
await expect(login.errorMessage).toContainText(/falsch|forbidden|password/i);
const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt'));
expect(token).toBeNull();
});
test('already logged in as admin → auto-redirect to /admin', async ({ page, api }) => {
const adminJwt = await api.adminLogin();
await page.goto('/');
await page.evaluate((jwt) => localStorage.setItem('eventsnap_jwt', jwt), adminJwt);
await page.goto('/admin/login');
await page.waitForURL('**/admin', { timeout: 5_000 });
});
});

View File

@@ -0,0 +1,28 @@
/**
* UX polish — back chevrons on /recover and /export. Both pages used to be
* dead-ends for deep-linked users; the chevron mirrors the upload-composer
* header pattern and routes back to /feed.
*/
import { test, expect } from '../../fixtures/test';
test.describe('Navigation — back chevrons', () => {
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ page }) => {
await page.goto('/recover');
const back = page.getByTestId('recover-back');
await expect(back).toBeVisible();
await back.click();
// Unauthenticated → /feed mounts and redirects to /join.
await page.waitForURL(/\/(join|feed)$/);
});
test('/export back chevron returns the authenticated guest to /feed', async ({ page, guest, signIn }) => {
const g = await guest('ExportBack');
await signIn(page, g);
await page.goto('/export');
const back = page.getByTestId('export-back');
await expect(back).toBeVisible();
await back.click();
await page.waitForURL('**/feed');
});
});

View File

@@ -0,0 +1,135 @@
/**
* USER_JOURNEYS.md §1 (First-time guest), §2 (Returning guest, same device),
* §3 (Returning guest, new device). Covers the happy path through
* /join, the PIN modal, the onboarding overlay landing, and the
* name-already-taken recovery transformation.
*/
import { test, expect } from '../../fixtures/test';
import { JoinPage } from '../../page-objects';
import { readStorage, STORAGE_KEYS, clearAllStorage } from '../../helpers/storage-helpers';
test.describe('Auth — join flow', () => {
test('happy path: name → PIN modal → feed @smoke', async ({ page }) => {
const join = new JoinPage(page);
await join.goto();
await expect(page.getByRole('heading', { name: 'Willkommen!' })).toBeVisible();
const { pin } = await join.joinAs('Alice');
expect(pin).toMatch(/^\d{4}$/);
// PIN copy button toggles to "Kopiert!" on click
await join.pinCopyButton.click();
await expect(join.pinCopyButton).toHaveText(/Kopiert/i);
await join.continueToFeed();
await expect(page).toHaveURL(/\/feed$/);
const storage = await readStorage(page);
expect(storage.jwt, 'JWT in localStorage').toMatch(/^eyJ/);
expect(storage.pin).toBe(pin);
expect(storage.userId).toMatch(/^[0-9a-f-]{36}$/);
expect(storage.displayName).toBe('Alice');
});
test('returning guest with valid JWT is redirected to /feed', async ({ page, guest, signIn }) => {
const alice = await guest('Bob');
await signIn(page, alice);
// Now visit the root — should auto-redirect to /feed.
await page.goto('/');
await page.waitForURL('**/feed', { timeout: 5_000 });
});
test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => {
const original = await guest('Charlie');
// Brand-new browser context (cleared storage) — landing on /join with same name
await clearAllStorage(page);
const join = new JoinPage(page);
await join.goto();
await join.fillName('Charlie');
await join.submit();
await expect(join.recoveryPinInput).toBeVisible();
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
// Type correct PIN → land on /feed with a new JWT
await join.recoveryPinInput.fill(original.pin);
await join.recoverySubmit.click();
await page.waitForURL('**/feed');
const storage = await readStorage(page);
expect(storage.userId).toBe(original.userId);
expect(storage.pin).toBe(original.pin);
});
test('wrong PIN three times locks the account for 15 minutes', async ({ page, guest, db }) => {
const dave = await guest('Dave');
await clearAllStorage(page);
const join = new JoinPage(page);
await join.goto();
await join.fillName('Dave');
await join.submit();
await expect(join.recoveryPinInput).toBeVisible();
// Wrong PIN (real one is dave.pin)
const wrong = dave.pin === '0000' ? '1111' : '0000';
for (let i = 0; i < 3; i++) {
await join.recoveryPinInput.fill(wrong);
await join.recoverySubmit.click();
await expect(join.recoveryError).toBeVisible();
}
// Fourth attempt should hit the 429 lockout (even with the correct PIN now)
await join.recoveryPinInput.fill(dave.pin);
await join.recoverySubmit.click();
await expect(join.recoveryError).toContainText(/15 Minuten/);
// Sanity: DB row reflects the lock
// (The handler sets pin_locked_until directly — verify via API "recover" returning 429)
void db; // unused for now, documenting that db.lockUserPin exists if we want shortcut path
});
test('"Anderen Namen wählen" returns to the normal join form', async ({ page, guest }) => {
await guest('Eve');
await clearAllStorage(page);
const join = new JoinPage(page);
await join.goto();
await join.fillName('Eve');
await join.submit();
await expect(join.recoveryPinInput).toBeVisible();
await join.tryDifferentNameButton.click();
await expect(join.nameInput).toBeVisible();
await expect(join.recoveryPinInput).not.toBeVisible();
});
test('"Ich habe bereits einen Account" link routes to /recover', async ({ page }) => {
const join = new JoinPage(page);
await join.goto();
await join.linkToRecover.click();
await expect(page).toHaveURL(/\/recover$/);
});
test('JWT and PIN keys are exactly the ones auth.ts expects', async ({ page, guest, signIn }) => {
const handle = await guest('Frank');
await signIn(page, handle);
// Read raw localStorage to make sure no test accidentally uses a different key.
const raw = await page.evaluate(() => ({
jwt: localStorage.getItem('eventsnap_jwt'),
pin: localStorage.getItem('eventsnap_pin'),
userId: localStorage.getItem('eventsnap_user_id'),
displayName: localStorage.getItem('eventsnap_display_name'),
}));
expect(raw.jwt).toBe(handle.jwt);
expect(raw.pin).toBe(handle.pin);
expect(raw.userId).toBe(handle.userId);
expect(raw.displayName).toBe('Frank');
// Sanity: the keys we just checked match STORAGE_KEYS in storage-helpers.ts
expect(STORAGE_KEYS.jwt).toBe('eventsnap_jwt');
});
});

View File

@@ -0,0 +1,25 @@
/**
* USER_JOURNEYS.md §15 — leaving an event clears auth and routes to /join.
* The journey doc spells out: clears JWT and PIN, calls DELETE /session.
*/
import { test, expect } from '../../fixtures/test';
import { AccountPage } from '../../page-objects';
import { readStorage } from '../../helpers/storage-helpers';
test.describe('Auth — leave event', () => {
test('leave event clears localStorage and redirects to /join', async ({ page, guest, signIn }) => {
const h = await guest('Jens');
await signIn(page, h);
const account = new AccountPage(page);
await account.goto();
await account.leaveEvent();
await expect(page).toHaveURL(/\/join$/);
const storage = await readStorage(page);
expect(storage.jwt, 'JWT should be cleared').toBeNull();
// PIN is intentionally retained per auth.ts so the user can recover.
expect(storage.userId).toBeNull();
});
});

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