Files
EventSnap/README.md
fabi 5f702f2b40 fix(audit-5): export disk preflight, media reclaim, low-disk warning, restore docs
Fifth round, all storage. The keepsake could not fit on the documented hardware
and failed halfway through a multi-GB write, leaving the deliverable stuck; the
quota had stopped bounding the disk; and the backup had no restore procedure.

Squashed from 6 commits, original messages preserved below.

──────── fix(export): refuse an export that cannot fit, and stop peaking at two generations

Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.

The failure is not "the export failed", it is "the deliverable is stuck":

  1. ENOSPC lands partway through a multi-GB write.
  2. The epoch has already moved, so the job row is `failed` at the CURRENT
     generation and readiness (epoch = event.export_epoch AND status = 'done') is
     false -- GET /export/zip 404s.
  3. The last good archive sits on disk, unreferenced and unreachable.
  4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.

Three changes.

Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.

Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.

Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".

Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.

──────── fix(maintenance): reclaim the media of deliberately deleted uploads

The quota stopped bounding the disk. `soft_delete_in_event` stamps `deleted_at` and
refunds `total_upload_bytes`, but nothing ever removed the bytes, and the hourly
sweep reached only `compression_status = 'failed'`. Upload 500 MB, delete, quota back
to zero, upload another 500 MB. Not an attack -- a guest curating their camera roll,
which is what people do. The host then sees guests hitting "Du hast dein Upload-Limit
erreicht" while the admin widget shows a disk full of files no upload row points at,
and the quota message is actively misleading because the space really is gone, just
not to anyone the accounting can name.

Two retention windows, because the two deletes mean different things. A compression
failure keeps its 14 days: the guest didn't ask for it and may not be able to retake
the photo. A deliberate removal gets 24 hours -- 14 days outlives the whole event, so
a deliberate delete would never reclaim anything while it mattered, and a day still
covers a mis-tap.

Wider than reported: ALL FOUR paths are reclaimed, not just the original. Preview,
display and thumbnail are each a separate file, none counted in
`original_size_bytes`, and nothing ever removed them either. That was invisible while
the sweep only saw failed compressions (which produce no derivatives) and becomes
three leaked files per upload the moment it reaches a successful one. A row is
re-selected until every path is cleared, and the columns are cleared only once every
file for that upload is gone -- clearing after a partial success would strand the
survivors in exactly the unowned state this drains.

`backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT
NULL`, which is close enough to the post-sweep state to be worth pinning: it is
guarded on `deleted_at IS NULL`, so it cannot re-decode an original that is no longer
on disk. Covered.

Residual, deliberately: within the 24h window the bytes are still spent and still
unaccounted, so delete-and-re-upload through an eight-hour event can outrun the
sweep. Bounding that means holding the quota until the file is reclaimed rather than
refunding at `deleted_at`. The low-disk warning is the net under it.

Tests: 6 DB-backed, replacing 3. The one asserting an owner-deleted upload IS
reclaimed is the exact inverse of what this file used to assert.

──────── feat(host): warn about low disk before it becomes unrecoverable

Storage visibility existed in exactly one place: a passive Speicherauslastung widget
on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing
warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1.

Two things make this a safety net rather than a nice-to-have. postgres_data,
media_data and exports_data are all Docker named volumes on ONE filesystem, so
running out doesn't degrade a subsystem -- Postgres stops being able to write and the
whole event goes down. And the keepsake needs room for two gallery-sized archives,
which the export preflight can only ever refuse AFTER the release, when the event is
over and every remedy is harder.

So the threshold is not a fixed number alone. It fires on the 10 GB floor the README
always named, OR on "you could not build the keepsake right now" -- the trigger a
host can still act on, computed with the same arithmetic the preflight uses. Unknown
free space is NOT low: it fails open like the upload quota and the preflight do,
because a banner that cries wolf on an unreadable mount is a banner nobody reads.

Carried on GET /host/event, which the dashboard already fetches on load and on every
reload -- no new endpoint, no new poll. Rendered above everything else including the
PIN-reset queue, and it names the consequence (the event, not just the download)
rather than only the number.

Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have
rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown
that way in the user list.

Tests: 5 unit on the threshold (including that plenty of free space is still low when
the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e.
The e2e drives it through `original_size_bytes` rather than a genuinely full disk:
the estimate is pure SQL over that column, so overstating one row moves the
accounting without touching a byte on disk.

──────── docs: add a restore procedure, fix the backup cadence, and correct quota_tolerance

Four things, all found by the same question: what does an operator standing at the
venue actually need?

A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.

Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:

  - The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
    "_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
    so the documented dump could only ever be restored into an empty database. Fixed
    at the source (the dump is now self-cleaning) and verified end to end: 16 tables
    back, exit 0.
  - `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
    the extract aborted before unpacking anything. `--numeric-owner` plus the
    explicit chown, verified to land 100:101.

BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.

quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.

SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.

Also ticks the low-disk alert off the roadmap, since it now exists.

──────── chore: raise the db memory limit and rate-limit social writes

Two smaller operational items.

POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 30 for a ~100-guest event (feed
polling + SSE + uploads at once), and 30 backends plus Postgres 16's default
shared_buffers leaves very little headroom at 512M. An OOM here doesn't degrade one
feature -- every request path touches the database, so it takes the event down.
Memory is the cheaper knob than shrinking the pool back and reintroducing the
queueing it was raised to fix. .env.example now names the pairing explicitly, the way
it already does for COMPRESSION_WORKER_CONCURRENCY.

SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the
only mutating endpoints in the app with no limit at all -- upload, join, recover,
export and admin login all carry one. Asymmetric coverage rather than a deliberate
decision.

Low severity, and honestly so: a like fans an SSE broadcast to every client, but the
export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s,
workers born with their epoch, superseded ones inert). So the ceiling is 120/min --
far above anything a real guest produces. This bounds a script, not an enthusiastic
double-tapper.

ONE bucket across all three actions: separate buckets would let a caller triple the
aggregate write rate by alternating between them. Keyed per USER, matching the feed
and upload limits -- at a venue every guest is behind one NAT, and an IP key is what
made the /join and /feed limits turn guests away in the first place.

Migration 020 seeds both keys, and both are wired into the admin allowlist, the
config UI and the e2e reseed -- the step two earlier per-area toggles missed, which
left switches that existed in code and could never be flipped.

Tests: 4 e2e, including that the shared bucket really is shared (the part most likely
to be lost in a refactor) and that one guest hitting the ceiling doesn't block
another behind the same IP.

──────── fix(e2e): stop the video poster assertion racing the ffmpeg thumbnail

Pre-existing, and it fired for real during the full-suite run on a cold stack.

The lightbox binds `poster={upload.thumbnail_url ?? undefined}`, so the attribute is
absent until compression produces the thumbnail. This test asserted on it immediately
after seeding, never waiting for the worker -- unlike the Range test further down the
same file, which does poll. Against a warm stack the worker usually wins; against a
freshly rebuilt one (`stack:down -v`, cold ffmpeg) it doesn't.

That is the worst possible time for a false failure: the first run after a rebuild is
exactly when you are trying to establish whether a change broke something. Poll for
`compression_status = 'done'` before the poster assertion. The `src` assertion needs
no wait and keeps none.

Verified with --repeat-each=3.

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

22 KiB
Raw Blame History

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


Sizing the disk

postgres_data, media_data and exports_data are all Docker named volumes under /var/lib/docker/volumes, so they share one filesystem. Filling it does not degrade one subsystem — Postgres stops being able to write and the whole event goes down.

Uploads are self-limiting. per_user_limit = free_disk × quota_tolerance ÷ active_uploaders is recomputed against live free space on every upload, so guests converge on a fixed point at tolerance / (1 + tolerance) of the free space you started with — 43% at the default 0.75. On an 80 GB box with ~70 GB free after the OS and images, media settles at ~30 GB and stops.

The keepsake is what the 80 GB baseline does not cover. Gallery.zip and Memories.zip are built concurrently and each is roughly a second copy of every original: both write their media Compression::Stored, and Memories.zip streams the untouched original for every video and for every image at or under 5 MB. So a release wants room for two more copies of the gallery on top of the gallery itself.

Stage Used Free (80 GB box)
Fresh box (OS + images) ~10 GB ~70 GB
Guests reach the quota fixed point ~40 GB ~40 GB
Host releases → both archives ~100 GB ENOSPC

Two ways to size for it:

  • Provision ~3× your expected media on one volume (media + two archives), or
  • give exports_data its own volume so a full export cannot reach Postgres, and size that one at ~2× expected media.

This is no longer silent. The export refuses up front with the two numbers rather than hitting ENOSPC halfway through a multi-GB write, a rebuild reclaims the superseded generation before it starts (so peak is one generation, not two), and the host dashboard warns as soon as the keepsake would not fit — which is the only point at which anyone can still do something about it.


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.
#    --clean --if-exists makes the dump SELF-CLEANING: without it the restore below
#    aborts on the first "already exists" against a database that has ever booted,
#    which is every database you would actually want to restore over.
mkdir -p ./backups
docker compose exec -T db \
  sh -c 'pg_dump --clean --if-exists -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 .

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

When to run it

A nightly cron is the wrong shape for this app. Every irreplaceable byte is created inside one eight-hour window, and nobody can retake a wedding. Run the three commands above:

  1. The night of the event, once uploads have stopped. This is the backup that matters; everything else is a formality.
  2. After the host releases the gallery, so the generated keepsake is captured too.
  3. Weekly thereafter, until the event is archived and torn down.

Take the DB dump and the media tarball back to back, without uploads in flight between them. Upload rows reference files by path — a database from 22:00 and a media volume from 23:00 gives you rows pointing at files the dump doesn't know about, and rows whose files aren't in the tarball. Locking uploads from the host dashboard first (Uploads sperren) makes the pair genuinely consistent.


Restore

An untested backup is not a backup. Run this once against a scratch host before the event — it is roughly ten minutes, and it is the only way to find out that your tarball is empty or your dump is truncated while that is still a small problem.

# 0. Stop the app FIRST. Migrations run on boot and a live pool will fight the
#    restore — a booting app against a half-restored schema can leave the migration
#    table and the schema disagreeing, which is its own recovery problem.
#    Leave `db` running: the dump is restored through it.
docker compose stop app caddy

# 1. Database. The dump carries its own DROPs (step 1 of Backup), so this replaces
#    rather than collides. A dump taken WITHOUT --clean --if-exists will abort here
#    on the first "already exists" — restore that one into a fresh empty database
#    instead.
gunzip -c ./backups/db_2026-07-29.sql.gz \
  | docker compose exec -T db \
      sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" --set ON_ERROR_STOP=1'

# 2. Media. NOTE the `--numeric-owner` and the chown: the app runs as a
#    NON-ROOT user (uid 100, gid 101 — `addgroup -S app && adduser -S app`), and a
#    restore that lands root-owned files makes every upload fail with EACCES deep in
#    the write path, surfacing to the guest as a generic 500 with nothing in the UI
#    to suggest permissions. The explicit chown is what guarantees it — BusyBox tar
#    (which is what `alpine` ships) has no --same-owner, and restores ownership only
#    because it runs as root here.
docker run --rm \
  -v eventsnap_media_data:/dst -v "$PWD/backups":/backup:ro \
  alpine sh -c 'tar xzf /backup/media_2026-07-29.tar.gz -C /dst \
                    --numeric-owner && chown -R 100:101 /dst'

# 3. Exports. Same volume-name caveat, same ownership rules.
docker run --rm \
  -v eventsnap_exports_data:/dst -v "$PWD/backups":/backup:ro \
  alpine sh -c 'tar xzf /backup/exports_2026-07-29.tar.gz -C /dst \
                    --numeric-owner && chown -R 100:101 /dst'

# 4. Back up. Migrations run, then export recovery re-arms any keepsake whose file
#    didn't come back with the volume.
docker compose up -d app caddy
docker compose logs -f app        # watch for "migrations applied"

# 5. Verify — all three, not just the first.
curl -fsS https://DOMAIN/health && echo            # → ok
#   … then sign in as host and confirm the feed renders images (proves the media
#   volume restored AND is readable by uid 100), and that the keepsake downloads.

If the media volume restored but images 404 while the feed lists them, the paths are there and the bytes aren't — check docker compose exec app ls -ln /media/originals and confirm both the files and the 100:101 ownership.

The restore is deliberately not automated. It is rare, destructive, and the one operation where a script that half-works is worse than a checklist someone reads.


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 — host dashboard warns below 10 GB free, or whenever the keepsake would not fit
  • 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.