README step 2 said to set "DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc." -- POSTGRES_PASSWORD was not in that list. .env.example said to set it and keep it in sync with DATABASE_URL. The two documents disagreed, and both readings ended badly. Branch A, README verbatim: the stack came up GREEN and healthy on CHANGE_ME_use_a_strong_password -- a database credential published in the public repo. The production secret guard covered JWT_SECRET and ADMIN_PASSWORD_HASH, and nothing anywhere looked at the Postgres password. Branch B, .env.example verbatim: a permanent restart loop, "password authentication failed for user eventsnap". The guard's own design made Branch B near-certain. It stops the APP on the first `docker compose up -d` -- but not the `db` service in that same command, which initialises its data directory and bakes in whatever password was in .env at that moment. POSTGRES_PASSWORD is honoured ONLY at initdb. So the intended recovery -- see the refusal, fix your secrets, boot again -- was exactly the sequence that broke it. Nothing in the error named the cause, and the remedy (`down -v`) is both unguessable and the one command you must never run once real data exists. Three changes, which have to ship together: the guard alone would just move operators out of Branch A and into Branch B. - The guard now rejects a placeholder DATABASE_URL in production (the password rides in that URL, which is what the app actually reads). Branch A can no longer boot. - It reports EVERY unset secret in one message instead of returning on the first. Fixing two secrets used to cost two boot cycles, on a stack where Caddy waits on the unhealthy app throughout, and each avoidable cycle is another chance to reach for -v. - A 28P01 handler in db.rs turns the unguessable failure into a self-explaining one: it names the initdb semantics, gives `down -v` with an explicit "deletes db + media + exports, no undo", and gives the ALTER ROLE alternative for when data already exists. Docs: step 2 now names POSTGRES_PASSWORD and says every secret must be set BEFORE the first up; the troubleshooting block covers the auth-failure loop and both remedies. Also replaces htpasswd (apache2-utils -- not on a stock VPS) with `docker run --rm caddy:2-alpine caddy hash-password`, an image the stack already pulls, in README, .env.example and the guard's own message. Verified the output ($2a$14) against the shipped $2y$12 example. Verified on a real Postgres, not just in tests: Branch A refuses and names DATABASE_URL; all three placeholders report in one boot; a volume initialised with one password and connected to with another prints the diagnostic; an unrelated connect failure (dead port) stays silent. 8 unit tests, including that non-prod ignores all of it so the e2e stack is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
525 lines
24 KiB
Markdown
525 lines
24 KiB
Markdown
# 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](https://docs.docker.com/engine/install/) (includes Compose plugin)
|
||
- A domain name with an A record pointing to your server
|
||
|
||
### Deploy on a fresh VPS
|
||
|
||
```bash
|
||
# 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 — set EVERY secret NOW, before step 3.
|
||
cp .env.example .env
|
||
nano .env # DOMAIN, EVENT_NAME, EVENT_SLUG,
|
||
# JWT_SECRET, ADMIN_PASSWORD_HASH,
|
||
# POSTGRES_PASSWORD *and* the same password inside DATABASE_URL
|
||
# (see "Generate required secrets" below)
|
||
|
||
# 3. Start the stack
|
||
docker compose up -d
|
||
```
|
||
|
||
> **Set every secret before step 3 — `POSTGRES_PASSWORD` especially.** Postgres reads it **only
|
||
> when it initialises its data directory**, which happens on the very first `docker compose up -d`.
|
||
> Changing it in `.env` afterwards does not change the stored password: the app then authenticates
|
||
> with the new one against a volume holding the old one, and you get a permanent restart loop with
|
||
> `password authentication failed for user "eventsnap"`. The only fixes are restoring the old
|
||
> password or `docker compose down -v`, which **deletes the database, the media and the exports**.
|
||
> Getting it right once, up front, costs nothing; getting it wrong costs the volume.
|
||
|
||
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` or the password inside `DATABASE_URL` still hold the
|
||
> `.env.example` placeholders (this is deliberate — a publicly-known signing key or database
|
||
> password 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 lists
|
||
> **every** unset secret at once, so one edit fixes them all.
|
||
>
|
||
> **If it comes up but keeps restarting with `password authentication failed for user
|
||
> "eventsnap"`:** `POSTGRES_PASSWORD` was changed after the database volume was created. Postgres
|
||
> applies that variable only at initialisation, so `.env` and the stored password have drifted
|
||
> apart permanently. `docker compose logs app` spells this out. Before the event, with nothing
|
||
> worth keeping:
|
||
>
|
||
> ```bash
|
||
> docker compose down -v && docker compose up -d # -v DELETES db + media + exports. No undo.
|
||
> ```
|
||
>
|
||
> **Once the event has real data, never do that.** Put the original password back into
|
||
> `DATABASE_URL`, or change the stored one instead:
|
||
>
|
||
> ```bash
|
||
> docker compose exec db psql -U "$POSTGRES_USER" -c \
|
||
> "ALTER ROLE eventsnap WITH PASSWORD 'the-password-now-in-your-.env';"
|
||
> ```
|
||
|
||
> **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:
|
||
> ```bash
|
||
> 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.
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
# JWT secret (64 random bytes)
|
||
openssl rand -hex 64
|
||
|
||
# Database password (goes in BOTH DATABASE_URL and POSTGRES_PASSWORD)
|
||
openssl rand -hex 24
|
||
|
||
# Admin password hash (bcrypt). Uses an image the stack already pulls, so it needs
|
||
# nothing installed on the host — `htpasswd` lives in apache2-utils, which a stock
|
||
# VPS does not have. Emits cost 14 rather than 12; that is fine (admin login is
|
||
# rate-limited and hashed off the async runtime), and any $2a/$2b/$2y hash verifies.
|
||
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
|
||
```
|
||
|
||
Wrap the resulting hash in **single quotes** in `.env` — see the note there; a bcrypt
|
||
hash is full of `$`, and both Compose and dotenvy would otherwise eat those segments.
|
||
|
||
### Environment Variables
|
||
|
||
See [.env.example](.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.
|
||
|
||
```bash
|
||
# 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.
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
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/`](e2e/). They spin up an isolated docker-compose stack (Postgres on `:55432`, Caddy on `:3101`) and exercise the SvelteKit frontend against the real Rust backend with rate limits disabled.
|
||
|
||
```bash
|
||
cd e2e
|
||
npm install
|
||
npm run install:browsers # one-time
|
||
npm run stack:up # bring up the test stack
|
||
npm run test:e2e # full Phase 1 suite on chromium-desktop
|
||
npm run test:e2e:smoke # cross-UA matrix (chromium, samsung-internet, webkit, firefox, …)
|
||
npm run stack:down # tear it down
|
||
```
|
||
|
||
See [`e2e/README.md`](e2e/README.md) for the full UA matrix, Samsung Internet escalation tiers, and the Phase 2/3 roadmap.
|
||
|
||
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml) (desktop **and**
|
||
mobile projects), plus [`checks.yml`](.github/workflows/checks.yml) for `cargo test`/clippy, the frontend
|
||
unit tests, svelte-check and the e2e typecheck, and [`audit.yml`](.github/workflows/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:
|
||
- [x] Project blueprint & architecture
|
||
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
||
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
|
||
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
|
||
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
|
||
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
|
||
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
|
||
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
|
||
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
|
||
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
|
||
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
|
||
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
|
||
- [x] Mobile-first redesign (bottom nav + FAB, see [docs/CONCEPT_MOBILE_UI.md](docs/CONCEPT_MOBILE_UI.md))
|
||
|
||
Open:
|
||
- [ ] Dynamic per-user storage quota enforcement (formula in [PROJECT.md §12](PROJECT.md); only tracking exists today)
|
||
- [ ] Own-upload deletion UI in the lightbox (backend route exists)
|
||
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
|
||
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
|
||
- [ ] Individual file download button per post
|
||
- [x] 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](docs/FEATURES.md) for the up-to-date capability matrix by role.
|
||
Speculative / v2+ ideas live in [docs/IDEAS.md](docs/IDEAS.md).
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
Private project — all rights reserved.
|