# 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= 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 24–48h — 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 `