Files
EventSnap/README.md
fabi 06ade4e158 fix(audit-3): restore the decode allocation guard, route /health in production
Third round. The decode allocation guard is a regression from round 1: swapping
reader.decode() for into_decoder() silently dropped the max_alloc enforcement
while keeping the comment that claimed it held.

Squashed from 3 commits, original messages preserved below.

──────── fix(imaging): restore the decode allocation guard I removed in round 1

This is a regression I introduced, not a pre-existing gap. Before 05948d8 the
compression worker used `ImageReader::decode()`, which does:

    let mut decoder = Self::make_decoder(format, self.inner, limits.clone())?;
    limits.reserve(decoder.total_bytes())?;   // enforces max_alloc
    decoder.set_limits(limits)?;

Reading the EXIF orientation tag needs `into_decoder()` instead, and that skips
the reserve entirely — the crate's own FIXME concedes `from_decoder` doesn't
compensate. Nothing else enforces `max_alloc`: the JPEG decoder's `set_limits`
only checks support and dimensions. So the 256 MiB budget has been inert since
that commit, and round 2 then propagated the weakened path into export.rs through
the shared helper, in a commit whose message claimed the helper "carries" the
decompression-bomb cap. It didn't, and the comment saying max_alloc "hard-caps
the decode allocation" was simply false.

What was left was only the per-axis cap, which permits 12000x12000 — 412 MiB
decoded, 824 MiB for the two concurrent decodes the worker runs by default,
against a 1 GiB container. Deploy-blocking right now because bumping
DERIVATIVES_REV makes the first boot after a deploy re-decode the entire gallery
two at a time: an OOM kill there restarts the container, which re-runs the
backfill. A boot loop, on the first deploy of these fixes.

Re-add the reserve exactly as `decode()` does it. Per the budget decision it stays
at 256 MiB (~89 MP for RGB8, above any mainstream phone's real output); two
concurrent decodes now peak at 512 MiB. Oversized images take the graceful path
from round 1 — original retained, quota refunded, upload-error toast — and fail
after the header parse but BEFORE any pixels are read, so they cost a header read
rather than an allocation. Measured peak during a concurrent oversized burst: 3.0
MiB.

Test parity is the other half, and the reason this was invisible: the e2e app
container had NO memory limit while production is capped at 1 GiB, so a decode
that would OOM-kill production simply succeeded in CI. Mirror the 1 GiB cap in
docker-compose.test.yml. That is the third divergence of this shape, after WebKit
missing from CI and /health existing only in Caddyfile.test.

Tests: a fixture that is 568 KiB on disk and 283 MiB decoded (11000x9000 = 99 MP,
deliberately UNDER the per-axis cap so the axis check cannot be what rejects it).
A unit test asserts the refusal — it fails against the old code, which decoded it
into an 11000x9000 buffer — with a companion asserting an ordinary photo still
decodes AND still gets its orientation applied, so the guard didn't become a
blanket refusal. An e2e test uploads it singly and as a concurrent pair, asserting
compression lands in 'failed' and the backend is still serving and still
processing afterwards.

──────── fix(deploy): route /health in production, and actually apply Caddyfile changes

Two defects in the update procedure I wrote last round, both of which make a
successful-looking deploy a lie.

1. The documented health check could never pass.

`curl -fsS https://DOMAIN/health` 404s against a perfectly healthy production
stack. The backend registers /health on its ROOT router, not under /api/v1, and
the production Caddyfile proxies only /api/* and /media/* — so /health fell
through to the SvelteKit catch-all, which has no such route and returns its 404
page. With -f, curl exits 22 and the `&& echo` never runs. My own gloss
("Anything other than ok means check the logs") then sent the operator chasing a
phantom outage.

e2e/Caddyfile.test has carried `reverse_proxy /health app:3000` since it was
written — precisely because the catch-all would otherwise swallow it. Production
never did. Per the fix-the-gap-not-the-doc call, production gets the same line,
and /health joins the no-store matcher so a cached response can't report the last
known state instead of the current one. Verified by running the production
Caddyfile against the real backend: /health -> 200 "ok", Cache-Control: no-store,
with /api/v1/event and / unaffected.

2. The sequence never reloaded Caddy, so a Caddyfile-only change was dropped.

`--build` only rebuilds services with a `build:` section, and caddy is a pinned
upstream image. Compose decides whether to recreate a container from its config
hash, which covers the mount SPECIFICATION but not the mounted file's CONTENTS —
so a git pull that changes ./Caddyfile produces no delta, Compose reports
`Running`, and Caddy serves its old config indefinitely. Exit code 0 throughout.

Round 1's iOS download fix (137c4ee) is exactly this shape: Caddyfile plus four
e2e files, so 100% of its production effect is in that one file. Following the
README to the letter deployed it, showed both image IDs changing, and left iOS
downloads broken.

Demonstrated rather than assumed — added a probe header to a Caddyfile, ran the
old sequence (`up -d --build`): header absent, change silently dropped. Ran the
new step 4 (`up -d --force-recreate caddy`): header served.

`--force-recreate` rather than `restart` or `caddy reload` because the bind mount
is resolved to an inode at container-create time and git pull replaces the file
rather than editing in place, so a restart can re-read the stale content — the
exact failure I hit in round 1 when `caddy reload` didn't pick up an edit.

Also rewrites the "db and caddy are untouched … so data volumes survive" sentence.
I wrote it as reassurance; "caddy is untouched" was the bug.

──────── chore: take the Bash(*) permission change back out of the shared settings

`.claude/settings.json` is committed and applies to anyone who clones. Fabi's
local `allow: ["Bash(*)"]` plus deny list ended up in it, inside f0d69f1 — a
commit about the image decode guard, which has nothing to do with permissions.

That was my mistake, twice. The file was already modified when I started the
round: my `git status --short` check printed "(clean)" from an unconditional
`echo` rather than from the status output, so I read a dirty tree as clean. Then
`git add -A` swept it into an unrelated commit, and I reported afterwards that I
had left it untouched. Neither the check nor the claim was true.

Restores the shared file to its previous three narrow entries. The permission
setup itself is preserved, moved to `.claude/settings.local.json`, which
`.gitignore:34` covers precisely so per-user permissions stay per-user — the
existing 442 entries there are kept alongside it.

Not rewriting f0d69f1 to erase this: main is unpushed so it would be safe, but a
visible correction is worth more than a tidy history, and a rebase across the
merge commits carries more risk than the mistake does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:18:08 +02:00

16 KiB

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

Updating an existing deployment

docker compose up -d alone will NOT deploy your changes. app and frontend are build: services with no published image tag, and Compose has no source-change detection: if an image with that name already exists it is reused. After a git pull the command reports Container … Running, changes nothing, and exits 0 — so a deploy that shipped nothing looks exactly like a successful one. --build is what makes it real.

cd /path/to/eventsnap

# 1. Back up first — migrations run automatically on boot and are not reversible in place.
#    (See "Backup" below; the database dump is the one that matters here.)

# 2. Fetch the new code.
git pull

# 3. Rebuild and restart the application services. --build is NOT optional.
docker compose up -d --build

# 4. Apply any Caddyfile change. Step 3 does NOT do this — see the warning below.
docker compose up -d --force-recreate caddy

# 5. Confirm the app came back up. Anything other than "ok" means check the logs.
curl -fsS https://DOMAIN/health && echo

# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
#    compare — it must have changed. (Ignore the CREATED column; it reports the base
#    layer's age, not this build's.) An unchanged ID means step 3 ran without --build
#    and you are still serving the old code.
docker compose images app frontend

Migrations are applied by the backend on startup, so step 3 covers them. If app stays unhealthy afterwards, docker compose logs app will name the failing migration — and note that a migration applied by a newer build is not removed by checking out an older commit, so rolling back code without restoring the database snapshot from step 1 leaves the schema ahead of the binary and the app refusing to boot.

Why step 4 exists. --build only rebuilds services that have a build: section, and caddy is a pinned upstream image. Compose decides whether to recreate a container from its config hash, which covers the mount specification (./Caddyfile:/etc/caddy/Caddyfile:ro) but not the file's contents — so a git pull that changes ./Caddyfile produces no delta, Compose reports Running, and Caddy keeps serving its old config indefinitely. Exit code 0 throughout.

That is not hypothetical: the fix that made the keepsake download work on iOS (137c4ee) touched the Caddyfile and four e2e files and nothing else, so all of its production effect lives in that one file. Without step 4 you deploy it, watch both image IDs change, and iOS downloads stay broken.

--force-recreate rather than restart or caddy reload: the bind mount is resolved to an inode when the container is created, and git pull replaces the file instead of editing it in place, so the container can still be bound to the old, now-unlinked inode. A restart then re-reads the stale content. Recreating the container re-resolves the path.

db is never touched, and recreating caddy does not disturb the caddy_data volume, so the TLS certificate and all data volumes survive.

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/* → Rust backend
  • Everything else → SvelteKit frontend (adapter-node)
  • Named volumes: postgres_data, media_data, exports_data, caddy_data

Media is not served as static files. Every image goes through a visibility-checked alias (/api/v1/upload/{id}/{preview,display,thumbnail,original}) so a host takedown or a ban actually revokes access to the bytes.


Backup

There are three things to back up, and they live in three different places. DATABASE_URL and the container paths (/media, /exports) are meaningful only inside the compose network — they are not host paths, and DATABASE_URL is never exported into an operator's shell — so every command below runs through docker compose from the repo directory.

# 1. Database snapshot. Runs pg_dump inside the db container (the app image has no
#    postgres client), reading credentials from the compose environment.
mkdir -p ./backups
docker compose exec -T db \
  sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
  | gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz

# 2. Uploaded media (originals + derivatives) out of the named volume.
#    NOTE the mountpoint is /src, not /media: if the volume is ever empty, Docker
#    pre-populates a fresh mount from the image's own directory, and alpine ships a
#    /media containing cdrom/floppy/usb. Mounting somewhere the image has nothing
#    avoids silently tarring (and polluting the volume with) those.
docker run --rm \
  -v eventsnap_media_data:/src:ro -v "$PWD/backups":/backup \
  alpine tar czf /backup/media_$(date +%Y-%m-%d).tar.gz -C /src .

# 3. Export archives — a SEPARATE volume (see the security note below).
docker run --rm \
  -v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \
  alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src .

# Weekly offsite sync of the three artefacts above.
rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/

Volume names are prefixed with the compose project name — eventsnap_ if you run from a directory called eventsnap. Confirm yours with docker volume ls.

Exports are deliberately NOT under /media. They live on their own exports_data volume (EXPORT_PATH=/exports) because a keepsake archive contains every photo in the event; keeping it outside the media tree is what stops it being reachable except through the ticket-gated download handler. Backing up only the media volume therefore loses every generated keepsake.


Running the backend test suite

cd backend

# The DB-backed integration tests (backend/tests/) need a live Postgres. `#[sqlx::test]` creates a
# throwaway database per test and runs backend/migrations/ into it — it does NOT touch this one's data.
docker run -d --name eventsnap-test-pg -p 55433:5432 \
  -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventsnap postgres:16-alpine

export DATABASE_URL=postgres://postgres:postgres@localhost:55433/eventsnap
cargo test        # 44 unit + 12 DB-backed
cargo clippy --all-targets -- -D warnings

cargo test requires DATABASE_URL — without it the integration tests panic rather than skip. That is deliberate. The riskiest code in this repo is SQL (the export epoch state machine, the atomic quota increment, the FOR SHARE upload lock), and for a long time not one line of it was executed by cargo test — every backend test was a pure-function test, so the tests clustered tightly around the code that could not break and stopped exactly where it started to. Tests that silently skip when the database is absent recreate that hole; they were meant to be a gate.

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 (desktop and mobile projects), plus checks.yml for cargo test/clippy, the frontend unit tests, svelte-check and the e2e typecheck, and audit.yml for dependency advisories.

Playwright runs with retries: 0, including in CI. This repo's real bugs are races, and from the outside a race is indistinguishable from a flake — so a retry silently resolves that ambiguity in favour of "flake" every time. A flake here is a bug report; treat it as one.


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.