fabi 8e906d7866 refactor(export): single epoch authority; fix upload-path TOCTOU losing photos
A deep investigative review of the export concurrency (four parallel adversarial
reviewers) found that three rounds of race fixes had been chasing the wrong bug, and
that the model itself was the root cause. This replaces the model and fixes the real
data loss.

THE ACTUAL BUG (upload path, not the state machine)
  `upload` checked `uploads_locked_at` BEFORE streaming the body — minutes, for a large
  video — then committed the row without re-checking. A release landing mid-upload let the
  row commit AFTER the export snapshot: the photo appeared in the live feed and was
  PERMANENTLY missing from the keepsake, with nothing to regenerate it. release_gallery's
  comment claims to prevent exactly this; it never did. Every prior round fixed the DB
  state machine, and the leak was upstream of it.
  Fix: re-assert the lock under `SELECT ... FOR SHARE` INSIDE the commit tx. That row lock
  conflicts with release_gallery's `UPDATE event`, so either the upload commits first (and
  the release — hence the snapshot — is ordered after it) or the release wins and the
  upload is rejected as `uploads_locked` (reversible; the client keeps the blob).
  Regression test verified NON-VACUOUS: with the fix reverted it fails, reporting accepted
  uploads missing from the keepsake.

THE MODEL (migration 014)
  "Which generation is current?" had no single answer: it was assembled from three
  separately-written pieces across two tables (`export_released_at`, a per-job
  `release_seq`, and cached `export_{zip,html}_ready` flags). Keeping them in agreement
  took ~10 hand-written guards; each review round found one more path that slipped through.
  Now: ONE monotonic `event.export_epoch`, bumped in the same UPDATE as any change to
  `export_released_at`. Each job carries a copy. Downloadable IFF
  `released AND job.epoch = event.export_epoch AND status = 'done'` — readiness DERIVED,
  never stored. A retired-epoch worker is INERT BY CONSTRUCTION: losing a race can no
  longer corrupt state, only waste work.
  Deleted: both ready-flag columns, both ready-flip blocks, `if ready { continue }`, the
  reopen seq-bump, open_event's transaction, and the cross-table claim guard.

  That last one was also UNSOUND: under READ COMMITTED, a blocked UPDATE re-evaluates its
  WHERE against the updated target row but answers subqueries on OTHER tables from the
  original snapshot — so the previous `EXISTS (SELECT ... FROM event ...)` claim guard
  could admit a claim against an already-reopened event while RETURNING the post-bump seq.
  Every guard is now same-row (`epoch = $n`), which EPQ re-evaluates correctly.

OTHER REAL DEFECTS FIXED
  - release_gallery committed the release and THEN awaited the enqueue inside the handler
    future. Axum drops that future on client disconnect → event released, uploads locked,
    ZERO export_job rows, host unable to retry ("bereits freigegeben"), restart-only
    recovery. Now one tx; workers spawn (detached) after commit.
  - No flush/fsync before the tmp→final rename. async_zip's close() never flushes the inner
    file and tokio's File drop DISCARDS write errors — so a full disk silently produced a
    truncated archive that was then marked done and published, after which the last good
    generation was pruned. Now flush + sync_all, which also surfaces ENOSPC.
  - Download read the ready flag and the file path in TWO queries; a reopen between them
    served a keepsake that should 404. Now one read through the `export_current` view.
  - `if !src.exists() { continue }` then `open()?` — a TOCTOU that turned a tolerated gap
    into a hard failure of the ENTIRE keepsake (unretryable while released). Now open-first,
    warn, and skip.
  - Recovery was flag-driven and never checked the disk: a `done` row whose archive was gone
    was a permanent dead end needing manual DB surgery. Now verifies the file and re-arms.
  - Temp files/dirs leaked on every error path (the leak is what fills the disk that
    corrupts the next archive). Now cleaned up.
  - Export archives were not event-scoped (`viewer_tmp_` already was), so a second event
    would collide on paths and one event's prune would delete another's live keepsake.
  - Host deletes after release never regenerated the keepsake — a photo removed on request
    lived on in the archive forever. Now bumps the epoch and rebuilds without it.
  - claim_job swallowed DB errors as "someone else won", stranding a job pending at 0%.
  - export_status reported retired-epoch rows (a progress bar that never moves).
  - The startup sweep's single-instance assumption is now documented, not hidden.

Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop (incl. 2 new regressions; the
upload-acceptance one confirmed to fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:48:38 +02:00

EventSnap

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


What is EventSnap?

At private events, photos and videos are scattered across dozens of guests' phones and never truly shared. Existing solutions (WhatsApp groups, Google Photos) 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.

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.

Project type: Mobile-first PWA — runs in any browser, no installation required.
Scale: Personal / private use — one event at a time, ~100 guests, ~1,000 files.


Features

MVP

Area Feature
Onboarding QR code join flow, name-only registration, persistent JWT + recovery PIN, 30-day sessions
Uploads Photo & video from library or live camera, client-side IndexedDB queue, per-file progress & retry, captions + #hashtags
Processing Lossless server-side compression, feed preview generation, ffmpeg video thumbnails
Feed Chronological grid, real-time SSE updates, hashtag filtering, likes & comments
Host Dashboard Ban/unban guests, delete content, promote to host, lock event, release gallery for export
Admin Dashboard All host permissions + configure limits, rates, quota tolerance, disk usage widget
Export On-demand ZIP (full-quality originals) + self-contained offline Memories.html viewer

Planned (v1.x)

  • Individual file download button
  • Low-disk alert (< 10 GB free)
  • Event banner / cover image
  • Chunked resumable upload for large videos
  • Host-curated story highlights
  • Slideshow / presentation mode

Tech Stack

Layer Technology
Frontend SvelteKit + TypeScript
Styling Tailwind CSS v4
Backend Rust + Axum
Async Tokio
Database PostgreSQL 16 via SQLx (compile-time query checking)
Auth Custom JWT (jsonwebtoken) + bcrypt PINs
Image processing image crate + oxipng (lossless compression)
Video processing ffmpeg via tokio::process::Command
File storage Local disk (/media/)
Real-time Axum SSE + tokio::sync::broadcast
Export async-zip (streaming ZIP) + minijinja (HTML bundle)
Rate limiting tower-governor (token-bucket, DB-configurable)
Reverse proxy Caddy 2 (automatic HTTPS via Let's Encrypt)
Containers Docker + Docker Compose
Infrastructure Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD)

Repository Structure

eventsnap/
├── backend/          # Rust + Axum API server
│   ├── src/
│   ├── Cargo.toml
│   └── Dockerfile
├── frontend/         # SvelteKit PWA
│   ├── src/
│   ├── svelte.config.js
│   └── Dockerfile
├── docker-compose.yml
├── docker-compose.dev.yml   # opt-in dev overlay (publishes Postgres on the host)
├── Caddyfile
└── .env.example

Getting Started

Prerequisites

  • Docker (includes Compose plugin)
  • A domain name with an A record pointing to your server

Deploy on a fresh VPS

# 1. Clone the repository (into a lowercase dir, matching the paths used below)
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
cd eventsnap

# 2. Configure environment
cp .env.example .env
nano .env   # set DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.

# 3. Start the stack
docker compose up -d

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

If the site never comes up: with APP_ENV=production the backend refuses to boot while JWT_SECRET/ADMIN_PASSWORD_HASH still hold the .env.example placeholders (this is deliberate — a publicly-known signing key is worse than downtime). Caddy then waits on the unhealthy app container and never serves. Check docker compose logs app — a "Refusing to start … placeholder …" line means you skipped step 2. Rotate the secrets (see below) and restart.

Production note: docker compose up -d does not expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:

docker compose -f docker-compose.yml -f docker-compose.dev.yml up

Generate required secrets

# JWT secret (64 random bytes)
openssl rand -hex 64

# Admin password hash (bcrypt, cost 12)
htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'

Environment Variables

See .env.example for the full list with descriptions and defaults. Key variables:

Variable Description
DOMAIN Public domain for TLS (e.g. my-wedding.example.com)
JWT_SECRET 64-byte random hex string for signing JWTs
ADMIN_PASSWORD_HASH bcrypt hash of the admin dashboard password
EVENT_NAME Display name shown to guests
EVENT_SLUG URL-safe event identifier
DATABASE_URL PostgreSQL connection string

Docker Compose Stack

┌─────────────────────────────────────┐
│  Caddy :80 / :443 (TLS termination) │
└────────────┬────────────────────────┘
             │
    ┌────────┴────────┐
    │                 │
┌───▼────┐      ┌─────▼──────┐
│  app   │      │  frontend  │
│ :3000  │      │   :3001    │
│ (Rust) │      │(SvelteKit) │
└───┬────┘      └────────────┘
    │
┌───▼────┐
│   db   │
│ :5432  │
│(Postgres)│
└────────┘
  • /api/* and /media/* → Rust backend
  • Everything else → SvelteKit frontend (adapter-node)
  • Named volumes: postgres_data, media_data, caddy_data

Backup

# Database snapshot
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz

# Weekly offsite sync (Hetzner Storage Box or similar)
rsync -az /opt/eventsnap/media/ user@storagebox.example.com:backup/eventsnap/

The /media volume holds originals, previews, thumbnails, exports, and DB backups — a single path to back up.


Running the E2E test suite

Playwright-based end-to-end tests live in 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.

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


Development Roadmap

Done:

  • Project blueprint & architecture
  • Monorepo scaffold (backend/, frontend/, Docker Compose)
  • DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
  • Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
  • Upload pipeline (multipart → compression worker via tokio::sync::Semaphore → SSE broadcast)
  • Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
  • Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
  • Camera capture (getUserMedia with front/back toggle, photo + MediaRecorder video)
  • Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
  • Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
  • Export engine: streaming ZIP + SvelteKit-static HTML viewer (see docs/CONCEPT_HTML_VIEWER.md)
  • Custom rate limiter (per-endpoint, hot-reloadable from config table)
  • Mobile-first redesign (bottom nav + FAB, see docs/CONCEPT_MOBILE_UI.md)

Open:

  • Dynamic per-user storage quota enforcement (formula in PROJECT.md §12; only tracking exists today)
  • Own-upload deletion UI in the lightbox (backend route exists)
  • SSE delta-fetch on foreground reconnect (scaffolded in sse.ts, not wired)
  • Live diashow / slideshow mode — see 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 for the up-to-date capability matrix by role. Speculative / v2+ ideas live in docs/IDEAS.md.


License

Private project — all rights reserved.

Description
A private, QR-code-accessed photo & video sharing platform for weddings, birthdays, and personal events — built for guests, run by you.
Readme 3 MiB
Languages
TypeScript 33.6%
Rust 28.3%
Svelte 25.1%
HTML 6.8%
JavaScript 3.8%
Other 2.4%