Files
EventSnap/PROJECT.md
fabi b89b1d6ffa chore: scaffold monorepo for EventSnap
- Rust/Axum backend skeleton with all crates, multi-stage Dockerfile
- SvelteKit + TypeScript frontend with Tailwind CSS v4, adapter-node, Dockerfile
- docker-compose.yml: db (postgres:16) → app → frontend → caddy with healthcheck and named volumes
- Caddyfile: TLS via Let's Encrypt, cache headers, API/media routing to backend
- .env.example: all environment variables documented with defaults
- README.md: project overview, features, stack, deploy guide, roadmap
- .gitignore: excludes secrets, build artifacts, node_modules, media uploads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:15:44 +02:00

1240 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project Blueprint: EventSnap
> *A private, QR-code-accessed photo & video sharing platform for weddings, birthdays, and personal events — built for guests, run by you.*
---
## Table of Contents
1. [Overview](#1-overview)
2. [Target Audience](#2-target-audience)
3. [Goals & Success Metrics](#3-goals--success-metrics)
4. [Feature Set](#4-feature-set)
5. [User Flows](#5-user-flows)
6. [System Architecture](#6-system-architecture)
7. [Tech Stack](#7-tech-stack)
8. [Third-Party Integrations](#8-third-party-integrations)
9. [Authentication & Authorization](#9-authentication--authorization)
10. [Non-Functional Requirements](#10-non-functional-requirements)
11. [Export System](#11-export-system)
12. [Upload Queue & Limits](#12-upload-queue--limits)
13. [Rate Limiting](#13-rate-limiting)
14. [Database Model (ERM & SQL)](#14-database-model-erm--sql)
15. [API Specification](#15-api-specification)
16. [Performance](#16-performance)
17. [Deployment](#17-deployment)
18. [Risks & Decision Log](#18-risks--decision-log)
19. [Next Steps](#19-next-steps)
- [Appendix A: Rust Crates](#appendix-a-rust-crates)
- [Appendix B: Frontend Libraries](#appendix-b-frontend-libraries)
---
## 1. Overview
### Problem Statement
At private events like weddings and birthday parties, photos and videos are scattered across dozens of guests' phones and never truly shared. Existing solutions (WhatsApp groups, Google Photos shared albums) 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.
### Vision
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. Guests feel seen; the host keeps everything.
### Project Type
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
### Status
Idea / Planning phase. Greenfield personal project.
---
## 2. Target Audience
### Primary Users
**Event guests** — non-technical consumers of all ages, accessing the app via a shared QR code link. They need zero-friction onboarding (name only), intuitive upload from camera or library, and a social feed they can browse and interact with. Mobile cellular or slow Wi-Fi must be assumed as the norm — data efficiency is a first-class concern.
### Secondary Users
- **Host** — the event organiser (not necessarily technical); manages guests and content via the Host Dashboard.
- **Admin** — the developer / technical operator (you); manages configuration, export pipeline, storage limits, and rate limits via the Admin Dashboard.
### User Scale
Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 files over the course of a day and the days following.
---
## 3. Goals & Success Metrics
| Goal | Metric |
|------|--------|
| Frictionless guest onboarding | Name entry to first upload in < 60 seconds |
| Reliable media collection | 0 lost uploads; all files retrievable after event |
| Engaged social experience | Guests like/comment; hashtag use emerges naturally |
| Data-light browsing | Feed usable on slow cellular via compressed previews |
| Complete archive export | Host releases gallery; guests can download within minutes |
| Simple deployment | Fresh VPS to running app: fill `.env`, run `docker compose up -d` |
---
## 4. Feature Set
### Must Have (MVP)
**Onboarding & Sessions**
- **QR code join flow** — event URL encoded in QR code; guests scan to land on the join page
- **Name-only registration** — enter display name → server issues a persistent JWT + recovery PIN
- **Session recovery** — guest on a new device enters name + PIN to reclaim their account; rate-limited: 3 attempts then 15-minute lockout
- **30-day session expiry** — JWT valid for 30 days, refreshed on activity
- **First-visit onboarding guide** — short, dismissible overlay on first login explaining the app (upload, hashtags, feed, PIN importance)
**Uploads**
- **Photo & video upload** — from device library or in-app camera/video capture via `getUserMedia`
- **Client-side upload queue** — select many files at once; app uploads sequentially with per-file progress bars and a retry button; persisted in IndexedDB
- **Caption + hashtag support** — optional short text with #hashtags; editable after upload
- **Lossless compression on ingest** — server applies lossless compression to all uploads transparently
- **Compressed preview generation** — smaller display variant for the feed; full-quality original retained for export only
- **Video thumbnail generation** — ffmpeg extracts a poster frame as the feed preview image
**Feed & Social**
- **Chronological gallery feed** — scrollable grid; shows compressed previews
- **Like & comment** — guests react to any upload; comments support hashtags
- **Own-content deletion** — guests delete only their own uploads and comments
- **Hashtag filtering in live feed** — filter gallery by one or more hashtags
- **Real-time feed updates via SSE** — new uploads/likes/comments appear without manual refresh
- **SSE lifecycle management** — client pauses SSE when app is backgrounded (Page Visibility API); reconnects + delta-fetches on foreground
**Roles & Dashboards**
- **Host Dashboard** — ban/unban users (with modal: hide or keep their uploads?), delete any content, promote guests to Host, toggle "Close event" (locks new uploads; reactions remain open), toggle "Release gallery" (unlocks export)
- **Admin Dashboard** — all Host permissions + configure per-user storage quota tolerance, per-file size limits (images & videos separately), rate limits per endpoint class, disk usage widget, export activation + progress bar
- **Admin seeded via `.env`** — `ADMIN_PASSWORD_HASH`; Hosts are promoted from guest accounts by Admin
**Export**
- **Export gate** — locked by default; Host/Admin releases post-event via "Release gallery"
- **On-demand generation** — releasing triggers an async server-side job; dashboard shows live progress bar; SSE notifies all guests when complete
- **Locked-state message** — guests see "Export not yet available — check back after the event"
- **ZIP export** — full-quality originals in structured folders
- **HTML offline viewer export** — self-contained ZIP with `Memories.html`, `README.txt`, all media, client-side hashtag filtering, comments, likes; fully offline
- **HTML export in-app guide** — brief modal shown before the HTML download
### Should Have (v1.x)
- **Individual file download** — full-quality download button per file in the gallery
- **Low-disk alert** — background task warns when < 10 GB free
- **Event banner / cover image** — configurable event name and hero image on the join page
- **Chunked upload for large videos** — resumable upload for files > 100 MB
- **Story-style highlights** — Host-curated "best of" selection pinned at the top of the feed
- **Slideshow / presentation mode** — fullscreen auto-advancing gallery for a venue screen/TV
### Could Have (Future)
- Multiple simultaneous events (multi-tenancy)
- Push notifications for new comments on a guest's uploads
- Per-guest gallery view (filter feed to a single user's uploads)
### Out of Scope
- Native iOS/Android apps
- iOS/Android native album export (users download ZIP and import via platform file manager)
- CI/CD pipeline
- User-to-user direct messaging
- Payment / monetisation
- Email-based auth
- Multi-event management UI
---
## 5. User Flows
### Core Flow: Guest Joins & Uploads
1. Guest scans QR code → lands on `https://yourdomain.com/`
2. First-visit onboarding overlay appears (dismissible after reading)
3. Enters display name → `POST /api/v1/join`
4. Server creates `User`, generates 4-digit recovery PIN, stores `bcrypt(PIN)`, issues JWT
5. PIN displayed **prominently** — large, bold, with a copy-to-clipboard button and a clear warning; also saved to `localStorage` and always accessible in "My Account"
6. Guest lands on gallery feed
7. Taps upload button → selects multiple files or opens in-app camera
8. Files enter the client-side upload queue with progress indicators
9. Per file: server receives → stores original → lossless compress → generate preview + thumbnail → write DB record → broadcast SSE `new-upload`
10. All connected guests' feeds update in real time
### Secondary Flow: Session Recovery (New Device / Cleared Storage)
1. Guest enters their name on a new device → server finds an existing match → prompt: "Were you here before? Enter your code."
2. Guest enters PIN → `bcrypt.verify()` → match → new JWT issued for existing `user_id`; PIN saved to `localStorage` on the new device
3. Wrong PIN: up to 3 attempts, then 15-minute lockout
### Secondary Flow: Returning Guest (Same Device)
1. App finds valid JWT in `localStorage` → guest lands directly in feed, no re-login required
### Secondary Flow: Event Closed (Uploads Locked)
1. Host toggles "Close event" → server sets `event.uploads_locked_at`; SSE broadcasts `event-closed`
2. Banner shown: "The event has ended — no new uploads are possible."
3. Upload button disabled; likes, comments, and browsing remain fully available
### Secondary Flow: Host Bans a User
1. Host taps "Ban user" in Host Dashboard
2. Modal: "What should happen to this user's uploads?" → "Keep visible" / "Hide"
3. Host confirms → `user.is_banned = true`, `uploads_hidden = choice`
4. Banned user's next request: HTTP 403
### Secondary Flow: Export Activation & Download
1. Host/Admin taps "Release gallery" → two `ExportJob` records enqueued
2. Background tasks process jobs; `ExportJob.progress_pct` updated in DB; dashboard shows live progress bar
3. Complete: SSE `export-available` → guest toast: "The gallery is now ready for download!"
4. Guests open Export page → two options: ZIP or HTML Viewer
5. HTML Viewer: in-app guide modal shown before download begins
6. Download starts; rate limiting enforced per IP
---
## 6. System Architecture
### Components and Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Guest Device (iOS Safari / Android Chrome) │
│ │
│ SvelteKit PWA │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ Feed / │ │ Upload │ │ Export │ │ Host / │ │
│ │ Gallery │ │ Queue │ │ Page │ │ Admin │ │
│ │ │ │ (IndexedDB)│ │ │ │ Dashboard │ │
│ └──────────┘ └────────────┘ └──────────┘ └─────────────┘ │
│ │ HTTPS REST + SSE (EventSource) │
└────────┼────────────────────────────────────────────────────────┘
┌─────────────────────┐
│ Caddy 2 │ Port 80/443 · Automatic TLS (Let's Encrypt)
│ Reverse Proxy │ HTTP/2 · gzip/zstd · Cache-Control headers
└────────┬────────────┘
│ HTTP internal port 3000
┌─────────────────────────────────────────────────────────────────┐
│ Axum HTTP Server (Rust — Single Binary) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
│ │ │ │ stream │ │ output, embedded) │ │
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
│ │ │ │
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
│ │ Tower Middleware Stack │ │ Background Workers │ │
│ │ · Auth (JWT) │ │ (tokio::spawn) │ │
│ │ · Rate Limiting (governor) │ │ · Compression worker │ │
│ │ · CORS │ │ · Preview generator │ │
│ │ · Request tracing │ │ · ffmpeg thumbnail │ │
│ └─────────────────────────────┘ │ · Export generator │ │
│ └──────────────────────────┘ │
└────────────────────┬────────────────────────────────────────────┘
┌───────────┴──────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ PostgreSQL 16 │ │ Local File Storage │
│ │ │ │
│ event │ │ /media/originals/ │
│ user │ │ /media/previews/ │
│ session │ │ /media/thumbnails/ │
│ upload │ │ /exports/ │
│ hashtag │ │ Gallery.zip │
│ upload_hashtag │ │ Memories.zip │
│ comment │ │ /backups/ │
│ comment_hashtag│ │ db_YYYY-MM-DD.sql.gz │
│ like │ └──────────────────────────┘
│ export_job │
│ config │
└─────────────────┘
```
### Docker Compose Stack
```yaml
services:
app:
build: ./backend # Multi-stage Rust Dockerfile
env_file: .env
depends_on: [db]
volumes:
- media_data:/media
restart: unless-stopped
db:
image: postgres:16-alpine
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [app]
restart: unless-stopped
volumes:
postgres_data:
media_data:
caddy_data:
```
### Caddyfile
```
{$DOMAIN} {
reverse_proxy app:3000
encode zstd gzip
# Immutable cache for content-hashed SvelteKit assets
@hashed_assets path_regexp \.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# Preview images and thumbnails: browser caches 1 hour
@previews path /media/previews/* /media/thumbnails/*
header @previews Cache-Control "public, max-age=3600"
# Original files: auth-gated, 24h browser cache
@originals path /media/originals/*
header @originals Cache-Control "private, max-age=86400"
# API and SSE: never cache
@api path /api/*
header @api Cache-Control "no-store"
}
```
### .env Configuration Variables
```dotenv
# Server
DOMAIN=my-wedding.com
APP_PORT=3000
# Database
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_DB=eventsnap
# Auth
ADMIN_PASSWORD_HASH=$2b$12$... # bcrypt hash
JWT_SECRET=<random 64-byte key>
SESSION_EXPIRY_DAYS=30
# Event
EVENT_NAME=Max & Maria's Wedding
EVENT_SLUG=max-maria-2026
# Storage & limits (overridable via Admin Dashboard)
MEDIA_PATH=/media
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
DEFAULT_QUOTA_TOLERANCE=0.75
DEFAULT_ESTIMATED_GUEST_COUNT=100
COMPRESSION_WORKER_CONCURRENCY=2
```
### Architecture Pattern
**Modular monolith.** A single Rust binary behind Caddy. Right-sized for a personal single-event project — no distributed complexity, trivially deployable and restorable.
### SSE Event Catalogue
| Event | Payload | Trigger |
|-------|---------|---------|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
| `new-like` | `{ upload_id, like_count }` | Like toggled |
| `upload-deleted` | `{ upload_id }` | Upload deleted |
| `event-closed` | `{}` | Host locks uploads |
| `event-opened` | `{}` | Host unlocks uploads |
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`
---
## 7. Tech Stack
| Layer | Technology | Rationale |
|-------|-----------|-----------|
| Frontend | SvelteKit + TypeScript | Small JS bundles, excellent mobile performance, built-in routing |
| Styling | Tailwind CSS | Utility-first, mobile-first; zero runtime CSS overhead |
| Backend | Rust + Axum | Developer preference; memory safety, single-binary deploy |
| Async Runtime | Tokio | De-facto Rust async runtime; Axum is built on it |
| Database Driver | SQLx | Async PostgreSQL with compile-time query checking; automatic prepared statements |
| Database | PostgreSQL 16 | Robust, relational; straightforward to back up |
| Auth | Custom JWT (`jsonwebtoken` crate) | No external service needed; name + PIN is the full auth model |
| Image Compression | `image` crate + `oxipng` | Lossless PNG compression; JPEG preview generation |
| Video Processing | ffmpeg (`tokio::process::Command`) | Lossless pass, thumbnail extraction, preview transcoding |
| File Storage | Local disk (`/media/`) | No S3 cost; 70 GB available is ample |
| Real-Time | Axum SSE + `tokio::sync::broadcast` | Native, lightweight, perfect for fan-out at this scale |
| ZIP Export | `async-zip` crate | Streaming ZIP generation without buffering the full archive in RAM |
| HTML Export | `minijinja` (Rust templating) | Generates `Memories.html` as a single self-contained file |
| Rate Limiting | `tower-governor` | Token-bucket per IP / per user; config from DB; hot-reloadable |
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |
---
## 8. Third-Party Integrations
| Service | Purpose | Notes |
|---------|---------|-------|
| Let's Encrypt (via Caddy) | Automatic TLS certificates | Required — `getUserMedia` only works over HTTPS; Caddy handles renewals |
| ffmpeg | Video thumbnail extraction, lossless compression, preview transcoding | Installed in the Docker image |
| Browser `getUserMedia` API | In-app camera & video capture | Works on iOS Safari 14.3+, all modern Android browsers |
| Page Visibility API | SSE lifecycle management | Browser-native; no library needed |
No paid third-party services required.
---
## 9. Authentication & Authorization
### Auth Method
**New guest flow:**
1. `POST /api/v1/join` with `{ display_name }`
2. Server creates `User`, generates a 4-digit recovery PIN, stores `bcrypt(PIN)`, issues JWT (`user_id`, `event_id`, `role`, `exp: now+30d`)
3. JWT stored in `localStorage`; PIN also saved to `localStorage` (plaintext, on-device)
4. PIN displayed prominently in a registration modal with a copy button
5. PIN always accessible afterwards in the **"My Account"** settings page — the SvelteKit component reads it directly from `localStorage`
**Recovery flow (new device / cleared storage):**
1. Guest enters their name → server finds an existing match → prompts for PIN
2. `bcrypt.verify(input, stored_hash)` → match → new JWT issued for existing `user_id`; PIN saved to `localStorage` on the new device
3. 3 failed attempts → 15-minute lockout
4. Name collision → PIN is the only disambiguator; Host can manually re-link via the Host Dashboard if PIN is lost
**Admin auth:** Password compared against `ADMIN_PASSWORD_HASH` from `.env`; Admin JWT stored in `sessionStorage` (clears on tab close)
**Host auth:** Guest accounts promoted to `role: host` by Admin; authenticate identically to guests
### Roles & Permissions
| Role | Permissions |
|------|------------|
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) |
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, lock/unlock uploads, release gallery export |
| Admin | All Host permissions + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation |
| Banned Guest | View feed only — cannot upload, like, comment, or export |
### Compliance
EU-hosted (Hetzner, Germany). Guests provide only a display name. Join page notice: *"Your name and uploads are stored for this event. Admins can delete content."* No formal GDPR tooling required for a private personal project.
---
## 10. Non-Functional Requirements
| Requirement | Target |
|-------------|--------|
| Feed performance | Loads < 1s on 4G via compressed previews |
| Upload UX | Non-blocking client queue; guest browses feed while uploads proceed |
| Availability | Best-effort; single VPS; no SLA |
| Scalability | ~100 users, ~1,000 uploads over 2448h — well within CX33 capacity |
| Disk storage | ~70 GB available; dynamic quota + file limits prevent exhaustion |
| Per-file limits | Configurable in Admin Dashboard; env defaults: images 20 MB, videos 500 MB |
| Rate limiting | Configurable per endpoint class; hot-reloadable without restart |
| Lossless compression | Applied transparently on ingest; zero quality loss |
| SSE efficiency | Client pauses on app background; server cleans up idle connections > 5 min |
| Mobile support | Primary: iOS Safari 14.3+, Android Chrome; responsive, touch-first, large tap targets |
| Camera capture | HTTPS required; Caddy ensures this automatically |
| UI language | German (DE) — all UI strings, error messages, export filenames, and guides |
| Accessibility | Best effort; semantic HTML, sufficient contrast |
---
## 11. Export System
Export is generated **on-demand after the Host/Admin releases it** — nothing is pre-built during the event.
### Lifecycle
1. "Release gallery" tapped → `event.export_released_at` set + two `ExportJob` records enqueued
2. Background Tokio tasks process jobs; `ExportJob.progress_pct` updated continuously in DB
3. Dashboard shows live progress bar (SSE-driven)
4. Complete: `export_zip_ready` / `export_html_ready` set → SSE `export-available` broadcast → guest toast
### Export Type 1: ZIP Archive (`Gallery.zip`)
Streamed via `async-zip` — no full archive RAM buffer:
```
Gallery/
Photos/
2026-06-14_15-32_Anna_DSC_0042.jpg
...
Videos/
2026-06-14_19-11_Tom_VID_0007.mp4
...
```
Full-quality originals only. File naming: `{date}_{time}_{username}_{original_filename}`
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
```
Memories/
Memories.html ← single entry point (all CSS + JS inlined; no external deps)
README.txt ← plain-text setup guide (in German, as the UI language)
Photos/ ...
Videos/ ...
```
**Fully self-contained / true offline:** `Memories.html` is a single file with all CSS and JS inlined as `<style>` and `<script>` tags — no external stylesheets, no CDN scripts, no network requests. All images and videos are referenced via **relative paths** to the sibling `Photos/` and `Videos/` folders — not base64-embedded (that would make the HTML file unworkably large). The ZIP must be unzipped first; relative paths resolve correctly from any location on disk.
**`Memories.html` features:** responsive photo/video grid, fullscreen lightbox, client-side hashtag filter chips, comments + like counts per upload, uploader name + timestamp, warm keepsake album aesthetic — all in self-contained vanilla JS + CSS.
**`README.txt`** (in German, as the app's UI language):
```
Willkommen in der Event-Galerie!
So geht's:
1. Entpacke diese ZIP-Datei
(Windows: Rechtsklick > "Alle extrahieren"; Mac: Doppelklick;
Handy: Dateimanager-App verwenden).
2. Öffne die Datei "Memories.html" in deinem Browser
(z. B. Chrome, Safari oder Firefox).
3. Stöbere durch alle Fotos und Videos.
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.
4. Eine Internetverbindung ist nicht nötig.
Alles ist lokal auf deinem Gerät gespeichert.
Viel Freude mit den Erinnerungen!
```
For video-heavy events the ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
---
## 12. Upload Queue & Limits
### Client-Side Upload Queue
- Select any number of files at once (photo + video mix)
- Queued in **IndexedDB** — survives page reloads and app backgrounding
- Uploads proceed sequentially — one file at a time — to avoid saturating mobile connections
- Per-file display: filename, size, progress bar, status (`Pending` / `Uploading` / `Done` / `Error`)
- Retry button on failed uploads
- Guest can freely browse the feed while the queue runs in the background
### Server-Side Limits
| Limit | Default | Scope |
|-------|---------|-------|
| Max image file size | 20 MB | Per file |
| Max video file size | 500 MB | Per file |
| Per-user storage quota | Dynamic (formula) | Per user per event |
| Upload rate | 10 / hour | Per `user_id` |
| Feed request rate | 60 / min | Per IP |
| Export download rate | 3 / day | Per IP |
### Dynamic Per-User Storage Quota
```
quota_per_user = ⌊(free_disk_bytes × tolerance) / max(active_uploaders, 1)⌋
```
- `free_disk_bytes`: queried via the `sysinfo` crate on each upload attempt
- `tolerance`: admin-configurable (default: 0.75)
- `active_uploaders`: users with ≥ 1 non-deleted upload
**Example:** 70 GB free · tolerance 0.75 · 30 active uploaders → ~1.75 GB per user
On quota exceeded: HTTP 429 — *"Du hast dein Upload-Limit für dieses Event erreicht."*
---
## 13. Rate Limiting
Tower middleware layers on the Axum router. All limits stored in the `config` table — hot-reloadable from the Admin Dashboard without a restart.
| Endpoint Class | Default | Key |
|---------------|---------|-----|
| `POST /api/v1/upload` | 10 / hour | per `user_id` |
| `GET /api/v1/feed` | 60 / min | per IP |
| `GET /media/previews/*` | 120 / min | per IP |
| `GET /api/v1/export/zip` | 3 / day | per IP |
| `GET /api/v1/export/html` | 3 / day | per IP |
| `POST /api/v1/join` | 5 / min | per IP |
| Recovery PIN attempts | 3 then 15-min lockout | per `user_id` |
Rate limit response: HTTP 429 — *"Zu viele Anfragen. Bitte warte kurz und versuche es erneut."*
---
## 14. Database Model (ERM & SQL)
### Entity-Relationship Overview
```
event ──< user ──< session
│ │
│ └──< upload ──< comment ──< comment_hashtag >── hashtag
│ │ ▲
│ └──< like │
│ └──< upload_hashtag >─────────────────────────
└──< export_job
└── config (key/value)
```
**Cardinalities:**
- `event` 1:N `user`, `upload`, `export_job`
- `user` 1:N `session`, `upload`, `comment`
- `upload` 1:N `comment`, `like`
- `upload` M:N `hashtag` via `upload_hashtag`
- `comment` M:N `hashtag` via `comment_hashtag`
### DDL: Full Schema
```sql
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Enums
CREATE TYPE user_role AS ENUM ('guest', 'host', 'admin');
CREATE TYPE export_type AS ENUM ('zip', 'html');
CREATE TYPE export_status AS ENUM ('pending', 'running', 'done', 'failed');
-- ─────────────────────────────────────────
-- event
-- ─────────────────────────────────────────
CREATE TABLE event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
cover_image_path TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
uploads_locked_at TIMESTAMPTZ, -- NULL = open
export_released_at TIMESTAMPTZ, -- NULL = locked
export_zip_ready BOOLEAN NOT NULL DEFAULT FALSE,
export_html_ready BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─────────────────────────────────────────
-- user
-- ─────────────────────────────────────────
CREATE TABLE "user" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'guest',
is_banned BOOLEAN NOT NULL DEFAULT FALSE,
uploads_hidden BOOLEAN NOT NULL DEFAULT FALSE,
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- No UNIQUE(event_id, display_name) — PIN disambiguates name collisions
);
-- ─────────────────────────────────────────
-- session
-- ─────────────────────────────────────────
CREATE TABLE session (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256(JWT)
expires_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ─────────────────────────────────────────
-- upload
-- ─────────────────────────────────────────
CREATE TABLE upload (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id),
original_path TEXT NOT NULL,
preview_path TEXT, -- NULL until compression completes
thumbnail_path TEXT, -- NULL until ffmpeg thumbnail ready
mime_type TEXT NOT NULL,
original_size_bytes BIGINT NOT NULL,
caption TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- soft delete
);
-- ─────────────────────────────────────────
-- hashtag
-- ─────────────────────────────────────────
CREATE TABLE hashtag (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
tag TEXT NOT NULL, -- normalised: lowercase, no #
UNIQUE (event_id, tag)
);
CREATE TABLE upload_hashtag (
upload_id UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
hashtag_id UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
PRIMARY KEY (upload_id, hashtag_id)
);
-- ─────────────────────────────────────────
-- comment
-- ─────────────────────────────────────────
CREATE TABLE comment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
upload_id UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE comment_hashtag (
comment_id UUID NOT NULL REFERENCES comment(id) ON DELETE CASCADE,
hashtag_id UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
PRIMARY KEY (comment_id, hashtag_id)
);
-- ─────────────────────────────────────────
-- like
-- ─────────────────────────────────────────
CREATE TABLE "like" (
upload_id UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (upload_id, user_id)
);
-- ─────────────────────────────────────────
-- export_job
-- ─────────────────────────────────────────
CREATE TABLE export_job (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
type export_type NOT NULL,
status export_status NOT NULL DEFAULT 'pending',
progress_pct SMALLINT NOT NULL DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
file_path TEXT, -- NULL until generation complete
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (event_id, type) -- max one active job per type
);
-- ─────────────────────────────────────────
-- config (admin-configurable runtime settings)
-- ─────────────────────────────────────────
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seeded from .env on first start; ON CONFLICT = keep existing overrides
INSERT INTO config (key, value) VALUES
('max_image_size_mb', '20'),
('max_video_size_mb', '500'),
('upload_rate_per_hour', '10'),
('feed_rate_per_min', '60'),
('export_rate_per_day', '3'),
('quota_tolerance', '0.75'),
('estimated_guest_count', '100'),
('compression_concurrency', '2')
ON CONFLICT (key) DO NOTHING;
```
### Indexes
```sql
-- Feed main query (covers > 90% of all DB requests)
CREATE INDEX idx_upload_event_created
ON upload(event_id, created_at DESC)
WHERE deleted_at IS NULL;
-- A user's own uploads (quota check, "My Account")
CREATE INDEX idx_upload_user
ON upload(user_id)
WHERE deleted_at IS NULL;
-- Comments per upload
CREATE INDEX idx_comment_upload
ON comment(upload_id)
WHERE deleted_at IS NULL;
-- Like count (without this = full table scan on every feed request)
CREATE INDEX idx_like_upload
ON "like"(upload_id);
-- Hashtag filtering
CREATE INDEX idx_upload_hashtag_hashtag
ON upload_hashtag(hashtag_id);
CREATE INDEX idx_hashtag_event_tag
ON hashtag(event_id, tag);
-- Session lookup (runs on every authenticated API request)
CREATE UNIQUE INDEX idx_session_token_hash
ON session(token_hash);
-- Expired session cleanup
CREATE INDEX idx_session_expires
ON session(expires_at);
-- User lookup for recovery flow
CREATE INDEX idx_user_event_name
ON "user"(event_id, display_name);
-- Export job status
CREATE UNIQUE INDEX idx_export_job_event_type
ON export_job(event_id, type);
```
### Views
```sql
-- v_feed: uploads with uploader name, like count, and comment count
-- Used in the feed endpoint as a single efficient query
CREATE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.mime_type,
u.caption,
u.created_at,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
-- v_hashtag_counts: most-used hashtags for an event (for filter chips)
CREATE VIEW v_hashtag_counts AS
SELECT
h.event_id,
h.tag,
COUNT(uh.upload_id) AS upload_count
FROM hashtag h
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
GROUP BY h.event_id, h.id, h.tag
ORDER BY upload_count DESC;
```
---
## 15. API Specification
### Versioning & Base Path
All endpoints under `/api/v1/`. Breaking changes introduced as `/api/v2/` without removing existing routes.
### Authentication
- Guests / Hosts / Admin: `Authorization: Bearer <JWT>`
- Missing token: `401 Unauthorized`
- Insufficient permissions: `403 Forbidden`
### DTOs
```typescript
// ─── Auth ──────────────────────────────────────────────────────
interface JoinRequest { display_name: string }
interface JoinResponse { jwt: string; pin: string; user_id: string; is_new: boolean }
interface RecoverRequest { display_name: string; pin: string }
interface RecoverResponse { jwt: string; user_id: string }
interface AdminLoginRequest { password: string }
interface AdminLoginResponse { jwt: string }
// ─── Feed ──────────────────────────────────────────────────────
interface UploadDto {
id: string; uploader_name: string; user_id: string;
preview_url: string; mime_type: string;
caption: string | null; hashtags: string[];
like_count: number; comment_count: number; liked_by_me: boolean;
created_at: string;
}
interface FeedResponse { uploads: UploadDto[]; next_cursor: string | null }
interface DeltaResponse { uploads: UploadDto[]; deleted_ids: string[] }
// ─── Upload ────────────────────────────────────────────────────
// Request: multipart/form-data; fields: file (Blob), caption? (string), hashtags? (CSV)
interface UploadResponse { upload: UploadDto }
interface EditUploadRequest { caption?: string; hashtags?: string[] }
// ─── Comments ──────────────────────────────────────────────────
interface CommentDto {
id: string; upload_id: string; user_id: string;
uploader_name: string; body: string;
hashtags: string[]; created_at: string;
}
interface CommentsResponse { comments: CommentDto[] }
interface AddCommentRequest { body: string }
// ─── Export ────────────────────────────────────────────────────
interface ExportJobDto { status: 'locked'|'pending'|'running'|'done'|'failed'; progress_pct: number }
interface ExportStatusResponse { released: boolean; zip: ExportJobDto; html: ExportJobDto }
// ─── Host / Admin ──────────────────────────────────────────────
interface UserDto {
id: string; display_name: string; role: string;
is_banned: boolean; uploads_hidden: boolean;
upload_count: number; total_upload_bytes: number; created_at: string;
}
interface BanRequest { hide_uploads: boolean }
interface ConfigDto { [key: string]: string }
interface StatsDto {
user_count: number; upload_count: number; comment_count: number;
disk_total_bytes: number; disk_used_bytes: number; disk_free_bytes: number;
}
```
### Endpoints
#### Auth
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/v1/join` | — | Join event; returns JWT + PIN |
| `POST` | `/api/v1/recover` | — | Recover session by name + PIN |
| `POST` | `/api/v1/admin/login` | — | Admin password auth |
| `DELETE` | `/api/v1/session` | Guest | Log out (invalidate JWT) |
#### Feed & Uploads
| Method | Path | Auth | Query | Description |
|--------|------|------|-------|-------------|
| `GET` | `/api/v1/feed` | Guest | `cursor`, `limit` (def. 20), `hashtag` | Paginated feed (cursor-based) |
| `GET` | `/api/v1/feed/delta` | Guest | `since` (ISO timestamp) | Uploads since last SSE reconnect |
| `POST` | `/api/v1/upload` | Guest | — | Upload a file (`multipart/form-data`) |
| `PATCH` | `/api/v1/upload/{id}` | Guest (own) | — | Edit caption / hashtags |
| `DELETE` | `/api/v1/upload/{id}` | Guest (own) | — | Soft-delete own upload |
| `GET` | `/api/v1/upload/{id}/original` | Guest | — | Download full-quality original |
#### Social
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/v1/upload/{id}/like` | Guest | Toggle like (like / unlike) |
| `GET` | `/api/v1/upload/{id}/comments` | Guest | List comments for an upload |
| `POST` | `/api/v1/upload/{id}/comment` | Guest | Add a comment |
| `DELETE` | `/api/v1/comment/{id}` | Guest (own) | Delete own comment |
| `GET` | `/api/v1/hashtags` | Guest | All event hashtags with counts (for filter chips) |
#### SSE
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/stream` | Guest | Open SSE connection; delivers all feed events |
#### Export
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/export/status` | Guest | Release status and generation progress |
| `GET` | `/api/v1/export/zip` | Guest | Download `Gallery.zip` (only if `export_zip_ready`) |
| `GET` | `/api/v1/export/html` | Guest | Download `Memories.zip` (only if `export_html_ready`) |
#### Host Dashboard
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/host/users` | Host | List all event users |
| `POST` | `/api/v1/host/users/{id}/ban` | Host | Ban user (`BanRequest`) |
| `POST` | `/api/v1/host/users/{id}/unban` | Host | Lift ban |
| `PATCH` | `/api/v1/host/users/{id}/role` | Host | Set role (`{ role: "guest"\|"host" }`) |
| `DELETE` | `/api/v1/host/upload/{id}` | Host | Delete any upload |
| `DELETE` | `/api/v1/host/comment/{id}` | Host | Delete any comment |
| `POST` | `/api/v1/host/event/close` | Host | Lock new uploads |
| `POST` | `/api/v1/host/event/open` | Host | Unlock new uploads |
| `POST` | `/api/v1/host/gallery/release` | Host | Trigger export generation + set release |
#### Admin Dashboard
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/admin/stats` | Admin | Disk usage, user/upload counts |
| `GET` | `/api/v1/admin/config` | Admin | Retrieve all configurable settings |
| `PATCH` | `/api/v1/admin/config` | Admin | Update settings (`ConfigDto`) |
| `GET` | `/api/v1/admin/export/jobs` | Admin | Export job status and progress |
### Error Format
```json
{
"error": "upload_quota_exceeded",
"message": "Du hast dein Upload-Limit für dieses Event erreicht.",
"status": 429
}
```
### HTTP Status Codes
| Code | Usage |
|------|-------|
| `200 OK` | Successful GET / PATCH |
| `201 Created` | Successful POST (upload, comment, join) |
| `204 No Content` | Successful action with no response body (like toggle, logout) |
| `400 Bad Request` | Validation error, missing required field |
| `401 Unauthorized` | Missing or invalid JWT |
| `403 Forbidden` | Insufficient permissions or banned user |
| `404 Not Found` | Resource not found or soft-deleted |
| `413 Payload Too Large` | File exceeds configured size limit |
| `429 Too Many Requests` | Rate limit or quota exceeded |
| `500 Internal Server Error` | Unexpected server error |
---
## 16. Performance
### Strategy Overview
EventSnap does not need a dedicated caching layer like Redis or Memcached. The combination of OS page cache, HTTP cache headers, SQL indexes, and SQLx prepared statements delivers excellent performance for the projected load (~100 users, ~1,000 uploads). Redis would make sense with thousands of concurrent users across multiple app instances — that is not the case here.
### 1. HTTP Caching (Highest Impact)
| Asset Type | `Cache-Control` | Effect |
|------------|----------------|--------|
| SvelteKit JS/CSS/Fonts (content-hashed) | `public, max-age=31536000, immutable` | Loaded once, cached forever — 0 bytes on subsequent visits |
| Preview images & thumbnails | `public, max-age=3600` | Once seen = never re-downloaded within the same hour |
| Original files (auth-gated) | `private, max-age=86400` | One download per day per browser |
| API endpoints & SSE | `no-store` | Feed always fresh |
A guest who reloads the feed or switches back to the app does **not re-download** previews they have already seen. On mobile networks this is the single largest contributor to perceived speed and data efficiency.
### 2. OS Page Cache (Free, Automatic)
Linux uses the RAM not occupied by PostgreSQL and the Rust binary (~7 GB of 8 GB) as a file cache automatically. Frequently accessed preview images and thumbnails stay in memory with no additional infrastructure required. No Redis needed.
### 3. SQL Prepared Statements via SQLx
SQLx validates all queries **at compile time** and submits them as prepared statements to PostgreSQL on first execution. Subsequent calls skip the parse/plan step entirely — particularly valuable for the feed endpoint, which runs on every page load:
```sql
-- Critical feed query via v_feed (held as a prepared statement):
SELECT *
FROM v_feed
WHERE event_id = $1
AND created_at < $2 -- cursor-based pagination
ORDER BY created_at DESC
LIMIT $3;
```
### 4. Database Connection Pooling
```rust
let pool = PgPoolOptions::new()
.max_connections(10) // sufficient for 100 concurrent users
.min_connections(2) // always keep 2 connections warm
.connect(&database_url)
.await?;
```
No connection establishment latency per request.
### 5. Strategic SQL Indexes
| Index | Query it accelerates | Rationale |
|-------|---------------------|-----------|
| `idx_upload_event_created` (Partial) | Feed main query | Covers > 90% of all DB requests; partial index on non-deleted rows only |
| `idx_like_upload` | Like count in `v_feed` | Without it: full table scan on every feed request |
| `idx_session_token_hash` (UNIQUE) | Auth middleware | Runs on **every** authenticated API request |
| `idx_hashtag_event_tag` | Hashtag filtering | Direct lookup instead of sequential scan |
| `idx_comment_upload` (Partial) | Comment lists | Partial on non-deleted; reduces index size |
### 6. Two-Tier Media Serving
| Tier | Used For | Typical Size | Serving Policy |
|------|---------|-------------|----------------|
| **Preview** | Feed grid | ~50150 KB (JPEG, 800px) | Always; browser-cached 1h |
| **Thumbnail** | Video poster frame in feed | ~3080 KB (WebP) | Feed grid for videos; browser-cached 1h |
| **Original** | Export, individual download | 420 MB (image), up to 500 MB (video) | Explicit request only |
During normal operation guests load previews exclusively — originals are served only on export or conscious download. This reduces feed traffic by ~95%.
### 7. Compression Worker Pool
```rust
// Semaphore bounds concurrent compression tasks
let semaphore = Arc::new(Semaphore::new(concurrency_limit));
tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
compress_and_generate_preview(upload_id, path).await;
});
```
The upload confirmation is returned immediately after the original is written to disk — compression and preview generation run asynchronously afterwards. `COMPRESSION_WORKER_CONCURRENCY` (default: 2) prevents RAM spikes on the 8 GB CX33.
### 8. SSE Connection Management
- Server cleans up SSE connections idle for > 5 minutes
- Client proactively closes the connection on `visibilitychange: hidden`
- On reconnect: only a delta via `GET /api/v1/feed/delta?since=` — no full feed re-fetch
### 9. Recommended PostgreSQL Configuration
Tuning for the CX33 (8 GB RAM), set via Docker Compose environment variables:
```
shared_buffers = 2GB # 25% of RAM for PostgreSQL's own buffer cache
work_mem = 16MB # for sort and join operations
maintenance_work_mem = 256MB # for VACUUM, index builds
effective_cache_size = 6GB # planner hint (OS cache + shared_buffers)
```
---
## 17. Deployment
### Complete Setup on a Fresh VPS
```bash
# 1. Install Docker (includes Compose plugin)
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER && newgrp docker
# 2. Copy project files to the server
scp -r eventsnap/ user@your-vps:/opt/eventsnap
cd /opt/eventsnap
# 3. Configure
cp .env.example .env
nano .env # set DOMAIN, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.
# 4. Start
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.
### Recommended Monorepo Structure
```
eventsnap/
backend/
src/
main.rs
routes/ ← endpoint handlers
models/ ← DB structs (SQLx)
workers/ ← compression, export
sse.rs
migrations/ ← SQLx migration files
Cargo.toml
frontend/
src/
routes/
lib/
upload-queue.ts ← IndexedDB-backed queue
sse.ts ← SSE client + Page Visibility lifecycle
package.json
docker-compose.yml
Caddyfile
.env.example
README.md
```
### Backup Strategy
```bash
# Daily (e.g. as a separate Compose service or cron on the VPS)
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
# Weekly: rsync /media volume to Hetzner Storage Box
rsync -az /opt/eventsnap/media/ \
user@u123456.your-storagebox.de:backup/eventsnap/
```
The `/media` volume contains originals, previews, thumbnails, generated exports, and DB backups — a single volume to back up.
---
## 18. Risks & Decision Log
### Known Risks
| Risk | Mitigation |
|------|-----------|
| Disk exhaustion | Dynamic quota + per-file limits + admin disk widget + low-disk alert (< 10 GB free) |
| iOS Safari `getUserMedia` bugs | Test early on real iOS devices; "Upload from library" always equally prominent |
| Large video upload connection drops | Client queue with retry; chunked upload planned for v1.x (files > 100 MB) |
| Recovery PIN forgotten | PIN visually unmissable at registration + copy button + always in "My Account"; Host can manually re-link |
| Export generation time | `async-zip` streams directly to disk (no RAM buffer); progress bar + SSE notification handle UX |
| ffmpeg RAM spikes | Bounded worker pool via `tokio::sync::Semaphore` (default: 2 concurrent tasks) |
### Decision Log
| Decision | Chosen | Rationale |
|----------|--------|-----------|
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page | Simple for non-technical guests; no email required |
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
| Caching layer | No Redis / Memcached | OS page cache + HTTP headers + indexes are sufficient for this load |
| Reverse proxy | Caddy instead of Nginx + Certbot | Automatic TLS with zero manual configuration |
| CI/CD | None — `docker compose up` only | Right-sized for a personal project |
---
## 19. Next Steps
1. **Provision Hetzner CX33** — create server, point DNS A record to IP, install Docker
2. **Scaffold monorepo**`backend/`, `frontend/`, `docker-compose.yml`, `Caddyfile`, `.env.example`
3. **DB schema + SQLx migrations** — all tables, views, and indexes; test with `docker compose up db`
4. **Auth flow** — join endpoint, JWT issuance, PIN generation + bcrypt storage + recovery verification
5. **Upload pipeline** — multipart POST → disk write → compression worker (Tokio task) → preview generation → DB record → SSE broadcast
6. **Client upload queue** — SvelteKit component: IndexedDB queue, sequential upload, per-file progress, retry, background operation
7. **Gallery feed** — grid view with compressed previews, SSE live updates, hashtag filter chips, Page Visibility API SSE pause/resume + delta-fetch
8. **Camera capture**`getUserMedia` SvelteKit component; test on real iOS and Android hardware
9. **Host Dashboard** — ban flow with upload visibility modal, delete, promote, event lock toggle, export release toggle
10. **Admin Dashboard** — config UI (limits, rates, quota tolerance), disk usage widget, export progress bar
11. **Export engine** — async ZIP streamer, then HTML bundle generator (`Memories.html` + `README.txt` via `minijinja`)
12. **Rate limiting middleware**`tower-governor` layers on all endpoint classes; read limits from `config` table
13. **"My Account" page** — PIN display from `localStorage`, session info
14. **Onboarding guide + HTML export guide** — dismissible overlays; `guide-seen` flag in `localStorage`
15. **End-to-end test event** — dry run with 10+ real devices on different networks (Wi-Fi + cellular) before any real event
---
## Appendix A: Rust Crates
| Crate | Purpose |
|-------|---------|
| `axum` | Web framework |
| `tokio` | Async runtime |
| `sqlx` | Async PostgreSQL driver; compile-time query checking; prepared statements; migrations |
| `jsonwebtoken` | JWT sign / verify |
| `bcrypt` | PIN + admin password hashing |
| `uuid` | UUID v7 (time-sortable) |
| `serde` / `serde_json` | Serialisation |
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
| `tower-governor` | Token-bucket rate limiting (per IP and per user) |
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
| `minijinja` | HTML export template rendering (`Memories.html`) |
| `image` | Image decoding, resizing, preview generation |
| `oxipng` | Lossless PNG compression |
| `tracing` + `tracing-subscriber` | Structured logging |
| `anyhow` | Ergonomic error handling |
| `dotenvy` | `.env` file loading |
| `sysinfo` | Disk usage querying for dynamic quota calculation |
## Appendix B: Frontend Libraries (npm)
| Library | Purpose |
|---------|---------|
| `@sveltejs/kit` | Routing, SSR, PWA manifest + service worker |
| `qrcode` | Client-side QR code generation for the event join link (Host/Admin view) |
| `idb` | Type-safe IndexedDB wrapper for the upload queue |
---
*Blueprint finalised — March 2026*