Files
EventSnap/PROJECT.md
fabi 0c0eed885a fix(audit-1): media gating, per-user limits, moderation UI, upload integrity
First audit round: a blocker and eight high-severity findings across the media
access gate, the guest-facing rate limiters, host moderation, and the upload
pipeline — plus the backup commands and the e2e/production divergences that hid
several of them.

Squashed from 7 commits, original messages preserved below.

──────── fix(export): let the keepsake download through X-Frame-Options on iOS

The keepsake download navigates a hidden, same-origin iframe (deliberately:
a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a
site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`.

Blink hands a `Content-Disposition: attachment` response to the download
manager at the network layer, so Chromium never noticed. WebKit enforces XFO
on the frame navigation first and aborts the load — so on iOS Safari, the
app's primary platform, tapping Download did nothing at all, silently.

Carve the two export endpoints out to SAMEORIGIN, which still blocks
cross-origin framing. Implemented as two disjoint matchers rather than an
override: Caddy applies the FIRST `header` directive outermost, so it wins on
write and a later, more specific `header` is silently ignored (verified
against the running test stack).

Also close the test gap that let this ship:

- `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the
  only engine that enforces XFO on the download frame.
- No test in the suite ever clicked a download button — every archive
  assertion used Node `fetch`, which has no frame and no XFO enforcement.
  Add a spec that clicks it and awaits a real `download` event. Verified
  falsifiable: with the blanket DENY reinstated it fails and reports the
  WebKit refusal as the cause.
- Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards
  carry `class="card p-5"` (a Tailwind `@apply` component class), never the
  `rounded-xl` the page object looked for. This had left the "shows enabled
  download buttons" test red on main.

──────── fix(media): close the percent-escape bypass of the media gate

`/media/%70reviews/{id}.jpg` served a taken-down photo to anyone,
unauthenticated. Verified against the running stack: the literal path 404s,
the escaped one returned 200 with the full image. Same for displays,
thumbnails and originals, and any escaped byte in any position works.

Cause: the block was four `nest_service("/media/previews", 404)` route
matches sitting above a `ServeDir` on `/media`. axum matches on the RAW path
(matchit does no percent-decoding), while `ServeDir` percent-decodes when it
resolves the file. So `%70reviews` missed every blocker, fell through to the
ServeDir, and was decoded back to `previews/` on disk — reaching the bytes
with no soft-delete and no ban-hide check. That defeats a host takedown,
which is the entire point of the gate.

Remove the `/media` route tree outright instead of racing the decoder.
Nothing needs it: every media URL the backend emits is already a gated
`/api/v1/upload/{id}/{original,preview,display,thumbnail}` alias
(handlers::feed), the frontend contains zero `/media/` references, and the
`/media` in config.rs/disk.rs is the filesystem path while `media/` in
export.rs is a path inside the zip. `/media/**` now 404s regardless of
encoding. The route's own comment already said it "serves nothing" — it
wasn't a backstop, it was the vector.

Caddy keeps proxying /media/* deliberately: the app 404s it, and forwarding
means the e2e gating specs exercise the app's refusal exactly as production
would rather than being masked by the SvelteKit 404 page.

Extend the gating spec with the encoded variants — asserting only the literal
spelling is what let this sit undetected.

──────── fix(rate-limit): key the guest-facing limiters per user, not per IP

At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.

`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.

- feed:{ip}   -> feed:{user_id}    (auth was already in scope)
- export:{ip} -> export:{user_id}  (resolved from the download ticket's
  session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
  per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
  default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
  per (ip, name), mirroring the existing `recover:{ip}:{name}`.

admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.

Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.

Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.

Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.

Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.

──────── feat(moderation): let a host remove a guest's photo or comment from the UI

`DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were complete on the
backend — transactional, SSE-broadcasting, audit-logged — and had zero frontend
callers. The feed context sheet offered "Löschen" only when
`target.user_id === myUserId`, so the only lever a host actually had against an
unwanted photo was banning the uploader.

That is both disproportionate and ineffective. A ban doesn't retract what was
already posted, and it makes things strictly worse for comments: the ban check
runs BEFORE the ownership check on the guest delete route, so banning an abusive
author leaves their comment on screen and permanently undeletable by them. With
no host affordance, nobody could remove it at all.

- feed: hosts/admins get "Beitrag entfernen" on other people's posts, routed to
  the host endpoint (the guest route 403s anything the caller doesn't own) with
  moderation-specific confirm copy. Own-post "Löschen" is unchanged.
- lightbox: same for comments, via /host/comment/{id}.
- Ban semantics are deliberately untouched (USER_JOURNEYS §10 — banned users keep
  read access and cannot write). The deadlock is broken by giving the host a way
  in, not by loosening the ban.

Live role (this had to come first). `getRole()` decodes the JWT claim, but the
token is never reissued — the backend slides the session row forward and treats
the DB row as authoritative. The claim is therefore frozen for the token's
lifetime: up to 30 days. A guest promoted at the party saw no Host-Dashboard and
no moderation actions until they signed out and back in, even though
`/me/context` had been returning their real role on every page load and 4 of its
6 call sites dropped the field on the floor.

Add `role-store.ts`: seeded from the claim so there's no flash of the wrong nav,
then corrected by every `/me/context` response. Point the ad-hoc `getRole()`
callers at it (account, upload, host, admin, and the new feed gate). The host and
admin dashboards now derive `myRole` reactively, so a demotion disables their
controls immediately instead of at next login.

Tests: 04-host/moderation-ui drives the real UI — host removes a guest photo and
it's gone from /feed server-side; a plain guest is offered nothing on someone
else's post (the mirror that keeps the first test honest); a promoted guest gains
the dashboard on reload while their token still carries `role: guest`; and a host
removes the comment of an already-banned guest, asserting first that the author's
own delete 403s so the deadlock is real.

──────── fix(upload): stop destroying originals, apply EXIF orientation, surface rejections

Three defects in the same pipeline, each of which loses a photo or misrepresents
one.

1. A transient error destroyed the guest's only copy.

`process`'s error arm unconditionally `remove_file`d the original. Every failure
routed there: `create_dir_all`, both derivative `save_with_format` calls (disk
full is the canonical case, and it arrives exactly when many guests upload at
once), a panic inside the image codec, or a momentary DB-pool exhaustion. The
row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and
they were the part we deleted. The author already knew this was wrong next door:
`backfill_missing_display` says it "must NEVER soft-delete an upload that already
has a working preview".

Retry up to 3 times with backoff (re-checking the e2e generation guard after each
sleep), and on final failure keep the refund + soft-delete but leave the original
on disk, logging its path. A failed upload is now recoverable instead of gone.

2. Every portrait photo was stored sideways.

Phones don't rotate sensor data — they record the camera orientation in EXIF and
store the pixels as shot. `decode()` returns those raw pixels and the JPEG
re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and
the keepsake were all rotated 90°, while "Original anzeigen" rendered upright
because the original keeps its tag. That asymmetry is why it reads as a viewer
bug. There was no EXIF handling anywhere in the repo and no exif crate.

Read the tag via `into_decoder()` (which carries the decode Limits through, so
the decompression-bomb cap is untouched) and apply it. Missing/malformed tags
fall back to NoTransforms — most images have none.

Existing derivatives are already baked wrong, so migration 018 adds
`derivatives_rev` and `backfill_missing_display` becomes
`backfill_stale_derivatives`: it now also picks up anything below the current rev
and regenerates it once from the original, which still carries its EXIF. Videos
are marked current in the migration — ffmpeg already honours the rotation matrix.
Bump DERIVATIVES_REV for any future change that invalidates derivatives.

3. A rejected upload vanished without a word.

`UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error
text, the only "Erneut" retry button and the only rate-limit countdown — was
never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were
unreachable at runtime. On a terminal rejection the store purged the blob and
wrote a clear German reason into `entry.error` "so the UI shows a clear reason".
There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so
the badge decremented exactly as if the upload had succeeded.

Mount the queue on /upload, toast the reason immediately (the flow sends the user
to /feed straight after staging, so the list alone would still miss them), and
count blocked/error in the badge so a failure can't read as success.

Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6
and asserts both derivatives come back PORTRAIT, with a sanity check that the
source really is stored landscape. 02-upload/rejection-visible bans the uploader
between staging and sending, then asserts the toast, the queue row with the
server's reason, and that the item is still counted.

Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next
commit.

──────── docs(backup): make the backup commands work; fix the e2e/prod divergences

Backup. Both documented commands failed on the shipped stack, and the sentence
explaining them was wrong too:

- `pg_dump $DATABASE_URL` — `DATABASE_URL` is only ever in the compose
  environment, never an operator's shell, and it points at `db:5432`, which is
  compose-internal DNS. The app image has no postgres client either.
- `> /media/backups/…` — `/media` is a named volume mounted inside the app
  container, not a host path, and nothing ever creates a `backups` subdirectory.
- `rsync /opt/eventsnap/media/` — that path does not exist anywhere.
- "a single path to back up" — false, and dangerously so: exports were moved to
  their own `exports_data` volume precisely so a keepsake (which contains every
  photo in the event) can't be served off the media tree. Backing up only
  `media_data` silently loses every generated keepsake.

Rewritten as three commands — db via `docker compose exec -T db pg_dump`, and one
`docker run … tar` per volume — all verified against the running stack. The
volume mounts use `/src`, not `/media`: I hit the footgun while testing this.
Docker pre-populates an EMPTY volume from the image's own directory and chowns it
to match, so `-v media_data:/media alpine` tars alpine's cdrom/floppy/usb, writes
them into the volume, and leaves it root-owned so the non-root app can no longer
write. Mounting where the image has nothing avoids all of it. Documented inline
so the next person doesn't rediscover it.

Also correct the architecture notes: `/media/*` no longer routes to the backend
(that static tree was removed as a gating bypass), and `exports_data` was missing
from the volume list — the one volume an operator most needs to know about.

e2e stack: add the `EXPORT_PATH` + `/exports` volume it was missing. The file
says "mirrors production layout"; without these, exports landed on the container's
writable layer at the default path, so export-leak and export-video wrote real
archives into ephemeral storage and the "exports live outside media" invariant
was never actually exercised.

Pre-existing red test, unrelated to the audit: all four 02-upload/quota tests
have been failing since 4464147 "stop /me/quota leaking raw disk to guests"
(2026-07-19), which post-dates the spec's last edit. `setLimitTo` calibrated
`quota_tolerance` from `free_disk_bytes` read through the GUEST's token — a field
that commit deliberately zeroes for non-staff. Dividing by it yields a NaN
tolerance, so every test in the block died in the helper. Read the calibration
inputs through a staff token and keep reading the ceiling back through the guest,
whose limit is the thing under test.

──────── test(e2e): document why WebKit can't run the client-queue upload tests

Three 02-upload tests have been failing on `webkit-iphone` on main — `02-upload`
was already in that project's testMatch, so this is pre-existing red, not
something the audit work introduced.

Root cause is the harness, not the app. Playwright's Linux WebKit build cannot
store Blobs in IndexedDB at all: `put()` fails with "UnknownError: Error
preparing Blob/File data to be stored in object store". Confirmed it is not
about how Playwright delivers files — a Blob constructed in-page with
`new Blob([bytes])` fails identically, while Chromium stores both that and a
`setInputFiles` File without complaint.

That breaks every test driving the composer (FAB → UploadSheet → /upload →
submit), because `handleSubmit` awaits `addToQueue`, which persists the file
before it can navigate. The symptom is a submit button stuck on "Wird
hochgeladen…" and a timeout waiting for /feed — which reads like an app hang and
cost real time to run down.

Skip those four (the three above plus the new rejection-visible) on WebKit only,
behind a named helper carrying the full explanation, so the next person gets the
answer instead of the investigation. Deliberately narrow: WebKit still runs every
API-driven upload test, all of 01-auth, 03-feed and 06-export — including the
keepsake download, which only WebKit can meaningfully verify. Chromium continues
to run all 14.

Worth being explicit, since these tests exist to protect iOS: real Safari
supports Blobs in IndexedDB, so this is NOT evidence that the offline upload
queue is broken on the platform. It does mean that guarantee is currently
unverifiable in CI and rests on Chromium coverage plus manual device testing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:14:37 +02:00

60 KiB
Raw Blame History

Project Blueprint: EventSnap

A private, QR-code-accessed photo & video sharing platform for weddings, birthdays, and personal events — built for guests, run by you.


Table of Contents

  1. Overview
  2. Target Audience
  3. Goals & Success Metrics
  4. Feature Set
  5. User Flows
  6. System Architecture
  7. Tech Stack
  8. Third-Party Integrations
  9. Authentication & Authorization
  10. Non-Functional Requirements
  11. Export System
  12. Upload Queue & Limits
  13. Rate Limiting
  14. Database Model (ERM & SQL)
  15. API Specification
  16. Performance
  17. Deployment
  18. Risks & Decision Log
  19. Next Steps

1. Overview

Problem Statement

At private events like weddings and birthday parties, photos and videos are scattered across dozens of guests' phones and never truly shared. Existing solutions (WhatsApp groups, Google Photos shared albums) require accounts, expose personal data, and lack event-specific social features. EventSnap gives every guest instant, frictionless access to a shared, living gallery — no app store, no email, no password.

Vision

A guest scans the QR code on their way in, types their name, and is immediately part of a shared moment. They upload, react, and comment throughout the day. After the event, the host releases the gallery — every guest walks away with a beautiful offline HTML keepsake and the full archive. Guests feel seen; the host keeps everything.

Project Type

Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.

Status

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 for the capability matrix.


2. Target Audience

Primary Users

Event guests — non-technical consumers of all ages, accessing the app via a shared QR code link. They need zero-friction onboarding (name only), intuitive upload from camera or library, and a social feed they can browse and interact with. Mobile cellular or slow Wi-Fi must be assumed as the norm — data efficiency is a first-class concern.

Secondary Users

  • Host — the event organiser (not necessarily technical); manages guests and content via the Host Dashboard.
  • Admin — the developer / technical operator (you); manages configuration, export pipeline, storage limits, and rate limits via the Admin Dashboard.

User Scale

Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 files over the course of a day and the days following.


3. Goals & Success Metrics

Goal Metric
Frictionless guest onboarding Name entry to first upload in < 60 seconds
Reliable media collection 0 lost uploads; all files retrievable after event
Engaged social experience Guests like/comment; hashtag use emerges naturally
Data-light browsing Feed usable on slow cellular via compressed previews
Complete archive export Host releases gallery; guests can download within minutes
Simple deployment Fresh VPS to running app: fill .env, run docker compose up -d

4. Feature Set

Must Have (MVP)

Onboarding & Sessions

  • QR code join flow — event URL encoded in QR code; guests scan to land on the join page
  • Name-only registration — enter display name → server issues a persistent JWT + recovery PIN
  • Session recovery — guest on a new device enters name + PIN to reclaim their account; rate-limited: 3 attempts then 15-minute lockout
  • 30-day session expiry — JWT valid for 30 days, refreshed on activity
  • First-visit onboarding guide — short, dismissible overlay on first login explaining the app (upload, hashtags, feed, PIN importance)

Uploads

  • Photo & video upload — from device library or in-app camera/video capture via getUserMedia
  • Client-side upload queue — select many files at once; app uploads sequentially with per-file progress bars and a retry button; persisted in IndexedDB
  • Caption + hashtag support — optional short text with #hashtags; editable after upload
  • Lossless compression on ingest — server applies lossless compression to all uploads transparently
  • Compressed preview generation — smaller display variant for the feed; full-quality original retained for export only
  • Video thumbnail generation — ffmpeg extracts a poster frame as the feed preview image

Feed & Social

  • Chronological gallery feed — scrollable grid; shows compressed previews
  • Like & comment — guests react to any upload; comments support hashtags
  • Own-content deletion — guests delete only their own uploads and comments
  • Hashtag filtering in live feed — filter gallery by one or more hashtags
  • Real-time feed updates via SSE — new uploads/likes/comments appear without manual refresh
  • SSE lifecycle management — client pauses SSE when app is backgrounded (Page Visibility API); reconnects + delta-fetches on foreground

Roles & Dashboards

  • Host Dashboard — ban/unban users (with modal: hide or keep their uploads?), delete any content, promote guests to Host, toggle "Close event" (locks new uploads; reactions remain open), toggle "Release gallery" (unlocks export)
  • Admin Dashboard — all Host permissions + configure per-user storage quota tolerance, per-file size limits (images & videos separately), rate limits per endpoint class, disk usage widget, export activation + progress bar
  • Admin seeded via .envADMIN_PASSWORD_HASH; Hosts are promoted from guest accounts by Admin

Export

  • Export gate — locked by default; Host/Admin releases post-event via "Release gallery"
  • On-demand generation — releasing triggers an async server-side job; dashboard shows live progress bar; SSE notifies all guests when complete
  • Locked-state message — guests see "Export not yet available — check back after the event"
  • ZIP export — full-quality originals in structured folders
  • HTML offline viewer export — self-contained ZIP with Memories.html, README.txt, all media, client-side hashtag filtering, comments, likes; fully offline
  • HTML export in-app guide — brief modal shown before the HTML download

Should Have (v1.x)

  • Individual file download — full-quality download button per file in the gallery
  • Low-disk alert — background task warns when < 10 GB free
  • Event banner / cover image — configurable event name and hero image on the join page
  • Chunked upload for large videos — resumable upload for files > 100 MB
  • Story-style highlights — Host-curated "best of" selection pinned at the top of the feed
  • Slideshow / presentation mode — fullscreen auto-advancing gallery for a venue screen/TV

Could Have (Future)

  • Multiple simultaneous events (multi-tenancy)
  • Push notifications for new comments on a guest's uploads
  • Per-guest gallery view (filter feed to a single user's uploads)

Out of Scope

  • Native iOS/Android apps
  • iOS/Android native album export (users download ZIP and import via platform file manager)
  • CI/CD pipeline
  • User-to-user direct messaging
  • Payment / monetisation
  • Email-based auth
  • Multi-event management UI

5. User Flows

Core Flow: Guest Joins & Uploads

  1. Guest scans QR code → lands on https://yourdomain.com/
  2. First-visit onboarding overlay appears (dismissible after reading)
  3. Enters display name → POST /api/v1/join
  4. Server creates User, generates 4-digit recovery PIN, stores bcrypt(PIN), issues JWT
  5. PIN displayed prominently — large, bold, with a copy-to-clipboard button and a clear warning; also saved to localStorage and always accessible in "My Account"
  6. Guest lands on gallery feed
  7. Taps upload button → selects multiple files or opens in-app camera
  8. Files enter the client-side upload queue with progress indicators
  9. Per file: server receives → stores original → lossless compress → generate preview + thumbnail → write DB record → broadcast SSE new-upload
  10. All connected guests' feeds update in real time

Secondary Flow: Session Recovery (New Device / Cleared Storage)

  1. Guest enters their name on a new device → server finds an existing match → prompt: "Were you here before? Enter your code."
  2. Guest enters PIN → bcrypt.verify() → match → new JWT issued for existing user_id; PIN saved to localStorage on the new device
  3. Wrong PIN: up to 3 attempts, then 15-minute lockout

Secondary Flow: Returning Guest (Same Device)

  1. App finds valid JWT in localStorage → guest lands directly in feed, no re-login required

Secondary Flow: Event Closed (Uploads Locked)

  1. Host toggles "Close event" → server sets event.uploads_locked_at; SSE broadcasts event-closed
  2. Banner shown: "The event has ended — no new uploads are possible."
  3. Upload button disabled; likes, comments, and browsing remain fully available

Secondary Flow: Host Bans a User

  1. Host taps "Ban user" in Host Dashboard
  2. Modal: "What should happen to this user's uploads?" → "Keep visible" / "Hide"
  3. Host confirms → user.is_banned = true, uploads_hidden = choice
  4. Banned user's next request: HTTP 403

Secondary Flow: Export Activation & Download

  1. Host/Admin taps "Release gallery" → two ExportJob records enqueued
  2. Background tasks process jobs; ExportJob.progress_pct updated in DB; dashboard shows live progress bar
  3. Complete: SSE export-available → guest toast: "The gallery is now ready for download!"
  4. Guests open Export page → two options: ZIP or HTML Viewer
  5. HTML Viewer: in-app guide modal shown before download begins
  6. Download starts; rate limiting enforced per IP

6. System Architecture

Components and Data Flow

┌─────────────────────────────────────────────────────────────────┐
│  Guest Device (iOS Safari / Android Chrome)                     │
│                                                                 │
│  SvelteKit PWA                                                  │
│  ┌──────────┐  ┌────────────┐  ┌──────────┐  ┌─────────────┐  │
│  │  Feed /  │  │  Upload    │  │  Export  │  │  Host /     │  │
│  │  Gallery │  │  Queue     │  │  Page    │  │  Admin      │  │
│  │          │  │ (IndexedDB)│  │          │  │  Dashboard  │  │
│  └──────────┘  └────────────┘  └──────────┘  └─────────────┘  │
│        │  HTTPS REST + SSE (EventSource)                        │
└────────┼────────────────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────┐
│  Caddy 2            │  Port 80/443 · Automatic TLS (Let's Encrypt)
│  Reverse Proxy      │  HTTP/2 · gzip/zstd · Cache-Control headers
└────────┬────────────┘
         │ HTTP internal port 3000
         ▼
┌─────────────────────────────────────────────────────────────────┐
│  Axum HTTP Server (Rust — Single Binary)                        │
│                                                                 │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────────────┐ │
│  │  REST API   │  │  SSE Engine  │  │  Media Static Server   │ │
│  │  /api/v1/*  │  │  /api/v1/   │  │  /media/* (originals,  │ │
│  │             │  │  stream      │  │  previews, thumbnails) │ │
│  └──────┬──────┘  └──────┬───────┘  └────────────────────────┘ │
│         │                │                                      │
│  ┌──────▼──────────────────────┐  ┌──────────────────────────┐ │
│  │  Tower Middleware Stack     │  │  Background Workers      │ │
│  │  · Auth (JWT)               │  │  (tokio::spawn)          │ │
│  │  · Rate Limiting (governor) │  │  · Compression worker    │ │
│  │  · CORS                     │  │  · Preview generator     │ │
│  │  · Request tracing          │  │  · ffmpeg thumbnail      │ │
│  └─────────────────────────────┘  │  · Export generator      │ │
│                                   └──────────────────────────┘ │
└────────────────────┬────────────────────────────────────────────┘
                     │
         ┌───────────┴──────────────┐
         │                          │
         ▼                          ▼
┌─────────────────┐      ┌──────────────────────────┐
│  PostgreSQL 16  │      │  Local File Storage      │
│                 │      │                          │
│  event          │      │  /media/originals/       │
│  user           │      │  /media/previews/        │
│  session        │      │  /media/thumbnails/      │
│  upload         │      │  /exports/               │
│  hashtag        │      │    Gallery.zip           │
│  upload_hashtag │      │    Memories.zip          │
│  comment        │      │  /backups/               │
│  comment_hashtag│      │    db_YYYY-MM-DD.sql.gz  │
│  like           │      └──────────────────────────┘
│  export_job     │
│  config         │
└─────────────────┘

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 for the authoritative definition.

services:
  db:        # postgres:16-alpine, persisted in postgres_data volume
  app:       # ./backend — Rust API on :3000, mounts media_data:/media
  frontend:  # ./frontend — SvelteKit (adapter-node) on :3001
  caddy:     # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend

Caddyfile

{$DOMAIN} {
    reverse_proxy app:3000
    encode zstd gzip

    # Immutable cache for content-hashed SvelteKit assets
    @hashed_assets path_regexp \.[a-f0-9]{8,}\.(js|css|woff2)$
    header @hashed_assets Cache-Control "public, max-age=31536000, immutable"

    # Preview images and thumbnails: browser caches 1 hour
    @previews path /media/previews/* /media/thumbnails/*
    header @previews Cache-Control "public, max-age=3600"

    # Original files: auth-gated, 24h browser cache
    @originals path /media/originals/*
    header @originals Cache-Control "private, max-age=86400"

    # API and SSE: never cache
    @api path /api/*
    header @api Cache-Control "no-store"
}

.env Configuration Variables

# Server
DOMAIN=my-wedding.com
APP_PORT=3000

# Database
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_DB=eventsnap

# Auth
ADMIN_PASSWORD_HASH=$2b$12$...   # bcrypt hash
JWT_SECRET=<random 64-byte key>
SESSION_EXPIRY_DAYS=30

# Event
EVENT_NAME=Max & Maria's Wedding
EVENT_SLUG=max-maria-2026

# Storage & limits (overridable via Admin Dashboard)
MEDIA_PATH=/media
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
DEFAULT_QUOTA_TOLERANCE=0.75
DEFAULT_ESTIMATED_GUEST_COUNT=100
COMPRESSION_WORKER_CONCURRENCY=2

Architecture Pattern

Modular monolith. A single Rust binary behind Caddy. Right-sized for a personal single-event project — no distributed complexity, trivially deployable and restorable.

SSE Event Catalogue

Event Payload Trigger
new-upload { id, preview_url, uploader, caption, created_at } Upload processing complete
new-comment { id, upload_id, body, uploader, created_at } Comment posted
like-update { upload_id, like_count } Like toggled
upload-deleted { upload_id } Upload deleted
event-closed {} Host locks uploads
event-opened {} Host unlocks uploads
export-available { types: ["zip","html"] } Export generation complete
upload-processed { upload_id, preview_url, thumbnail_url } Server-side compression / preview generation finished
upload-error { upload_id, message } Compression / preview generation failed
export-progress { type, progress_pct } Periodic progress update from an export job

Client SSE lifecycle: visibilitychange: hidden → close connection · visible → reconnect + delta-fetch via GET /api/v1/feed/delta?since=


7. Tech Stack

Layer Technology Rationale
Frontend SvelteKit + TypeScript Small JS bundles, excellent mobile performance, built-in routing
Styling Tailwind CSS Utility-first, mobile-first; zero runtime CSS overhead
Backend Rust + Axum Developer preference; memory safety, single-binary deploy
Async Runtime Tokio De-facto Rust async runtime; Axum is built on it
Database Driver SQLx Async PostgreSQL with compile-time query checking; automatic prepared statements
Database PostgreSQL 16 Robust, relational; straightforward to back up
Auth Custom JWT (jsonwebtoken crate) No external service needed; name + PIN is the full auth model
Image Compression image crate + oxipng Lossless PNG compression; JPEG preview generation
Video Processing ffmpeg (tokio::process::Command) Lossless pass, thumbnail extraction, preview transcoding
File Storage Local disk (/media/) No S3 cost; 70 GB available is ample
Real-Time Axum SSE + tokio::sync::broadcast Native, lightweight, perfect for fan-out at this scale
ZIP Export async-zip crate Streaming ZIP generation without buffering the full archive in RAM
HTML Export minijinja (Rust templating) Generates Memories.html as a single self-contained file
Rate Limiting Custom in-memory sliding-window limiter (services/rate_limiter.rs) Per IP / per user; limits read from config DB table on each request; hot-reloadable without restart
Reverse Proxy Caddy 2 Automatic HTTPS via Let's Encrypt; zero certificate management
Containerisation Docker + Docker Compose Full stack in one file; .env for all config; single-command deploy
Infrastructure Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) Well-sized; 20 TB/month means post-event bulk downloads are no issue

8. Third-Party Integrations

Service Purpose Notes
Let's Encrypt (via Caddy) Automatic TLS certificates Required — getUserMedia only works over HTTPS; Caddy handles renewals
ffmpeg Video thumbnail extraction, lossless compression, preview transcoding Installed in the Docker image
Browser getUserMedia API In-app camera & video capture Works on iOS Safari 14.3+, all modern Android browsers
Page Visibility API SSE lifecycle management Browser-native; no library needed

No paid third-party services required.


9. Authentication & Authorization

Auth Method

New guest flow:

  1. POST /api/v1/join with { display_name }
  2. Server creates User, generates a 4-digit recovery PIN, stores bcrypt(PIN), issues JWT (user_id, event_id, role, exp: now+30d)
  3. JWT stored in localStorage; PIN also saved to localStorage (plaintext, on-device)
  4. PIN displayed prominently in a registration modal with a copy button
  5. PIN always accessible afterwards in the "My Account" settings page — the SvelteKit component reads it directly from localStorage

Recovery flow (new device / cleared storage):

  1. Guest enters their name → server finds an existing match → prompts for PIN
  2. bcrypt.verify(input, stored_hash) → match → new JWT issued for existing user_id; PIN saved to localStorage on the new device
  3. 3 failed attempts → 15-minute lockout
  4. Name collision → PIN is the only disambiguator; Host can manually re-link via the Host Dashboard if PIN is lost

Admin auth: Password compared against ADMIN_PASSWORD_HASH from .env; Admin JWT stored in sessionStorage (clears on tab close)

Host auth: Guest accounts promoted to role: host by Admin; authenticate identically to guests

Roles & Permissions

Role Permissions
Guest Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release), pick data mode, read privacy note
Host All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, demote other Hosts to guest (never self), reset guest PINs (planned), lock/unlock uploads, release gallery export
Admin All Host permissions + reset any non-admin PIN, configure storage/file/rate limits with on/off toggles, edit quota tolerance and per-area quota toggles, edit the Datenschutzhinweis, view disk usage, manage app config, trigger export generation
Banned Guest View feed only — cannot upload, like, comment, or export

Compliance

EU-hosted (Hetzner, Germany). Guests provide only a display name. Join page notice: "Your name and uploads are stored for this event. Admins can delete content." No formal GDPR tooling required for a private personal project.


10. Non-Functional Requirements

Requirement Target
Feed performance Loads < 1s on 4G via compressed previews
Upload UX Non-blocking client queue; guest browses feed while uploads proceed
Availability Best-effort; single VPS; no SLA
Scalability ~100 users, ~1,000 uploads over 2448h — well within CX33 capacity
Disk storage ~70 GB available; dynamic quota + file limits prevent exhaustion
Per-file limits Configurable in Admin Dashboard; env defaults: images 20 MB, videos 500 MB
Rate limiting Configurable per endpoint class; hot-reloadable without restart
Lossless compression Applied transparently on ingest; zero quality loss
SSE efficiency Client pauses on app background; server cleans up idle connections > 5 min
Mobile support Primary: iOS Safari 14.3+, Android Chrome; responsive, touch-first, large tap targets
Camera capture HTTPS required; Caddy ensures this automatically
UI language German (DE) — all UI strings, error messages, export filenames, and guides
Accessibility Best effort; semantic HTML, sufficient contrast

11. Export System

Export is generated on-demand after the Host/Admin releases it — nothing is pre-built during the event.

Lifecycle

  1. "Release gallery" tapped → event.export_released_at set + two ExportJob records enqueued
  2. Background Tokio tasks process jobs; ExportJob.progress_pct updated continuously in DB
  3. Dashboard shows live progress bar (SSE-driven)
  4. Complete: export_zip_ready / export_html_ready set → SSE export-available broadcast → guest toast

Export Type 1: ZIP Archive (Gallery.zip)

Streamed via async-zip — no full archive RAM buffer:

Gallery/
  Photos/
    2026-06-14_15-32_Anna_DSC_0042.jpg
    ...
  Videos/
    2026-06-14_19-11_Tom_VID_0007.mp4
    ...

Full-quality originals only. File naming: {date}_{time}_{username}_{original_filename}

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.

Memories/
  index.html            ← entry point; open this in any browser
  _app/
    immutable/...       ← hashed JS/CSS bundles (viewer SPA)
  data.json             ← event metadata, posts, comments, likes, hashtags
  media/
    {id}_thumb.jpg      ← grid thumbnails (≈400 px wide)
    {id}_full.jpg/.mp4  ← full-size media for the lightbox

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.

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

Build flow: The viewer lives at frontend/export-viewer/ and is built ahead of time into 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.

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.

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.")


12. Upload Queue & Limits

Client-Side Upload Queue

  • Select any number of files at once (photo + video mix)
  • Queued in IndexedDB — survives page reloads and app backgrounding
  • Uploads proceed sequentially — one file at a time — to avoid saturating mobile connections
  • Per-file display: filename, size, progress bar, status (Pending / Uploading / Done / Error)
  • Retry button on failed uploads
  • Guest can freely browse the feed while the queue runs in the background

Server-Side Limits

Limit Default Scope
Max image file size 20 MB Per file
Max video file size 500 MB Per file
Per-user storage quota Dynamic (formula) Per user per event
Upload rate 10 / hour Per user_id
Feed request rate 60 / min Per IP
Export download rate 3 / day Per IP

Dynamic Per-User Storage Quota

quota_per_user = ⌊(free_disk_bytes × tolerance) / max(active_uploaders, 1)⌋
  • free_disk_bytes: queried via the sysinfo crate on each upload attempt
  • tolerance: admin-configurable (default: 0.75)
  • active_uploaders: users with ≥ 1 non-deleted upload

Example: 70 GB free · tolerance 0.75 · 30 active uploaders → ~1.75 GB per user

On quota exceeded: HTTP 429 — "Du hast dein Upload-Limit für dieses Event erreicht."


13. Rate Limiting

Tower middleware layers on the Axum router. All limits stored in the config table — hot-reloadable from the Admin Dashboard without a restart.

Endpoint Class Default Key
POST /api/v1/upload 10 / hour per user_id
GET /api/v1/feed 60 / min per IP
GET /media/previews/* 120 / min per IP
GET /api/v1/export/zip 3 / day per IP
GET /api/v1/export/html 3 / day per IP
POST /api/v1/join 5 / min per IP
Recovery PIN attempts 3 then 15-min lockout per user_id

Rate limit response: HTTP 429 — "Zu viele Anfragen. Bitte warte kurz und versuche es erneut."


14. Database Model (ERM & SQL)

Entity-Relationship Overview

event ──< user ──< session
  │          │
  │          └──< upload ──< comment ──< comment_hashtag >── hashtag
  │                  │                                            ▲
  │                  └──< like                                    │
  │                  └──< upload_hashtag >─────────────────────────
  │
  └──< export_job
  └──  config (key/value)

Cardinalities:

  • event 1:N user, upload, export_job
  • user 1:N session, upload, comment
  • upload 1:N comment, like
  • upload M:N hashtag via upload_hashtag
  • comment M:N hashtag via comment_hashtag

DDL: Full Schema

-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Enums
CREATE TYPE user_role     AS ENUM ('guest', 'host', 'admin');
CREATE TYPE export_type   AS ENUM ('zip', 'html');
CREATE TYPE export_status AS ENUM ('pending', 'running', 'done', 'failed');

-- ─────────────────────────────────────────
-- event
-- ─────────────────────────────────────────
CREATE TABLE event (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    slug                TEXT        NOT NULL UNIQUE,
    name                TEXT        NOT NULL,
    cover_image_path    TEXT,
    is_active           BOOLEAN     NOT NULL DEFAULT TRUE,
    uploads_locked_at   TIMESTAMPTZ,                    -- NULL = open
    export_released_at  TIMESTAMPTZ,                    -- NULL = locked
    export_zip_ready    BOOLEAN     NOT NULL DEFAULT FALSE,
    export_html_ready   BOOLEAN     NOT NULL DEFAULT FALSE,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─────────────────────────────────────────
-- user
-- ─────────────────────────────────────────
CREATE TABLE "user" (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id            UUID        NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    display_name        TEXT        NOT NULL,
    role                user_role   NOT NULL DEFAULT 'guest',
    is_banned           BOOLEAN     NOT NULL DEFAULT FALSE,
    uploads_hidden      BOOLEAN     NOT NULL DEFAULT FALSE,
    recovery_pin_hash   TEXT        NOT NULL,           -- bcrypt(PIN)
    total_upload_bytes  BIGINT      NOT NULL DEFAULT 0, -- running sum for quota checks
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- 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).
);

-- ─────────────────────────────────────────
-- session
-- ─────────────────────────────────────────
CREATE TABLE session (
    id           UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id      UUID        NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
    token_hash   TEXT        NOT NULL UNIQUE,           -- SHA-256(JWT)
    expires_at   TIMESTAMPTZ NOT NULL,
    last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─────────────────────────────────────────
-- upload
-- ─────────────────────────────────────────
CREATE TABLE upload (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id            UUID        NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    user_id             UUID        NOT NULL REFERENCES "user"(id),
    original_path       TEXT        NOT NULL,
    preview_path        TEXT,                           -- NULL until compression completes
    thumbnail_path      TEXT,                           -- NULL until ffmpeg thumbnail ready
    mime_type           TEXT        NOT NULL,
    original_size_bytes BIGINT      NOT NULL,
    caption             TEXT,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at          TIMESTAMPTZ                     -- soft delete
);

-- ─────────────────────────────────────────
-- hashtag
-- ─────────────────────────────────────────
CREATE TABLE hashtag (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id   UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    tag        TEXT NOT NULL,                           -- normalised: lowercase, no #
    UNIQUE (event_id, tag)
);

CREATE TABLE upload_hashtag (
    upload_id   UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
    hashtag_id  UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
    PRIMARY KEY (upload_id, hashtag_id)
);

-- ─────────────────────────────────────────
-- comment
-- ─────────────────────────────────────────
CREATE TABLE comment (
    id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    upload_id   UUID        NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
    user_id     UUID        NOT NULL REFERENCES "user"(id),
    body        TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at  TIMESTAMPTZ
);

CREATE TABLE comment_hashtag (
    comment_id  UUID NOT NULL REFERENCES comment(id) ON DELETE CASCADE,
    hashtag_id  UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
    PRIMARY KEY (comment_id, hashtag_id)
);

-- ─────────────────────────────────────────
-- like
-- ─────────────────────────────────────────
CREATE TABLE "like" (
    upload_id   UUID        NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
    user_id     UUID        NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (upload_id, user_id)
);

-- ─────────────────────────────────────────
-- export_job
-- ─────────────────────────────────────────
CREATE TABLE export_job (
    id            UUID           PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id      UUID           NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    type          export_type    NOT NULL,
    status        export_status  NOT NULL DEFAULT 'pending',
    progress_pct  SMALLINT       NOT NULL DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
    file_path     TEXT,                                -- NULL until generation complete
    error_message TEXT,
    created_at    TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
    completed_at  TIMESTAMPTZ,
    UNIQUE (event_id, type)                            -- max one active job per type
);

-- ─────────────────────────────────────────
-- config  (admin-configurable runtime settings)
-- ─────────────────────────────────────────
CREATE TABLE config (
    key        TEXT        PRIMARY KEY,
    value      TEXT        NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Seeded from .env on first start; ON CONFLICT = keep existing overrides
INSERT INTO config (key, value) VALUES
    ('max_image_size_mb',       '20'),
    ('max_video_size_mb',       '500'),
    ('upload_rate_per_hour',    '100'),  -- raised from 10 in migration 015 (guests upload bursts of 10-20)
    ('feed_rate_per_min',       '60'),
    ('export_rate_per_day',     '3'),
    ('quota_tolerance',         '0.75'),
    ('estimated_guest_count',   '100'),
    ('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;

Indexes

-- Feed main query (covers > 90% of all DB requests)
CREATE INDEX idx_upload_event_created
    ON upload(event_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- A user's own uploads (quota check, "My Account")
CREATE INDEX idx_upload_user
    ON upload(user_id)
    WHERE deleted_at IS NULL;

-- Comments per upload
CREATE INDEX idx_comment_upload
    ON comment(upload_id)
    WHERE deleted_at IS NULL;

-- Like count (without this = full table scan on every feed request)
CREATE INDEX idx_like_upload
    ON "like"(upload_id);

-- Hashtag filtering
CREATE INDEX idx_upload_hashtag_hashtag
    ON upload_hashtag(hashtag_id);

CREATE INDEX idx_hashtag_event_tag
    ON hashtag(event_id, tag);

-- Session lookup (runs on every authenticated API request)
CREATE UNIQUE INDEX idx_session_token_hash
    ON session(token_hash);

-- Expired session cleanup
CREATE INDEX idx_session_expires
    ON session(expires_at);

-- User lookup for recovery flow
CREATE INDEX idx_user_event_name
    ON "user"(event_id, display_name);

-- Export job status
CREATE UNIQUE INDEX idx_export_job_event_type
    ON export_job(event_id, type);

Views

-- v_feed: uploads with uploader name, like count, and comment count
-- Used in the feed endpoint as a single efficient query
CREATE VIEW v_feed AS
SELECT
    u.id,
    u.event_id,
    u.user_id,
    usr.display_name             AS uploader_name,
    usr.is_banned,
    usr.uploads_hidden,
    u.preview_path,
    u.thumbnail_path,
    u.mime_type,
    u.caption,
    u.created_at,
    COUNT(DISTINCT l.user_id)    AS like_count,
    COUNT(DISTINCT c.id)         AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l  ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
  AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;


-- v_hashtag_counts: most-used hashtags for an event (for filter chips)
CREATE VIEW v_hashtag_counts AS
SELECT
    h.event_id,
    h.tag,
    COUNT(uh.upload_id) AS upload_count
FROM hashtag h
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
JOIN upload u          ON u.id = uh.upload_id AND u.deleted_at IS NULL
GROUP BY h.event_id, h.id, h.tag
ORDER BY upload_count DESC;

15. API Specification

Versioning & Base Path

All endpoints under /api/v1/. Breaking changes introduced as /api/v2/ without removing existing routes.

Authentication

  • Guests / Hosts / Admin: Authorization: Bearer <JWT>
  • Missing token: 401 Unauthorized
  • Insufficient permissions: 403 Forbidden

DTOs

// ─── Auth ──────────────────────────────────────────────────────
interface JoinRequest    { display_name: string }
interface JoinResponse   { jwt: string; pin: string; user_id: string; is_new: boolean }

interface RecoverRequest  { display_name: string; pin: string }
interface RecoverResponse { jwt: string; user_id: string }

interface AdminLoginRequest  { password: string }
interface AdminLoginResponse { jwt: string }

// ─── Feed ──────────────────────────────────────────────────────
interface UploadDto {
    id: string;           uploader_name: string;  user_id: string;
    preview_url: string;  mime_type: string;
    caption: string | null;                        hashtags: string[];
    like_count: number;   comment_count: number;   liked_by_me: boolean;
    created_at: string;
}
interface FeedResponse  { uploads: UploadDto[]; next_cursor: string | null }
interface DeltaResponse { uploads: UploadDto[]; deleted_ids: string[] }

// ─── Upload ────────────────────────────────────────────────────
// Request: multipart/form-data; fields: file (Blob), caption? (string), hashtags? (CSV)
interface UploadResponse    { upload: UploadDto }
interface EditUploadRequest { caption?: string; hashtags?: string[] }

// ─── Comments ──────────────────────────────────────────────────
interface CommentDto {
    id: string;  upload_id: string;  user_id: string;
    uploader_name: string;  body: string;
    hashtags: string[];  created_at: string;
}
interface CommentsResponse  { comments: CommentDto[] }
interface AddCommentRequest { body: string }

// ─── Export ────────────────────────────────────────────────────
interface ExportJobDto { status: 'locked'|'pending'|'running'|'done'|'failed'; progress_pct: number }
interface ExportStatusResponse { released: boolean; zip: ExportJobDto; html: ExportJobDto }

// ─── Host / Admin ──────────────────────────────────────────────
interface UserDto {
    id: string;  display_name: string;  role: string;
    is_banned: boolean;  uploads_hidden: boolean;
    upload_count: number;  total_upload_bytes: number;  created_at: string;
}
interface BanRequest { hide_uploads: boolean }

interface ConfigDto { [key: string]: string }
interface StatsDto {
    user_count: number;  upload_count: number;  comment_count: number;
    disk_total_bytes: number;  disk_used_bytes: number;  disk_free_bytes: number;
}

Endpoints

Auth

Method Path Auth Description
POST /api/v1/join Join event; returns JWT + PIN
POST /api/v1/recover Recover session by name + PIN
POST /api/v1/admin/login Admin password auth
DELETE /api/v1/session Guest Log out (invalidate JWT)

Feed & Uploads

Method Path Auth Query Description
GET /api/v1/feed Guest cursor, limit (def. 20), hashtag Paginated feed (cursor-based)
GET /api/v1/feed/delta Guest since (ISO timestamp) Uploads since last SSE reconnect
POST /api/v1/upload Guest Upload a file (multipart/form-data)
PATCH /api/v1/upload/{id} Guest (own) Edit caption / hashtags
DELETE /api/v1/upload/{id} Guest (own) Soft-delete own upload
GET /api/v1/upload/{id}/original Guest Download full-quality original

Social

Method Path Auth Description
POST /api/v1/upload/{id}/like Guest Toggle like (like / unlike)
GET /api/v1/upload/{id}/comments Guest List comments for an upload
POST /api/v1/upload/{id}/comment Guest Add a comment
DELETE /api/v1/comment/{id} Guest (own) Delete own comment
GET /api/v1/hashtags Guest All event hashtags with counts (for filter chips)

SSE

Method Path Auth Description
GET /api/v1/stream Guest Open SSE connection; delivers all feed events

Export

Method Path Auth Description
GET /api/v1/export/status Guest Release status and generation progress
GET /api/v1/export/zip Guest Download Gallery.zip (only if export_zip_ready)
GET /api/v1/export/html Guest Download Memories.zip (only if export_html_ready)

Host Dashboard

Method Path Auth Description
GET /api/v1/host/users Host List all event users
POST /api/v1/host/users/{id}/ban Host Ban user (BanRequest)
POST /api/v1/host/users/{id}/unban Host Lift ban
PATCH /api/v1/host/users/{id}/role Host Set role ({ role: "guest"|"host" })
DELETE /api/v1/host/upload/{id} Host Delete any upload
DELETE /api/v1/host/comment/{id} Host Delete any comment
POST /api/v1/host/event/close Host Lock new uploads
POST /api/v1/host/event/open Host Unlock new uploads
POST /api/v1/host/gallery/release Host Trigger export generation + set release

Admin Dashboard

Method Path Auth Description
GET /api/v1/admin/stats Admin Disk usage, user/upload counts
GET /api/v1/admin/config Admin Retrieve all configurable settings
PATCH /api/v1/admin/config Admin Update settings (ConfigDto)
GET /api/v1/admin/export/jobs Admin Export job status and progress

Error Format

{
  "error": "upload_quota_exceeded",
  "message": "Du hast dein Upload-Limit für dieses Event erreicht.",
  "status": 429
}

HTTP Status Codes

Code Usage
200 OK Successful GET / PATCH
201 Created Successful POST (upload, comment, join)
204 No Content Successful action with no response body (like toggle, logout)
400 Bad Request Validation error, missing required field
401 Unauthorized Missing or invalid JWT
403 Forbidden Insufficient permissions or banned user
404 Not Found Resource not found or soft-deleted
413 Payload Too Large File exceeds configured size limit
429 Too Many Requests Rate limit or quota exceeded
500 Internal Server Error Unexpected server error

16. Performance

Strategy Overview

EventSnap does not need a dedicated caching layer like Redis or Memcached. The combination of OS page cache, HTTP cache headers, SQL indexes, and SQLx prepared statements delivers excellent performance for the projected load (~100 users, ~1,000 uploads). Redis would make sense with thousands of concurrent users across multiple app instances — that is not the case here.

1. HTTP Caching (Highest Impact)

Asset Type Cache-Control Effect
SvelteKit JS/CSS/Fonts (content-hashed) public, max-age=31536000, immutable Loaded once, cached forever — 0 bytes on subsequent visits
Preview images & thumbnails public, max-age=3600 Once seen = never re-downloaded within the same hour
Original files (auth-gated) private, max-age=86400 One download per day per browser
API endpoints & SSE no-store Feed always fresh

A guest who reloads the feed or switches back to the app does not re-download previews they have already seen. On mobile networks this is the single largest contributor to perceived speed and data efficiency.

2. OS Page Cache (Free, Automatic)

Linux uses the RAM not occupied by PostgreSQL and the Rust binary (~7 GB of 8 GB) as a file cache automatically. Frequently accessed preview images and thumbnails stay in memory with no additional infrastructure required. No Redis needed.

3. SQL Prepared Statements via SQLx

SQLx validates all queries at compile time and submits them as prepared statements to PostgreSQL on first execution. Subsequent calls skip the parse/plan step entirely — particularly valuable for the feed endpoint, which runs on every page load:

-- Critical feed query via v_feed (held as a prepared statement):
SELECT *
FROM v_feed
WHERE event_id = $1
  AND created_at < $2       -- cursor-based pagination
ORDER BY created_at DESC
LIMIT $3;

4. Database Connection Pooling

let pool = PgPoolOptions::new()
    .max_connections(10)    // sufficient for 100 concurrent users
    .min_connections(2)     // always keep 2 connections warm
    .connect(&database_url)
    .await?;

No connection establishment latency per request.

5. Strategic SQL Indexes

Index Query it accelerates Rationale
idx_upload_event_created (Partial) Feed main query Covers > 90% of all DB requests; partial index on non-deleted rows only
idx_like_upload Like count in v_feed Without it: full table scan on every feed request
idx_session_token_hash (UNIQUE) Auth middleware Runs on every authenticated API request
idx_hashtag_event_tag Hashtag filtering Direct lookup instead of sequential scan
idx_comment_upload (Partial) Comment lists Partial on non-deleted; reduces index size

6. Two-Tier Media Serving

Tier Used For Typical Size Serving Policy
Preview Feed grid ~50150 KB (JPEG, 800px) Always; browser-cached 1h
Thumbnail Video poster frame in feed ~3080 KB (WebP) Feed grid for videos; browser-cached 1h
Original Export, individual download 420 MB (image), up to 500 MB (video) Explicit request only

During normal operation guests load previews exclusively — originals are served only on export or conscious download. This reduces feed traffic by ~95%.

7. Compression Worker Pool

// Semaphore bounds concurrent compression tasks
let semaphore = Arc::new(Semaphore::new(concurrency_limit));

tokio::spawn(async move {
    let _permit = semaphore.acquire().await.unwrap();
    compress_and_generate_preview(upload_id, path).await;
});

The upload confirmation is returned immediately after the original is written to disk — compression and preview generation run asynchronously afterwards. COMPRESSION_WORKER_CONCURRENCY (default: 2) prevents RAM spikes on the 8 GB CX33.

8. SSE Connection Management

  • Server cleans up SSE connections idle for > 5 minutes
  • Client proactively closes the connection on visibilitychange: hidden
  • On reconnect: only a delta via GET /api/v1/feed/delta?since= — no full feed re-fetch

Tuning for the CX33 (8 GB RAM), set via Docker Compose environment variables:

shared_buffers       = 2GB    # 25% of RAM for PostgreSQL's own buffer cache
work_mem             = 16MB   # for sort and join operations
maintenance_work_mem = 256MB  # for VACUUM, index builds
effective_cache_size = 6GB    # planner hint (OS cache + shared_buffers)

17. Deployment

Complete Setup on a Fresh VPS

# 1. Install Docker (includes Compose plugin)
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER && newgrp docker

# 2. Copy project files to the server
scp -r eventsnap/ user@your-vps:/opt/eventsnap
cd /opt/eventsnap

# 3. Configure
cp .env.example .env
nano .env   # set DOMAIN, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.

# 4. Start
docker compose up -d

Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at https://DOMAIN within ~30 seconds.

eventsnap/
  backend/
    src/
      main.rs
      routes/           ← endpoint handlers
      models/           ← DB structs (SQLx)
      workers/          ← compression, export
      sse.rs
    migrations/         ← SQLx migration files
    Cargo.toml
  frontend/
    src/
      routes/
      lib/
        upload-queue.ts ← IndexedDB-backed queue
        sse.ts          ← SSE client + Page Visibility lifecycle
    package.json
  docker-compose.yml
  Caddyfile
  .env.example
  README.md

Backup Strategy

Three artefacts in three places: the database, the media_data volume (originals + derivatives), and the separate exports_data volume. See README.md for the exact commands.

Everything runs through docker compose / docker run, because DATABASE_URL and the /media and /exports paths only exist inside the compose network — they are not host paths, and DATABASE_URL is never exported into an operator's shell.

Export archives are deliberately outside MEDIA_PATH (EXPORT_PATH=/exports): a keepsake contains every photo in the event, and keeping it off the media tree is what stops it being reachable except through the ticket-gated handler. A backup of the media volume alone silently loses every generated keepsake.


18. Risks & Decision Log

Known Risks

Risk Mitigation
Disk exhaustion Dynamic quota + per-file limits + admin disk widget + low-disk alert (< 10 GB free)
iOS Safari getUserMedia bugs Test early on real iOS devices; "Upload from library" always equally prominent
Large video upload connection drops Client queue with retry; chunked upload planned for v1.x (files > 100 MB)
Recovery PIN forgotten PIN visually unmissable at registration + copy button + always in "My Account"; Host can manually re-link
Export generation time async-zip streams directly to disk (no RAM buffer); progress bar + SSE notification handle UX
ffmpeg RAM spikes Bounded worker pool via tokio::sync::Semaphore (default: 2 concurrent tasks)

Decision Log

Decision Chosen Rationale
Recovery mechanism 4-digit PIN, stored in localStorage + "My Account" page; Host/Admin can issue a fresh PIN via the user list when a guest loses it entirely Simple for non-technical guests; no email required; Host-mediated reset preserves the no-email identity model
Host demotion authority Hosts can demote other Hosts (never themselves); Admin can demote anyone non-admin Avoids requiring an Admin for every staffing change at the event
Privacy note Free-text, plain (no HTML), admin-edited, rendered preformatted in My Account Many events need a per-event privacy statement; preformatted text avoids any markup-injection risk
Data mode Per-device localStorage setting (Saver / Original), default Saver A guest can be on cellular on one device and Wi-Fi on another; per-device is the right scope
Rate-limit & quota toggles On/off switches plus numeric values in the config table Lets the Admin disable enforcement for testing or trusted events without redeploying
Admin dashboard path /admin (standard route) Correct auth checks are the security; obscure paths add no meaningful protection
ZIP contents Full-quality originals only (Photos + Videos folders) Clean and simple; no metadata JSON
HTML export assets Fully offline (relative paths, CSS/JS inlined) True offline experience; no external dependencies
Caching layer No Redis / Memcached OS page cache + HTTP headers + indexes are sufficient for this load
Reverse proxy Caddy instead of Nginx + Certbot Automatic TLS with zero manual configuration
CI/CD None — docker compose up only Right-sized for a personal project

19. Next Steps

  1. Provision Hetzner CX33 — create server, point DNS A record to IP, install Docker
  2. Scaffold monorepobackend/, frontend/, docker-compose.yml, Caddyfile, .env.example
  3. DB schema + SQLx migrations — all tables, views, and indexes; test with docker compose up db
  4. Auth flow — join endpoint, JWT issuance, PIN generation + bcrypt storage + recovery verification
  5. Upload pipeline — multipart POST → disk write → compression worker (Tokio task) → preview generation → DB record → SSE broadcast
  6. Client upload queue — SvelteKit component: IndexedDB queue, sequential upload, per-file progress, retry, background operation
  7. Gallery feed — grid view with compressed previews, SSE live updates, hashtag filter chips, Page Visibility API SSE pause/resume + delta-fetch
  8. Camera capturegetUserMedia SvelteKit component; test on real iOS and Android hardware
  9. Host Dashboard — ban flow with upload visibility modal, delete, promote, event lock toggle, export release toggle
  10. Admin Dashboard — config UI (limits, rates, quota tolerance), disk usage widget, export progress bar
  11. Export engine — async ZIP streamer, then HTML bundle generator (Memories.html + README.txt via minijinja)
  12. Rate limiting middlewaretower-governor layers on all endpoint classes; read limits from config table
  13. "My Account" page — PIN display from localStorage, session info
  14. Onboarding guide + HTML export guide — dismissible overlays; guide-seen flag in localStorage
  15. End-to-end test event — dry run with 10+ real devices on different networks (Wi-Fi + cellular) before any real event

Appendix A: Rust Crates

Crate Purpose
axum Web framework
tokio Async runtime
sqlx Async PostgreSQL driver; compile-time query checking; prepared statements; migrations
jsonwebtoken JWT sign / verify
bcrypt PIN + admin password hashing
uuid UUID v7 (time-sortable)
serde / serde_json Serialisation
tower / tower-http Middleware stack (CORS, compression, static files, request tracing)
(custom limiter, no crate) Token-bucket / sliding window built in-tree at services/rate_limiter.rs
tokio::sync::Semaphore Bounded worker pool for compression tasks
async-zip Streaming ZIP export (no in-memory buffer)
minijinja HTML export template rendering (Memories.html)
image Image decoding, resizing, preview generation
oxipng Lossless PNG compression
tracing + tracing-subscriber Structured logging
anyhow Ergonomic error handling
dotenvy .env file loading
sysinfo Disk usage querying for dynamic quota calculation

Appendix B: Frontend Libraries (npm)

Library Purpose
@sveltejs/kit Routing, SSR, PWA manifest + service worker
qrcode Client-side QR code generation for the event join link (Host/Admin view)
idb Type-safe IndexedDB wrapper for the upload queue

Blueprint finalised — March 2026