MechaCat02 ef6d3a077a
Some checks failed
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m5s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m55s
Checks / E2E — typecheck + lint (push) Failing after 49s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 10m42s
E2E / Cross-UA smoke matrix (push) Failing after 7m57s
Audit / cargo audit (backend) (push) Failing after 11m12s
Audit / npm audit (frontend) (push) Successful in 44s
fix: close what nine adversarial reviews found, most of it mine
Nine focused reviews (export state machine, upload path, auth/abuse, client
queue, guest UI, database, deploy/ops, regression hunt, test honesty). Every
finding below was re-verified against the code before being acted on; several
plausible-sounding ones were checked and rejected.

## Data loss and denial of service

**One request could OOM-kill the app container.** `client_upload_id` was read
with `Field::text()` — axum builds its multipart reader with no SizeLimit, so
the only bound was the route's 576 MiB body limit, then decoded into a second
full String. `caption` and `hashtags` go through `read_text_field_bounded` for
exactly this reason; this field arrived later and missed it. Any guest, one
request, and every SSE stream drops and every in-flight temp file is stranded.

**Nothing bounded concurrent upload bodies.** The headroom gate can only refuse
to COMMIT — the body is already streamed to a temp file by the time it runs, and
neither axum, the tower stack nor Caddy limits how many stream at once. ~100
guests tapping "upload all" after the ceremony puts 10-20 GB of .tmp on a 40 GB
volume, invisible to the gate, eating the reserve that keeps Postgres able to
write WAL. New `UploadAdmission` budgets bytes (not requests, so one video and
two hundred photos coexist) via a permit that releases on drop, so every exit
path returns it.

**The export decode bypassed the memory permit the compression path takes.**
Same class of work — decode + resize every image in the gallery — in a bare
spawn_blocking. A release fired while the last photos were still compressing put
both in the same 1 GiB cgroup; the OOM kill marks the export failed and
`recover_exports` re-spawns it into the same conditions on the next boot. The
permit is now process-wide in `imaging`, because the constraint it expresses is
the container's memory, not one worker's.

**`MediaTotalCache` cached its own failure as 0.** For the whole TTL the gate
then saw an empty event and collapsed to the flat reserve — the behaviour the
two-halves design replaced — with no log line. And the trigger correlates with
the danger: with max_connections 10 the query fails exactly during a burst. Now
falls back to the last good reading and says so.

**V8's heap ceiling sat above the frontend container's entire budget** (measured:
259 MB inside a 256M limit), so GC could never intervene and the only
backpressure was SIGKILL under an arrival burst.

## Guest-visible

**The feed stopped being newest-first after the first reconcile.** It fetches
whole 100-item server pages while `uploads` grows in 20s, so everything in the
gap was absent from `present`, classified as new, and prepended — ~80 photos
from earlier in the evening above the newest ones. It also stalled infinite
scroll, since the cursor still pointed at item 20 and the observer only re-fires
on a change. The union is now sorted on the server's own (created_at, id) key,
which additionally places an SSE arrival correctly.

**A stale `loadMoreError` outlived every refresh and filter change**, leaving a
false error above a button that returns immediately on `!nextCursor`.

**A failed derivative toasted "Ein Upload konnte nicht verarbeitet werden."** for
a photo sitting right there on screen — the handler still assumed 1d9fb11's
pre-fix behaviour (row deleted, quota refunded, card evicted), none of which is
true any more. It was the last surviving route for the "your photo is gone"
signal that fix set out to remove.

## Enforcement that existed only in comments

`recover_name_rate_per_15min` is clamped at the point of use: the ordering
`3 x ceiling <= PIN_LOCK_THRESHOLD` is the whole control against one source
locking any guest whose name is on the feed, it was asserted in a comment, and
`patch_config` accepted 1..100_000. The test pinned the default constant rather
than the enforced bound; it now pins the bound.

## Tests that could not fail

- The gate test asserted only its own premise (`500MB x 100 > 35GB`) and never
  touched the gate. It now checks both controls against the same state and
  requires them to disagree in the right direction.
- `the_banner_always_fires_before_the_upload_gate_closes` reduced to
  `G < G + G/4` — true for any margin, including zero, so it could not detect
  the banner moving to exactly the gate. It now pins the gap.
- `disk_is_low`'s `free < LOW_DISK_FLOOR_BYTES` clause was unreachable (warn_at
  is always >= 12.5 GB against a 10 GB floor). Two tests were named after it and
  neither could fail if it were deleted. Clause and constant removed.
- The suspension test I added last commit hard-coded the credit cap instead of
  importing it, so changing STALL_TIMEOUT_MS would leave it passing against a
  system that no longer exists. Now imports MAX_SUSPEND_CREDIT_MS.

## Stale comments corrected

The prune doc still argued at length for the pre-build ordering that 1d9fb11
reversed — a reader trusting it would reopen the blocker 0506369 fixed.
DISK_RESERVE_BYTES claimed to equal the banner threshold that 0506369
deliberately offset by 25%. And host.rs kept its own duplicate 10 GB literal
instead of importing the constant.

154/154 backend, 59/59 vitest, clippy clean, svelte-check 0 errors, eslint
clean, both builds, compose + caddy validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 14:51:58 +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
  • 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 — 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:

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:

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:

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

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

Gallery.zip and Memories.zip are each 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 — which is what required_free_bytes encodes as media × 1.1 × 2.

The per-user quota does not bound this. It is a fairness mechanism that divides free space between guests, and since it carries a floor (MIN_QUOTA_LIMIT_BYTES, so a guest's allowance stops shrinking as the party fills up) the aggregate ceiling it used to imply is gone. What bounds the disk is the global gate in the upload handler, which refuses any upload that would leave too little room to build the keepsake:

free_after_upload  <  media_after × 1.1 × 2  +  DISK_RESERVE_BYTES   →   refused

Solving that for the gallery size gives the real ceiling. On the 40 GB box this runs on, with ~5 GB for the OS, Docker images (the runbook pre-pulls the rollback tag too) and Postgres:

Volume Usable after baseline Media ceiling Free at release
40 GB ~35 GB ~8 GB ~27 GB → both archives fit
80 GB ~70 GB ~19 GB ~51 GB → both archives fit

Uploads therefore stop at roughly 8 GB of media on a 40 GB box, not when the disk is full. That is deliberate. 1000 photos at ~3.5 MB is ~3.5 GB and fits comfortably; video is what consumes the budget, so lower max_video_size_mb (seeded at 500) if you expect a lot of it. Refusing the 1001st upload is a far better outcome than accepting it and discovering at 01:00 that the archive can never be built.

Two ways to buy headroom:

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

None of this is silent. The upload gate refuses with a German message naming the cause, the export preflight refuses up front with both numbers rather than hitting ENOSPC halfway through a multi-GB write, a rebuild only reclaims the superseded generation after the new one lands (so a failed rebuild can never leave you with no archive at all), 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.

Description
A private, QR-code-accessed photo & video sharing platform for weddings, birthdays, and personal events — built for guests, run by you.
Readme 4.4 MiB
Languages
Rust 36.2%
TypeScript 31.8%
Svelte 22.5%
HTML 4.9%
JavaScript 2.7%
Other 1.9%