Compare commits
33 Commits
1685bf105c
...
fix/audit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d55e941c | ||
|
|
a895bebdc2 | ||
|
|
2d7169e971 | ||
|
|
db1e5b8833 | ||
|
|
d4181b1119 | ||
|
|
23a7d89a89 | ||
|
|
8272197cea | ||
|
|
0737288ed9 | ||
|
|
5bd008591b | ||
|
|
cf428725b9 | ||
|
|
ae6c496f94 | ||
|
|
9364cb624a | ||
|
|
8faf702208 | ||
|
|
ab9f1d89b2 | ||
|
|
2068e8c1f3 | ||
|
|
bbec815854 | ||
|
|
309c25bc06 | ||
|
|
b241ba6415 | ||
|
|
d228676a56 | ||
|
|
2340f21637 | ||
|
|
e42d8a92a1 | ||
|
|
1cdab21514 | ||
|
|
05f76514a2 | ||
|
|
e6efffafe5 | ||
|
|
16d8bdb680 | ||
|
|
2761ac7db6 | ||
|
|
e619a3bd64 | ||
|
|
8a769b52bf | ||
|
|
251f9f1469 | ||
|
|
2e98f5ddf5 | ||
|
|
141c918dd5 | ||
|
|
9a0ceeced7 | ||
|
|
f7fdfa4627 |
12
.env.example
12
.env.example
@@ -4,15 +4,21 @@ DOMAIN=my-event.example.com
|
||||
|
||||
# ── App server ────────────────────────────────────────────────────────────────
|
||||
APP_PORT=3000
|
||||
# docker-compose.yml already forces APP_ENV=production for the `app` service.
|
||||
# Only set this for a non-compose (bare cargo) run; leave unset for local dev.
|
||||
# APP_ENV=production
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
|
||||
# Set a strong password and keep it identical in DATABASE_URL and POSTGRES_PASSWORD.
|
||||
DATABASE_URL=postgres://eventsnap:CHANGE_ME_strong_db_password@db:5432/eventsnap
|
||||
POSTGRES_USER=eventsnap
|
||||
POSTGRES_PASSWORD=secret
|
||||
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
|
||||
POSTGRES_DB=eventsnap
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Generate with: openssl rand -hex 64
|
||||
# REQUIRED in production: generate with `openssl rand -hex 64` (128 hex chars).
|
||||
# The backend refuses to start in production with this placeholder or any value
|
||||
# that is short or contains change/example/placeholder/replace.
|
||||
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
|
||||
SESSION_EXPIRY_DAYS=30
|
||||
|
||||
|
||||
110
.github/workflows/e2e.yml
vendored
Normal file
110
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E (chromium-desktop)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'e2e/package.json'
|
||||
|
||||
- name: Install e2e deps
|
||||
working-directory: ./e2e
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ./e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Bring up the test stack
|
||||
working-directory: ./e2e
|
||||
run: docker compose -f docker-compose.test.yml up -d --build
|
||||
|
||||
- name: Wait for stack health
|
||||
working-directory: ./e2e
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3101/health > /dev/null; then exit 0; fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Stack never became healthy. Dumping logs:"
|
||||
docker compose -f docker-compose.test.yml logs
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: ./e2e
|
||||
run: npm run test:e2e -- --project=chromium-desktop
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: traces
|
||||
path: e2e/test-results/
|
||||
retention-days: 14
|
||||
|
||||
- name: Tear down stack
|
||||
if: always()
|
||||
working-directory: ./e2e
|
||||
run: docker compose -f docker-compose.test.yml down -v
|
||||
|
||||
smoke-ua-matrix:
|
||||
name: Cross-UA smoke matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install e2e deps
|
||||
working-directory: ./e2e
|
||||
run: npm install
|
||||
- name: Install Playwright browsers (full)
|
||||
working-directory: ./e2e
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Bring up the test stack
|
||||
working-directory: ./e2e
|
||||
run: docker compose -f docker-compose.test.yml up -d --build
|
||||
- name: Wait for stack health
|
||||
working-directory: ./e2e
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3101/health > /dev/null; then exit 0; fi
|
||||
sleep 2
|
||||
done
|
||||
docker compose -f docker-compose.test.yml logs
|
||||
exit 1
|
||||
- name: Smoke matrix
|
||||
working-directory: ./e2e
|
||||
run: npm run test:e2e:smoke
|
||||
- name: Upload smoke report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-smoke-report
|
||||
path: e2e/playwright-report/
|
||||
retention-days: 14
|
||||
- name: Tear down
|
||||
if: always()
|
||||
working-directory: ./e2e
|
||||
run: docker compose -f docker-compose.test.yml down -v
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -14,6 +14,17 @@ frontend/export-viewer/.svelte-kit/
|
||||
# Media uploads (mounted volume in production)
|
||||
media/
|
||||
|
||||
# Playwright E2E suite — runtime artifacts (the suite itself is committed)
|
||||
e2e/node_modules/
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
e2e/.cache/
|
||||
e2e/.env.test
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local audit/review reports — generated working artifacts, kept out of git
|
||||
docs/AUDIT-2026-06-27.md
|
||||
docs/FIX-VERIFICATION-2026-06-27.md
|
||||
|
||||
25
Caddyfile
25
Caddyfile
@@ -5,21 +5,24 @@
|
||||
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
||||
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# Media previews and thumbnails
|
||||
@previews path /media/previews/* /media/thumbnails/*
|
||||
header @previews Cache-Control "public, max-age=3600"
|
||||
|
||||
# Original media files (private — only host can download)
|
||||
@originals path /media/originals/*
|
||||
header @originals Cache-Control "private, max-age=86400"
|
||||
|
||||
# API — never cache
|
||||
@api path /api/*
|
||||
header @api Cache-Control "no-store"
|
||||
|
||||
# Route API and media requests to the Rust backend
|
||||
reverse_proxy /api/* app:3000
|
||||
reverse_proxy /media/* app:3000
|
||||
# Media is served by the authenticated gateway at /media/{kind}/{id}, which
|
||||
# sets its own Cache-Control (private) and security headers per response — no
|
||||
# Caddy-side cache rules (the old /media/previews|originals matchers were for
|
||||
# the retired static mount).
|
||||
|
||||
# Route API and media requests to the Rust backend. Set X-Real-IP from the
|
||||
# real TCP peer and overwrite any client-supplied value so the backend's
|
||||
# rate-limit keys can't be spoofed via a forged X-Forwarded-For.
|
||||
reverse_proxy /api/* app:3000 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
reverse_proxy /media/* app:3000 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Everything else goes to SvelteKit frontend
|
||||
reverse_proxy frontend:3001
|
||||
|
||||
53
FOLLOWUPS.md
Normal file
53
FOLLOWUPS.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Follow-ups
|
||||
|
||||
Tracked work that was deferred during the multi-round UI/UX review pass.
|
||||
Each item has a clear acceptance criterion so a future pass can land it
|
||||
without re-deriving the context.
|
||||
|
||||
## A11y — assistive-tech containment inside modals
|
||||
|
||||
**Problem.** Open modals (LightboxModal, ConfirmSheet, Modal, OnboardingGuide,
|
||||
join PIN modal, account data-mode sheet, export HTML guide) trap keyboard Tab
|
||||
via `focusTrap`, but VoiceOver rotor / TalkBack arrow-key navigation can still
|
||||
escape into the page content behind the dialog. Screen-reader users hear the
|
||||
wrong context.
|
||||
|
||||
**Why deferred.** A naive sibling-walk that sets `inert` on direct children of
|
||||
the modal's parent silences the global `<Toaster>` (`aria-live="polite"` region
|
||||
mounted in `+layout.svelte`) and the `<BottomNav>` while a modal is open —
|
||||
breaking toast announcements and the visible nav state. SvelteKit has no
|
||||
built-in portal mechanism, so dialogs render inside the route tree alongside
|
||||
the Toaster.
|
||||
|
||||
**Acceptance criterion.** With any modal open:
|
||||
- VoiceOver rotor (iOS Safari) and TalkBack swipe navigation (Android Chrome)
|
||||
cannot leave the dialog subtree.
|
||||
- Toasts that fire while a modal is open are still announced.
|
||||
- Nested modals (e.g. ConfirmSheet opened from inside ContextSheet) maintain
|
||||
correct containment when the inner closes.
|
||||
|
||||
**Sketch of an approach.** One of:
|
||||
1. **Portal pattern.** Render dialogs into a dedicated `<div id="modal-root">`
|
||||
that's a sibling of the main app root in `app.html`. `focusTrap` then sets
|
||||
`inert` on the main root, leaving the modal root and the toast region (also
|
||||
moved to its own portal root) untouched.
|
||||
2. **Opt-out marker.** Walk siblings and inert them, but skip any node carrying
|
||||
a `data-modal-passthrough` attribute. Mark `<Toaster>` with it. Document
|
||||
the contract.
|
||||
3. **Stack-aware containment.** Maintain a module-level stack of open dialog
|
||||
nodes; the topmost owns the inert state, popped dialogs restore the
|
||||
previous layer. Avoids the nested-modal restoration bug.
|
||||
|
||||
Approach 1 is the cleanest long-term but the highest blast radius. Approach 2
|
||||
is the smallest patch.
|
||||
|
||||
**Files to touch.**
|
||||
- [frontend/src/lib/actions/focus-trap.ts](frontend/src/lib/actions/focus-trap.ts) — add inert logic
|
||||
- [frontend/src/lib/components/Toaster.svelte](frontend/src/lib/components/Toaster.svelte) — add passthrough marker (if approach 2) or move to a portal (if approach 1)
|
||||
- [frontend/src/app.html](frontend/src/app.html) — add `<div id="modal-root">` (if approach 1)
|
||||
|
||||
## Smaller nits, optional
|
||||
|
||||
- **Auto-submit on retried 4th digit.** [recover/+page.svelte](frontend/src/routes/recover/+page.svelte), [join/+page.svelte](frontend/src/routes/join/+page.svelte) — after a wrong PIN, deleting one digit and retyping triggers an immediate submit. Backend's 3-attempts/15-min lockout makes this safe; could feel hair-trigger after a typo. Consider gating the second auto-submit per input session behind an explicit button press.
|
||||
- **Onboarding pip tap target on the vertical axis.** [OnboardingGuide.svelte](frontend/src/lib/components/OnboardingGuide.svelte) — current `p-2.5` yields ~26 px height, meets WCAG 2.2 AA (≥24 px) but below iOS HIG / Material's 44 / 48 dp recommendation. Bumping to `p-3` is the easy improvement; further increases start crowding the row.
|
||||
- **Migrate bespoke focus-trapped dialogs to `<Modal>`.** Join PIN modal, OnboardingGuide, LightboxModal, HTML guide, account data-mode sheet — all currently roll their own shell with `focusTrap`. They're correct, just not using the canonical primitive. Migrate when `<Modal>` gains features (e.g. the inert work above) you'd want everywhere.
|
||||
122
PROJECT.md
122
PROJECT.md
@@ -42,7 +42,7 @@ A guest scans the QR code on their way in, types their name, and is immediately
|
||||
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
|
||||
|
||||
### Status
|
||||
Idea / Planning phase. Greenfield personal project.
|
||||
Implementation in progress (~v0.16). Core flows + new features all wired: auth, feed, upload, host/admin dashboards, ZIP + HTML-viewer export, SSE with delta-fetch on reconnect, toggleable rate limits + quotas with live per-user estimate, host PIN reset with one-time modal, data-mode (Saver/Original), Datenschutzhinweis, mobile gestures (long-press context sheet, double-tap to like), and the live Diashow with pluggable transitions. Open items: low-disk alert, event banner UI, chunked resumable upload for very large videos. See [FEATURES.md](docs/FEATURES.md) for the capability matrix.
|
||||
|
||||
---
|
||||
|
||||
@@ -208,9 +208,9 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
|
||||
│ Axum HTTP Server (Rust — Single Binary) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
||||
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
|
||||
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
|
||||
│ │ │ │ stream │ │ output, embedded) │ │
|
||||
│ │ REST API │ │ SSE Engine │ │ Media Static Server │ │
|
||||
│ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
|
||||
│ │ │ │ stream │ │ previews, thumbnails) │ │
|
||||
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
|
||||
@@ -245,36 +245,14 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
|
||||
|
||||
### Docker Compose Stack
|
||||
|
||||
Four services: Postgres, the Rust API (`app`), the SvelteKit Node server (`frontend`), and Caddy. Caddy routes `/api/*` and `/media/*` to the Rust binary and everything else to the SvelteKit server. See [docker-compose.yml](docker-compose.yml) for the authoritative definition.
|
||||
|
||||
```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:
|
||||
db: # postgres:16-alpine, persisted in postgres_data volume
|
||||
app: # ./backend — Rust API on :3000, mounts media_data:/media
|
||||
frontend: # ./frontend — SvelteKit (adapter-node) on :3001
|
||||
caddy: # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
|
||||
```
|
||||
|
||||
### Caddyfile
|
||||
@@ -345,11 +323,14 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
||||
|-------|---------|---------|
|
||||
| `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 |
|
||||
| `like-update` | `{ 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 |
|
||||
| `upload-processed` | `{ upload_id, preview_url, thumbnail_url }` | Server-side compression / preview generation finished |
|
||||
| `upload-error` | `{ upload_id, message }` | Compression / preview generation failed |
|
||||
| `export-progress` | `{ type, progress_pct }` | Periodic progress update from an export job |
|
||||
|
||||
**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`
|
||||
|
||||
@@ -372,7 +353,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
||||
| 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 |
|
||||
| Rate Limiting | Custom in-memory sliding-window limiter ([services/rate_limiter.rs](backend/src/services/rate_limiter.rs)) | Per IP / per user; limits read from `config` DB table on each request; hot-reloadable without restart |
|
||||
| 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 |
|
||||
@@ -417,9 +398,9 @@ No paid third-party services required.
|
||||
|
||||
| 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 |
|
||||
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release), pick data mode, read privacy note |
|
||||
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, demote *other* Hosts to guest (never self), reset guest PINs (planned), lock/unlock uploads, release gallery export |
|
||||
| Admin | All Host permissions + reset any non-admin PIN, configure storage/file/rate limits with on/off toggles, edit quota tolerance and per-area quota toggles, edit the Datenschutzhinweis, view disk usage, manage app config, trigger export generation |
|
||||
| Banned Guest | View feed only — cannot upload, like, comment, or export |
|
||||
|
||||
### Compliance
|
||||
@@ -473,37 +454,33 @@ Full-quality originals only. File naming: `{date}_{time}_{username}_{original_fi
|
||||
|
||||
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
|
||||
|
||||
The HTML export is a **pre-built SvelteKit static app** (`adapter-static`, `ssr=false`) shipped together with the event data. It is a non-interactive, read-only clone of the live feed — same components, same Tailwind tokens, same look — minus auth, upload, comment, and any dashboards. Full design rationale in [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md).
|
||||
|
||||
```
|
||||
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/ ...
|
||||
index.html ← entry point; open this in any browser
|
||||
_app/
|
||||
immutable/... ← hashed JS/CSS bundles (viewer SPA)
|
||||
data.json ← event metadata, posts, comments, likes, hashtags
|
||||
media/
|
||||
{id}_thumb.jpg ← grid thumbnails (≈400 px wide)
|
||||
{id}_full.jpg/.mp4 ← full-size media for the lightbox
|
||||
```
|
||||
|
||||
**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.
|
||||
**How it works:** open `index.html` in any modern browser. The viewer hydrates client-side, `fetch('./data.json')` loads the event snapshot, all media references are relative paths into `media/`. No network calls, no service required. The ZIP must be unzipped first; the viewer does not run from inside an archive.
|
||||
|
||||
**`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.
|
||||
**Viewer feature parity with the live app:**
|
||||
- List view (chronological) and 3-column grid view with the same toggle as the live app
|
||||
- Lightbox with swipe navigation
|
||||
- Hashtag filter chips and grid-view search/autocomplete
|
||||
- Like counts and comment lists shown as a static snapshot from export time
|
||||
- All UI strings in German
|
||||
|
||||
**`README.txt`** (in German, as the app's UI language):
|
||||
```
|
||||
Willkommen in der Event-Galerie!
|
||||
**Build flow:** The viewer lives at [frontend/export-viewer/](frontend/export-viewer/) and is built ahead of time into [backend/static/export-viewer/](backend/static/export-viewer/) (committed to the repo). The export job embeds those assets via `include_dir!`, generates `data.json` from the database, processes thumbnails/full-sized variants, and streams the ZIP.
|
||||
|
||||
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.
|
||||
**Source files (ZIP archive export, see below)** still contain the unmodified originals — the viewer is the polished read-only experience, the ZIP is the raw archive.
|
||||
|
||||
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.")
|
||||
For video-heavy events the viewer ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
|
||||
|
||||
---
|
||||
|
||||
@@ -625,7 +602,9 @@ CREATE TABLE "user" (
|
||||
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
|
||||
-- Case-insensitive UNIQUE on (event_id, LOWER(display_name)) added by migration 007.
|
||||
-- Name collisions are rejected on join; the user is prompted to recover with their PIN
|
||||
-- (or to pick a different name).
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────
|
||||
@@ -735,7 +714,18 @@ INSERT INTO config (key, value) VALUES
|
||||
('export_rate_per_day', '3'),
|
||||
('quota_tolerance', '0.75'),
|
||||
('estimated_guest_count', '100'),
|
||||
('compression_concurrency', '2')
|
||||
('compression_concurrency', '2'),
|
||||
-- Planned (see docs/FEATURES.md §2.6 and §2.7):
|
||||
-- on/off switches for rate limits and quotas, and the privacy note text
|
||||
('rate_limits_enabled', 'true'),
|
||||
('upload_rate_enabled', 'true'),
|
||||
('feed_rate_enabled', 'true'),
|
||||
('export_rate_enabled', 'true'),
|
||||
('join_rate_enabled', 'true'),
|
||||
('quota_enabled', 'true'),
|
||||
('storage_quota_enabled', 'true'),
|
||||
('upload_count_quota_enabled', 'true'),
|
||||
('privacy_note', '') -- free text, whitespace + newlines preserved, no HTML
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
```
|
||||
|
||||
@@ -1173,7 +1163,11 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
|
||||
|
||||
| Decision | Chosen | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page | Simple for non-technical guests; no email required |
|
||||
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page; Host/Admin can issue a fresh PIN via the user list when a guest loses it entirely | Simple for non-technical guests; no email required; Host-mediated reset preserves the no-email identity model |
|
||||
| Host demotion authority | Hosts can demote other Hosts (never themselves); Admin can demote anyone non-admin | Avoids requiring an Admin for every staffing change at the event |
|
||||
| Privacy note | Free-text, plain (no HTML), admin-edited, rendered preformatted in My Account | Many events need a per-event privacy statement; preformatted text avoids any markup-injection risk |
|
||||
| Data mode | Per-device `localStorage` setting (Saver / Original), default Saver | A guest can be on cellular on one device and Wi-Fi on another; per-device is the right scope |
|
||||
| Rate-limit & quota toggles | On/off switches plus numeric values in the `config` table | Lets the Admin disable enforcement for testing or trusted events without redeploying |
|
||||
| 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 |
|
||||
@@ -1215,7 +1209,7 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
|
||||
| `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) |
|
||||
| (custom limiter, no crate) | Token-bucket / sliding window built in-tree at [services/rate_limiter.rs](backend/src/services/rate_limiter.rs) |
|
||||
| `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`) |
|
||||
|
||||
64
README.md
64
README.md
@@ -144,18 +144,18 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
||||
┌───▼────┐ ┌─────▼──────┐
|
||||
│ app │ │ frontend │
|
||||
│ :3000 │ │ :3001 │
|
||||
│ (Rust) │ │ (SvelteKit)│
|
||||
│ (Rust) │ │(SvelteKit) │
|
||||
└───┬────┘ └────────────┘
|
||||
│
|
||||
┌───▼────┐
|
||||
│ db │
|
||||
│ :5432 │
|
||||
│(Postgres│
|
||||
│(Postgres)│
|
||||
└────────┘
|
||||
```
|
||||
|
||||
- `/api/*` and `/media/*` → Rust backend
|
||||
- Everything else → SvelteKit frontend
|
||||
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
||||
|
||||
---
|
||||
@@ -174,21 +174,57 @@ The `/media` volume holds originals, previews, thumbnails, exports, and DB backu
|
||||
|
||||
---
|
||||
|
||||
## Running the E2E test suite
|
||||
|
||||
Playwright-based end-to-end tests live in [`e2e/`](e2e/). They spin up an isolated docker-compose stack (Postgres on `:55432`, Caddy on `:3101`) and exercise the SvelteKit frontend against the real Rust backend with rate limits disabled.
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npm install
|
||||
npm run install:browsers # one-time
|
||||
npm run stack:up # bring up the test stack
|
||||
npm run test:e2e # full Phase 1 suite on chromium-desktop
|
||||
npm run test:e2e:smoke # cross-UA matrix (chromium, samsung-internet, webkit, firefox, …)
|
||||
npm run stack:down # tear it down
|
||||
```
|
||||
|
||||
See [`e2e/README.md`](e2e/README.md) for the full UA matrix, Samsung Internet escalation tiers, and the Phase 2/3 roadmap.
|
||||
|
||||
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml).
|
||||
|
||||
---
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
Done:
|
||||
- [x] Project blueprint & architecture
|
||||
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
||||
- [ ] DB schema + SQLx migrations
|
||||
- [ ] Auth flow (join, JWT, PIN recovery)
|
||||
- [ ] Upload pipeline (multipart → compression worker → SSE broadcast)
|
||||
- [ ] Client upload queue (IndexedDB, progress, retry)
|
||||
- [ ] Gallery feed (grid, SSE, hashtag filters)
|
||||
- [ ] Camera capture (`getUserMedia`)
|
||||
- [ ] Host Dashboard
|
||||
- [ ] Admin Dashboard
|
||||
- [ ] Export engine (ZIP + offline HTML)
|
||||
- [ ] Rate limiting middleware
|
||||
- [ ] End-to-end test event (10+ real devices)
|
||||
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
|
||||
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
|
||||
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
|
||||
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
|
||||
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
|
||||
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
|
||||
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
|
||||
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
|
||||
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
|
||||
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
|
||||
- [x] Mobile-first redesign (bottom nav + FAB, see [docs/CONCEPT_MOBILE_UI.md](docs/CONCEPT_MOBILE_UI.md))
|
||||
|
||||
Open:
|
||||
- [ ] Dynamic per-user storage quota enforcement (formula in [PROJECT.md §12](PROJECT.md); only tracking exists today)
|
||||
- [ ] Own-upload deletion UI in the lightbox (backend route exists)
|
||||
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
|
||||
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
|
||||
- [ ] Individual file download button per post
|
||||
- [ ] Low-disk alert (< 10 GB free)
|
||||
- [ ] Event banner / cover image
|
||||
- [ ] Chunked resumable upload for files > 100 MB
|
||||
- [ ] Shared Tailwind config between main app and export-viewer
|
||||
- [ ] End-to-end test event (10+ real devices on cellular)
|
||||
|
||||
See [docs/FEATURES.md](docs/FEATURES.md) for the up-to-date capability matrix by role.
|
||||
Speculative / v2+ ideas live in [docs/IDEAS.md](docs/IDEAS.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,45 +16,47 @@ Please test each step in order and report any errors (console errors, wrong text
|
||||
9. ✅ Expected: Overlay disappears
|
||||
|
||||
### Step 3 — Feed & navigation
|
||||
10. ✅ Expected: Feed shows "Noch keine Fotos." empty state with an upload button
|
||||
11. ✅ Expected: Top-right has an **upload button** (blue) and a **person icon** link
|
||||
10. ✅ Expected: Feed shows the empty state ("Noch keine Fotos." or similar) with a hint to upload
|
||||
11. ✅ Expected: A **persistent bottom nav** is visible with three slots — 🏠 **Feed** on the left, an elevated 📷+ **FAB** in the center, 👤 **Account** on the right
|
||||
|
||||
### Step 4 — My Account page
|
||||
12. Click the **person icon** in the top-right
|
||||
12. Tap the **👤 Account** tab in the bottom nav
|
||||
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
|
||||
14. Click **Kopieren** — check clipboard contains your PIN
|
||||
14. Tap **Kopieren** — check the clipboard contains your PIN
|
||||
15. ✅ Expected: Button briefly shows "Kopiert!"
|
||||
16. Click **Zur Galerie** to go back to the feed
|
||||
16. Tap the 🏠 **Feed** tab to go back
|
||||
|
||||
### Step 5 — Upload
|
||||
17. Click **Hochladen** — this takes you to `/upload`
|
||||
18. Try uploading a photo from your device library
|
||||
19. ✅ Expected: Photo appears in queue with a progress bar, then completes
|
||||
20. Go back to `/feed` — ✅ Expected: your photo appears in the feed grid
|
||||
17. Tap the central **📷+ FAB** in the bottom nav
|
||||
18. ✅ Expected: A bottom sheet slides up offering **Kamera** and **Galerie** options
|
||||
19. Tap **Galerie** → pick a photo from your device library
|
||||
20. ✅ Expected: Preview screen (`/upload`) shows the staged file with an optional caption / hashtag editor
|
||||
21. Tap **Hochladen**
|
||||
22. ✅ Expected: You return to the feed immediately; the FAB shows a small badge while uploading; the photo appears in the feed once processing completes
|
||||
|
||||
### Step 6 — Onboarding guide not shown again
|
||||
21. Reload the page at `/feed`
|
||||
22. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
||||
23. Reload the page at `/feed`
|
||||
24. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
||||
|
||||
### Step 7 — Recover (open a private/incognito window)
|
||||
23. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
||||
24. Enter the same name (`Max`) and the PIN you copied
|
||||
25. ✅ Expected: You're redirected to the feed with the same account
|
||||
25. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
||||
26. Enter the same name (`Max`) and the PIN you copied
|
||||
27. ✅ Expected: You're redirected to the feed with the same account
|
||||
|
||||
### Step 8 — Upload rate-limit auto-retry
|
||||
26. Upload more than 20 photos in one hour to trigger the rate limit
|
||||
27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
|
||||
28. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
|
||||
29. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
|
||||
28. Upload more than the per-hour limit of photos in quick succession to trigger the rate limit
|
||||
29. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
|
||||
30. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
|
||||
31. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
|
||||
|
||||
### Step 9 — Name uniqueness (case-insensitive)
|
||||
30. In a private/incognito window go to **http://localhost:5173/join**
|
||||
31. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
|
||||
32. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
|
||||
33. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
|
||||
34. Enter your PIN from Step 1 and click **Anmelden**
|
||||
35. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
|
||||
36. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
|
||||
32. In a private/incognito window go to **http://localhost:5173/join**
|
||||
33. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
|
||||
34. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
|
||||
35. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
|
||||
36. Enter your PIN from Step 1 and click **Anmelden**
|
||||
37. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
|
||||
38. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
|
||||
|
||||
---
|
||||
|
||||
|
||||
27
backend/Cargo.lock
generated
27
backend/Cargo.lock
generated
@@ -513,6 +513,17 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -897,6 +908,7 @@ dependencies = [
|
||||
"futures",
|
||||
"image",
|
||||
"include_dir",
|
||||
"infer",
|
||||
"jsonwebtoken",
|
||||
"oxipng",
|
||||
"rand 0.9.2",
|
||||
@@ -1004,6 +1016,12 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -1609,6 +1627,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
|
||||
@@ -30,6 +30,7 @@ image = "0.25"
|
||||
oxipng = "9"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||
include_dir = "0.7"
|
||||
infer = "0.15"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# --- Build stage ---
|
||||
FROM rust:1.87-alpine AS builder
|
||||
FROM rust:1.88-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
|
||||
|
||||
@@ -12,15 +12,24 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||
|
||||
COPY src ./src
|
||||
COPY static ./static
|
||||
COPY migrations ./migrations
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates ffmpeg
|
||||
# Run as an unprivileged user (defense-in-depth). The media volume mounts at
|
||||
# /media; creating it here as `app` means a fresh named volume inherits app
|
||||
# ownership and is writable without running as root. (An existing root-owned
|
||||
# volume from a prior deploy needs a one-time `chown -R app:app` — see deploy notes.)
|
||||
RUN apk add --no-cache ca-certificates ffmpeg \
|
||||
&& addgroup -S app && adduser -S -G app app \
|
||||
&& mkdir -p /media && chown app:app /media
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/eventsnap-backend ./
|
||||
RUN chown -R app:app /app
|
||||
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
CMD ["./eventsnap-backend"]
|
||||
|
||||
2
backend/migrations/008_compression_status.down.sql
Normal file
2
backend/migrations/008_compression_status.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Remove compression_status field
|
||||
ALTER TABLE upload DROP COLUMN compression_status;
|
||||
6
backend/migrations/008_compression_status.up.sql
Normal file
6
backend/migrations/008_compression_status.up.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Add compression_status to track media processing state
|
||||
ALTER TABLE upload ADD COLUMN compression_status TEXT NOT NULL DEFAULT 'pending';
|
||||
|
||||
-- Values: 'pending', 'processing', 'done', 'failed'
|
||||
-- Add comment to document the field
|
||||
COMMENT ON COLUMN upload.compression_status IS 'Tracks media compression/preview generation: pending -> processing -> (done or failed)';
|
||||
11
backend/migrations/009_feature_toggles.down.sql
Normal file
11
backend/migrations/009_feature_toggles.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
DELETE FROM config WHERE key IN (
|
||||
'rate_limits_enabled',
|
||||
'upload_rate_enabled',
|
||||
'feed_rate_enabled',
|
||||
'export_rate_enabled',
|
||||
'join_rate_enabled',
|
||||
'quota_enabled',
|
||||
'storage_quota_enabled',
|
||||
'upload_count_quota_enabled',
|
||||
'privacy_note'
|
||||
);
|
||||
16
backend/migrations/009_feature_toggles.up.sql
Normal file
16
backend/migrations/009_feature_toggles.up.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Feature toggles for rate limits and quotas, plus the admin-configurable
|
||||
-- Datenschutzhinweis. Everything lives in the `config` table — no schema change.
|
||||
INSERT INTO config (key, value) VALUES
|
||||
-- Rate limits (master + per-endpoint)
|
||||
('rate_limits_enabled', 'true'),
|
||||
('upload_rate_enabled', 'true'),
|
||||
('feed_rate_enabled', 'true'),
|
||||
('export_rate_enabled', 'true'),
|
||||
('join_rate_enabled', 'true'),
|
||||
-- Quotas (master + per-area)
|
||||
('quota_enabled', 'true'),
|
||||
('storage_quota_enabled', 'true'),
|
||||
('upload_count_quota_enabled', 'true'),
|
||||
-- Free-text privacy note shown to guests in My Account. Plain text — no HTML.
|
||||
('privacy_note', '')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
23
backend/migrations/010_feed_view_perf.down.sql
Normal file
23
backend/migrations/010_feed_view_perf.down.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Restore the original join-based v_feed definition.
|
||||
CREATE OR REPLACE 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;
|
||||
26
backend/migrations/010_feed_view_perf.up.sql
Normal file
26
backend/migrations/010_feed_view_perf.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- H6: replace v_feed's double LEFT JOIN + COUNT(DISTINCT) (which materializes a
|
||||
-- likes×comments Cartesian per upload before de-duping) with correlated scalar
|
||||
-- subqueries. Each count now uses its own index (idx_like_upload /
|
||||
-- idx_comment_upload) and there is no GROUP BY. The output columns are
|
||||
-- unchanged, so every consumer keeps working.
|
||||
CREATE OR REPLACE 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,
|
||||
(SELECT COUNT(*) FROM "like" l
|
||||
WHERE l.upload_id = u.id) AS like_count,
|
||||
(SELECT COUNT(*) FROM comment c
|
||||
WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
|
||||
FROM upload u
|
||||
JOIN "user" usr ON u.user_id = usr.id
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND usr.uploads_hidden = FALSE;
|
||||
28
backend/migrations/README.md
Normal file
28
backend/migrations/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Migrations
|
||||
|
||||
SQLx-managed Postgres migrations. Each `NNN_topic.up.sql` has a matching
|
||||
`NNN_topic.down.sql`. Run by `sqlx::migrate!()` at app start.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Never edit a shipped migration.** If a column needs to change or a fix needs to
|
||||
land, write a new migration. Production has already applied the old one and SQLx
|
||||
tracks each by checksum — editing in place will fail to apply on existing databases.
|
||||
2. **Always pair `.up.sql` with a `.down.sql`.** Reverts may not be perfect (data
|
||||
loss is sometimes unavoidable) but the file must exist and do the best it can.
|
||||
3. **Prefer additive changes.** New columns, new tables, new keys in `config`. Drop /
|
||||
rename only when there is no alternative.
|
||||
4. **No business logic in migrations.** Schema + seeds only. Anything that needs Rust
|
||||
code goes in a one-off binary, not a migration file.
|
||||
5. **One concern per migration.** Easier to revert. Easier to read in `git log`.
|
||||
|
||||
## Numbering
|
||||
|
||||
Zero-padded three digits, monotonically increasing. The next free number lives at the
|
||||
bottom of the directory listing — pick that.
|
||||
|
||||
## Seed-only migrations
|
||||
|
||||
When you only need to add `config` keys (feature flags, defaults), use
|
||||
`INSERT … ON CONFLICT DO NOTHING` so existing operator overrides survive. See
|
||||
`009_feature_toggles.up.sql` for the canonical shape.
|
||||
@@ -14,6 +14,8 @@ use crate::error::AppError;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::session::Session;
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::services::config;
|
||||
use crate::services::password;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -36,19 +38,41 @@ pub async fn join(
|
||||
Json(body): Json<JoinRequest>,
|
||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await;
|
||||
if rate_limits_on && join_rate_on {
|
||||
// Short burst cap (typos / double-taps) AND a generous per-IP daily cap
|
||||
// on account creation. The daily cap bounds mass account-minting (which
|
||||
// otherwise resets the per-user upload budget, see the event-wide count
|
||||
// quota in the upload handler) while staying well above a real ~100-guest
|
||||
// venue sharing one NAT'd IP.
|
||||
let burst_ok =
|
||||
state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60));
|
||||
let daily_ok = state
|
||||
.rate_limiter
|
||||
.check(format!("join_day:{ip}"), 200, Duration::from_secs(86_400));
|
||||
if !burst_ok || !daily_ok {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let display_name = body.display_name.trim();
|
||||
if display_name.is_empty() || display_name.len() > 50 {
|
||||
let name_chars = display_name.chars().count();
|
||||
if name_chars == 0 || name_chars > 50 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
|
||||
));
|
||||
}
|
||||
// Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers
|
||||
// see a clean 400 instead of an internal error.
|
||||
if display_name.contains('\0') {
|
||||
return Err(AppError::BadRequest(
|
||||
"Name enthält ungültige Zeichen.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let event = Event::find_or_create(
|
||||
&state.pool,
|
||||
@@ -65,10 +89,10 @@ pub async fn join(
|
||||
)));
|
||||
}
|
||||
|
||||
// Generate a 4-digit PIN
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
// Generate a 6-digit PIN (≈20 bits vs the old ~13.3, so brute-forcing it
|
||||
// against the 3-strike lockout is far harder).
|
||||
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
|
||||
let pin_hash = password::hash(pin.clone(), 12).await?;
|
||||
|
||||
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
|
||||
|
||||
@@ -110,10 +134,33 @@ pub struct RecoverResponse {
|
||||
|
||||
pub async fn recover(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<RecoverRequest>,
|
||||
) -> Result<Json<RecoverResponse>, AppError> {
|
||||
let display_name = body.display_name.trim();
|
||||
|
||||
// Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this
|
||||
// an attacker who knows a display name (they're visible on the feed) can
|
||||
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
||||
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
||||
// softens that into a real cost.
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let recover_rate_on = config::get_bool(&state.pool, "recover_rate_enabled", true).await;
|
||||
if rate_limits_on && recover_rate_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if !state.rate_limiter.check(
|
||||
format!("recover:{ip}:{name_key}"),
|
||||
5,
|
||||
Duration::from_secs(15 * 60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
@@ -128,18 +175,22 @@ pub async fn recover(
|
||||
}
|
||||
|
||||
for user in &users {
|
||||
// Check PIN lockout
|
||||
// Check PIN lockout. The failed-attempt counter is deliberately NOT reset
|
||||
// when the window expires — it drives the *escalating* backoff below, so a
|
||||
// determined guesser faces an exponentially growing wait. A successful
|
||||
// recovery (and a host PIN reset) is what clears it. The counter only ever
|
||||
// grows on a *wrong* PIN, so a legitimate user is unaffected.
|
||||
if let Some(locked_until) = user.pin_locked_until {
|
||||
if Utc::now() < locked_until {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
||||
"Zu viele Versuche. Bitte warte und versuche es später erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
||||
.unwrap_or(false);
|
||||
let pin_matches =
|
||||
password::verify(body.pin.clone(), user.recovery_pin_hash.clone()).await;
|
||||
|
||||
if pin_matches {
|
||||
// Reset failed attempts on success
|
||||
@@ -166,9 +217,28 @@ pub async fn recover(
|
||||
|
||||
// Wrong PIN — increment failure count
|
||||
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
||||
tracing::warn!(
|
||||
user_id = %user.id,
|
||||
event_id = %event.id,
|
||||
ip = %ip,
|
||||
attempts,
|
||||
"recover: wrong PIN"
|
||||
);
|
||||
if attempts >= 3 {
|
||||
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
||||
// Escalating backoff: 15min, 30, 60, … doubling per failure past the
|
||||
// threshold, capped at 24h. Makes sustained guessing against the
|
||||
// 6-digit space (1M combinations) astronomically slow.
|
||||
let exp = (attempts as u32).saturating_sub(3).min(10);
|
||||
let minutes = 15i64.saturating_mul(1i64 << exp).min(24 * 60);
|
||||
let lockout = Utc::now() + chrono::Duration::minutes(minutes);
|
||||
User::lock_pin(&state.pool, user.id, lockout).await?;
|
||||
tracing::warn!(
|
||||
user_id = %user.id,
|
||||
event_id = %event.id,
|
||||
ip = %ip,
|
||||
minutes,
|
||||
"recover: account locked (escalating backoff)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +257,7 @@ pub struct AdminLoginResponse {
|
||||
|
||||
pub async fn admin_login(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<AdminLoginRequest>,
|
||||
) -> Result<Json<AdminLoginResponse>, AppError> {
|
||||
if state.config.admin_password_hash.is_empty() {
|
||||
@@ -195,10 +266,50 @@ pub async fn admin_login(
|
||||
));
|
||||
}
|
||||
|
||||
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
||||
.unwrap_or(false);
|
||||
// Throttle password attempts. The admin password is bcrypt-hashed (slow to
|
||||
// verify) but with no IP-level limit a determined attacker can still mount
|
||||
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
||||
// honest typos.
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
|
||||
// Hard, non-disableable floor: admin_login is the one credential endpoint
|
||||
// whose brute-force protection must survive the DB rate-limit master toggle
|
||||
// being turned off. 30 attempts / 5 min / IP is generous for typos but caps
|
||||
// sustained guessing regardless of config.
|
||||
if !state.rate_limiter.check(
|
||||
format!("admin_login_floor:{ip}"),
|
||||
30,
|
||||
Duration::from_secs(300),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let admin_rate_on = config::get_bool(&state.pool, "admin_login_rate_enabled", true).await;
|
||||
if rate_limits_on && admin_rate_on
|
||||
&& !state.rate_limiter.check(
|
||||
format!("admin_login:{ip}"),
|
||||
5,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let valid = password::verify(
|
||||
body.password.clone(),
|
||||
state.config.admin_password_hash.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
||||
}
|
||||
|
||||
@@ -215,9 +326,13 @@ pub async fn admin_login(
|
||||
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
|
||||
u
|
||||
} else {
|
||||
// Create admin user with a dummy PIN (admin authenticates via password)
|
||||
let dummy_hash = bcrypt::hash("0000", 4)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
// Admin authenticates via password, but the schema still requires a PIN
|
||||
// hash. Generate a random unguessable PIN so the recovery path remains
|
||||
// unusable as an escalation route even if the role flag ever got cleared.
|
||||
let dummy_pin: String = (0..32)
|
||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||
.collect();
|
||||
let dummy_hash = password::hash(dummy_pin, 4).await?;
|
||||
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||
.bind(user.id)
|
||||
@@ -228,6 +343,8 @@ pub async fn admin_login(
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
|
||||
};
|
||||
|
||||
tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success");
|
||||
|
||||
let token = jwt::create_token(
|
||||
admin_user.id,
|
||||
event.id,
|
||||
|
||||
@@ -13,6 +13,13 @@ pub struct Claims {
|
||||
pub role: UserRole,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
/// Random per-token identifier. Without it, two `create_token` calls in the
|
||||
/// same wall-clock second for the same (sub, role, event) produce identical
|
||||
/// JWT bytes — and identical sha256(token) hashes — which then collide on
|
||||
/// the `session.token_hash` UNIQUE constraint. The jti is ignored by the
|
||||
/// verifier but breaks the collision.
|
||||
#[serde(default)]
|
||||
pub jti: Uuid,
|
||||
}
|
||||
|
||||
pub fn create_token(
|
||||
@@ -29,6 +36,7 @@ pub fn create_token(
|
||||
role,
|
||||
iat: now.timestamp(),
|
||||
exp: (now + Duration::days(expiry_days)).timestamp(),
|
||||
jti: Uuid::new_v4(),
|
||||
};
|
||||
jsonwebtoken::encode(
|
||||
&Header::default(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::extract::{FromRequestParts, State};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -38,22 +38,44 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
|
||||
let token_hash = jwt::hash_token(token);
|
||||
|
||||
let session = Session::find_by_token_hash(&state.pool, &token_hash)
|
||||
// Reconcile the session against the live `user` row. We do NOT trust the
|
||||
// JWT claims for role/ban/event — a token can outlive a ban or demotion
|
||||
// (default lifetime 30 days). The single JOIN query is the same number
|
||||
// of round-trips as the old existence check.
|
||||
let ctx = Session::find_auth_context(&state.pool, &token_hash)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
||||
|
||||
// Update last_seen_at in the background (fire-and-forget)
|
||||
// Ban takes effect immediately on the next request, regardless of the
|
||||
// token's remaining lifetime.
|
||||
if ctx.is_banned {
|
||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||
}
|
||||
|
||||
// Defend against a JWT that was issued for a different user/event than
|
||||
// the session's DB row points at (e.g. a swapped or replayed token).
|
||||
if claims.sub != ctx.user_id || claims.event_id != ctx.event_id {
|
||||
return Err(AppError::Unauthorized("Token passt nicht zur Sitzung.".into()));
|
||||
}
|
||||
|
||||
// Update last_seen_at in the background (fire-and-forget). Failures are
|
||||
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
||||
// pressure that would otherwise be the first symptom of a real problem.
|
||||
let pool = state.pool.clone();
|
||||
let session_id = session.id;
|
||||
let session_id = ctx.session_id;
|
||||
tokio::spawn(async move {
|
||||
let _ = Session::touch(&pool, session_id).await;
|
||||
if let Err(e) = Session::touch(&pool, session_id).await {
|
||||
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
user_id: claims.sub,
|
||||
event_id: claims.event_id,
|
||||
role: claims.role,
|
||||
user_id: ctx.user_id,
|
||||
event_id: ctx.event_id,
|
||||
// Live role from the DB, not the claim — demotion/promotion takes
|
||||
// effect on the next request.
|
||||
role: ctx.role,
|
||||
token_hash,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
|
||||
/// we refuse to start with this value; otherwise we warn loudly.
|
||||
const DEV_JWT_SECRET_SENTINEL: &str = "dev_secret_do_not_use_in_production_32byteslong_aaaa";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
@@ -16,11 +20,41 @@ pub struct AppConfig {
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let app_env =
|
||||
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||
let is_prod = app_env.eq_ignore_ascii_case("production");
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
// A weak/placeholder signing key lets anyone forge any token (including
|
||||
// admin). Detect the known dev sentinel, the `.env.example` placeholder,
|
||||
// and anything that smells like an unreplaced template value.
|
||||
let lower = jwt_secret.to_ascii_lowercase();
|
||||
let looks_placeholder = jwt_secret == DEV_JWT_SECRET_SENTINEL
|
||||
|| lower.contains("change")
|
||||
|| lower.contains("example")
|
||||
|| lower.contains("placeholder")
|
||||
|| lower.contains("replace");
|
||||
|
||||
if is_prod {
|
||||
// Production must use a real, strong secret — no placeholders, ≥64
|
||||
// chars (an `openssl rand -hex 64` is 128 hex chars).
|
||||
if looks_placeholder || jwt_secret.len() < 64 {
|
||||
return Err(anyhow!(
|
||||
"Refusing to start in production: JWT_SECRET is a placeholder or too short. \
|
||||
Generate a real one (openssl rand -hex 64) and set it in the prod environment."
|
||||
));
|
||||
}
|
||||
} else if looks_placeholder || jwt_secret.len() < 32 {
|
||||
tracing::warn!(
|
||||
"JWT_SECRET looks like a dev/placeholder value — fine for local development, \
|
||||
NEVER ship this to production."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.context("DATABASE_URL must be set")?,
|
||||
jwt_secret: std::env::var("JWT_SECRET")
|
||||
.context("JWT_SECRET must be set")?,
|
||||
jwt_secret,
|
||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
|
||||
@@ -2,9 +2,16 @@ use anyhow::{Context, Result};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
|
||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.context("failed to connect to database")?;
|
||||
@@ -14,6 +21,6 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
.await
|
||||
.context("failed to run database migrations")?;
|
||||
|
||||
tracing::info!("database connected and migrations applied");
|
||||
tracing::info!(max_connections, "database connected and migrations applied");
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use sysinfo::System;
|
||||
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
use crate::services::config;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -117,7 +118,10 @@ pub async fn patch_config(
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
Json(body): Json<HashMap<String, String>>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
const ALLOWED_KEYS: &[&str] = &[
|
||||
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
|
||||
// privacy note is free text. Splitting these explicitly is verbose but makes the
|
||||
// failure mode for typos obvious (`Unbekannter Schlüssel: ...`).
|
||||
const NUMERIC_KEYS: &[&str] = &[
|
||||
"max_image_size_mb",
|
||||
"max_video_size_mb",
|
||||
"upload_rate_per_hour",
|
||||
@@ -127,15 +131,53 @@ pub async fn patch_config(
|
||||
"estimated_guest_count",
|
||||
"compression_concurrency",
|
||||
];
|
||||
const BOOL_KEYS: &[&str] = &[
|
||||
"rate_limits_enabled",
|
||||
"upload_rate_enabled",
|
||||
"feed_rate_enabled",
|
||||
"export_rate_enabled",
|
||||
"join_rate_enabled",
|
||||
"quota_enabled",
|
||||
"storage_quota_enabled",
|
||||
"upload_count_quota_enabled",
|
||||
];
|
||||
const TEXT_KEYS: &[&str] = &["privacy_note"];
|
||||
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
|
||||
|
||||
let mut privacy_note_changed = false;
|
||||
|
||||
for (key, value) in &body {
|
||||
if !ALLOWED_KEYS.contains(&key.as_str()) {
|
||||
return Err(AppError::BadRequest(format!("Unbekannter Konfigurationsschlüssel: {key}")));
|
||||
}
|
||||
// Validate numeric values
|
||||
if value.parse::<f64>().is_err() {
|
||||
return Err(AppError::BadRequest(format!("Ungültiger Wert für {key}: muss eine Zahl sein.")));
|
||||
let key_str = key.as_str();
|
||||
if NUMERIC_KEYS.contains(&key_str) {
|
||||
if value.parse::<f64>().is_err() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
||||
)));
|
||||
}
|
||||
} else if BOOL_KEYS.contains(&key_str) {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Ungültiger Wert für {key}: muss true oder false sein."
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else if TEXT_KEYS.contains(&key_str) {
|
||||
if value.len() > PRIVACY_NOTE_MAX_LEN {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||
)));
|
||||
}
|
||||
if key_str == "privacy_note" {
|
||||
privacy_note_changed = true;
|
||||
}
|
||||
} else {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Unbekannter Konfigurationsschlüssel: {key}"
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||
@@ -146,6 +188,15 @@ pub async fn patch_config(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Notify all clients that a publicly-readable config value changed so their stores
|
||||
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
||||
if privacy_note_changed {
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"event-updated",
|
||||
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -177,11 +228,7 @@ pub async fn download_zip(
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
|
||||
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
|
||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
||||
}
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
@@ -206,11 +253,7 @@ pub async fn download_html(
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
|
||||
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
|
||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
||||
}
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
@@ -295,12 +338,25 @@ pub async fn export_status(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
||||
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
|
||||
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
|
||||
/// each request.
|
||||
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await;
|
||||
if !(rate_limits_on && export_rate_on) {
|
||||
return Ok(());
|
||||
}
|
||||
let ip = client_ip(headers, "unknown");
|
||||
let limit = config::get_usize(&state.pool, "export_rate_per_day", 3).await;
|
||||
if !state
|
||||
.rate_limiter
|
||||
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::services::config;
|
||||
use crate::services::media_token;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -26,6 +28,8 @@ pub struct FeedUpload {
|
||||
pub uploader_name: String,
|
||||
pub preview_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// Signed gateway URL for the full-resolution original. Always present.
|
||||
pub original_url: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub caption: Option<String>,
|
||||
pub like_count: i64,
|
||||
@@ -54,6 +58,33 @@ struct FeedRow {
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Build a feed DTO, minting fresh signed gateway URLs for each artifact. The
|
||||
/// preview/thumbnail URLs are present only when the derivative exists so the
|
||||
/// client can show a skeleton while compression is still running; the original
|
||||
/// URL is always present.
|
||||
fn to_feed_upload(r: FeedRow, liked: bool, secret: &str, now: i64) -> FeedUpload {
|
||||
FeedUpload {
|
||||
liked_by_me: liked,
|
||||
preview_url: r
|
||||
.preview_path
|
||||
.as_ref()
|
||||
.map(|_| media_token::signed_url(secret, "preview", r.id, now)),
|
||||
thumbnail_url: r
|
||||
.thumbnail_path
|
||||
.as_ref()
|
||||
.map(|_| media_token::signed_url(secret, "thumbnail", r.id, now)),
|
||||
original_url: Some(media_token::signed_url(secret, "original", r.id, now)),
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
uploader_name: r.uploader_name,
|
||||
mime_type: r.mime_type,
|
||||
caption: r.caption,
|
||||
like_count: r.like_count,
|
||||
comment_count: r.comment_count,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
@@ -61,9 +92,19 @@ pub async fn feed(
|
||||
Query(q): Query<FeedQuery>,
|
||||
) -> Result<Json<FeedResponse>, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||
if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) {
|
||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||
if !state
|
||||
.rate_limiter
|
||||
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = q.limit.unwrap_or(20).min(100);
|
||||
@@ -124,24 +165,13 @@ pub async fn feed(
|
||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let secret = &state.config.jwt_secret;
|
||||
let uploads = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
|
||||
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
|
||||
FeedUpload {
|
||||
liked_by_me: liked_set.contains(&r.id),
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
uploader_name: r.uploader_name,
|
||||
preview_url,
|
||||
thumbnail_url,
|
||||
mime_type: r.mime_type,
|
||||
caption: r.caption,
|
||||
like_count: r.like_count,
|
||||
comment_count: r.comment_count,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
let liked = liked_set.contains(&r.id);
|
||||
to_feed_upload(r, liked, secret, now)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -160,57 +190,90 @@ pub struct DeltaQuery {
|
||||
pub struct DeltaResponse {
|
||||
pub uploads: Vec<FeedUpload>,
|
||||
pub deleted_ids: Vec<Uuid>,
|
||||
/// Set when the delta was clamped (too-old cursor) or hit the row cap — the
|
||||
/// client should do a full feed reload instead of trusting the partial set.
|
||||
pub reload_required: bool,
|
||||
}
|
||||
|
||||
/// Hard cap on how many uploads one delta returns. Beyond this the client is
|
||||
/// told to reload rather than streaming the whole gallery through the view.
|
||||
const DELTA_LIMIT: i64 = 200;
|
||||
/// How far back a client-supplied `since` may reach. A tab backgrounded for days
|
||||
/// must not pull the entire event on reconnect.
|
||||
const DELTA_MAX_LOOKBACK_DAYS: i64 = 7;
|
||||
|
||||
pub async fn feed_delta(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<DeltaQuery>,
|
||||
) -> Result<Json<DeltaResponse>, AppError> {
|
||||
// H7: feed_delta runs the (expensive) feed query and fires on every tab
|
||||
// refocus, so it needs the same rate limit as feed(), keyed per user.
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||
if !state.rate_limiter.check(
|
||||
format!("feed_delta:{}", auth.user_id),
|
||||
rate_limit,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the lookback server-side; signal a full reload if we had to.
|
||||
let min_since = Utc::now() - chrono::Duration::days(DELTA_MAX_LOOKBACK_DAYS);
|
||||
let clamped = q.since < min_since;
|
||||
let since = if clamped { min_since } else { q.since };
|
||||
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||
mime_type, caption, like_count, comment_count, created_at
|
||||
FROM v_feed
|
||||
WHERE event_id = $1 AND created_at > $2
|
||||
ORDER BY created_at DESC",
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(q.since)
|
||||
.bind(since)
|
||||
.bind(DELTA_LIMIT + 1)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let capped = rows.len() as i64 > DELTA_LIMIT;
|
||||
let rows: Vec<FeedRow> = rows.into_iter().take(DELTA_LIMIT as usize).collect();
|
||||
let reload_required = clamped || capped;
|
||||
|
||||
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT id FROM upload
|
||||
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(q.since)
|
||||
.bind(since)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
|
||||
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let secret = &state.config.jwt_secret;
|
||||
let uploads = rows
|
||||
.into_iter()
|
||||
.map(|r| FeedUpload {
|
||||
liked_by_me: liked_set.contains(&r.id),
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
uploader_name: r.uploader_name,
|
||||
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
|
||||
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
|
||||
mime_type: r.mime_type,
|
||||
caption: r.caption,
|
||||
like_count: r.like_count,
|
||||
comment_count: r.comment_count,
|
||||
created_at: r.created_at,
|
||||
.map(|r| {
|
||||
let liked = liked_set.contains(&r.id);
|
||||
to_feed_upload(r, liked, secret, now)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(DeltaResponse {
|
||||
uploads,
|
||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||
reload_required,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -238,16 +301,6 @@ pub async fn hashtags(
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
|
||||
let row: Option<(DateTime<Utc>,)> =
|
||||
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
|
||||
|
||||
@@ -9,8 +9,20 @@ use crate::auth::middleware::RequireHost;
|
||||
use crate::error::AppError;
|
||||
use crate::models::comment::Comment;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::session::Session;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::AppState;
|
||||
use crate::models::user::UserRole;
|
||||
use crate::state::{AppState, SseEvent};
|
||||
|
||||
/// Revoke all of a user's sessions (best-effort). A failure here is logged but
|
||||
/// never fails the surrounding admin action — the live-role/ban reconciliation
|
||||
/// in `AuthUser` is the authoritative gate; revocation is defense-in-depth.
|
||||
async fn revoke_sessions(pool: &sqlx::PgPool, user_id: Uuid, action: &str) {
|
||||
match Session::delete_by_user_id(pool, user_id).await {
|
||||
Ok(n) => tracing::info!(target_user_id = %user_id, revoked = n, action, "sessions revoked"),
|
||||
Err(e) => tracing::warn!(error = ?e, target_user_id = %user_id, action, "session revoke failed"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -119,18 +131,46 @@ pub async fn ban_user(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
revoke_sessions(&state.pool, user_id, "ban_user").await;
|
||||
|
||||
// Tell the banned user's online devices to log out immediately.
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"user-banned",
|
||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
event_id = %auth.event_id,
|
||||
hide_uploads = body.hide_uploads,
|
||||
"host: ban_user"
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn unban_user(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
sqlx::query("UPDATE \"user\" SET is_banned = FALSE WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
||||
}
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
event_id = %auth.event_id,
|
||||
"host: unban_user"
|
||||
);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -141,51 +181,185 @@ pub async fn set_role(
|
||||
Json(body): Json<SetRoleRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest("Du kannst deine eigene Rolle nicht ändern.".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"Du kannst deine eigene Rolle nicht ändern.".into(),
|
||||
));
|
||||
}
|
||||
let new_role = match body.role.as_str() {
|
||||
"guest" => "guest",
|
||||
"host" => "host",
|
||||
_ => return Err(AppError::BadRequest("Ungültige Rolle. Erlaubt: guest, host.".into())),
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
|
||||
// promote guests and demote *other* hosts (the user explicitly requested this
|
||||
// expansion). Hosts may not touch admins. Admins may do anything (except change
|
||||
// themselves, blocked above).
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
if target.0 == "admin" {
|
||||
return Err(AppError::Forbidden(
|
||||
"Admins können nicht geändert werden.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
||||
.bind(user_id)
|
||||
.bind(new_role)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Force a clean re-auth so the new role can't be cached client-side and so
|
||||
// any in-flight tokens carrying the old role are invalidated. The live-role
|
||||
// reconciliation in AuthUser already prevents privilege escalation, but
|
||||
// revoking is cheaper to reason about.
|
||||
revoke_sessions(&state.pool, user_id, "set_role").await;
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
event_id = %auth.event_id,
|
||||
old_role = %target.0,
|
||||
new_role,
|
||||
"host: set_role"
|
||||
);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PinResetResponse {
|
||||
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
|
||||
pub pin: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
|
||||
///
|
||||
/// Authorisation:
|
||||
/// - Host caller → may reset **guest** PINs only.
|
||||
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
|
||||
/// - Target ≠ caller.
|
||||
pub async fn reset_user_pin(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<PinResetResponse>, AppError> {
|
||||
use rand::Rng;
|
||||
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
match (auth.role.clone(), target.0.as_str()) {
|
||||
(UserRole::Admin, "guest" | "host") => {}
|
||||
(UserRole::Host, "guest") => {}
|
||||
_ => {
|
||||
return Err(AppError::Forbidden(
|
||||
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
|
||||
let pin_hash = crate::services::password::hash(pin.clone(), 12).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET recovery_pin_hash = $1,
|
||||
failed_pin_attempts = 0,
|
||||
pin_locked_until = NULL
|
||||
WHERE id = $2",
|
||||
)
|
||||
.bind(&pin_hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// A PIN reset must invalidate existing sessions so a compromised/old token
|
||||
// can't outlive the reset.
|
||||
revoke_sessions(&state.pool, user_id, "reset_user_pin").await;
|
||||
|
||||
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
||||
// their cached local PIN. They'll save the new one on the next /recover.
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"pin-reset",
|
||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
event_id = %auth.event_id,
|
||||
"host: reset_user_pin"
|
||||
);
|
||||
|
||||
Ok(Json(PinResetResponse { pin }))
|
||||
}
|
||||
|
||||
pub async fn host_delete_upload(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
||||
let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
||||
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "upload-deleted".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload.id }).to_string(),
|
||||
});
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"upload-deleted",
|
||||
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
event_id = %auth.event_id,
|
||||
upload_id = %upload_id,
|
||||
"host: host_delete_upload"
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn host_delete_comment(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(comment_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
Comment::find_by_id(&state.pool, comment_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
|
||||
|
||||
Comment::soft_delete(&state.pool, comment_id).await?;
|
||||
let deleted =
|
||||
Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||
}
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
event_id = %auth.event_id,
|
||||
comment_id = %comment_id,
|
||||
"host: host_delete_comment"
|
||||
);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -200,10 +374,7 @@ pub async fn close_event(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "event-closed".to_string(),
|
||||
data: "{}".to_string(),
|
||||
});
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -219,10 +390,7 @@ pub async fn open_event(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "event-opened".to_string(),
|
||||
data: "{}".to_string(),
|
||||
});
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -235,28 +403,65 @@ pub async fn release_gallery(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
if event.export_released_at.is_some() {
|
||||
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
|
||||
// Atomically claim the release AND enqueue the jobs in one transaction (M8).
|
||||
// The claim UPDATE row-locks the event row and the lock is held until commit
|
||||
// — *after* the export_job rows exist — so a second concurrent press re-reads
|
||||
// the now-present jobs and matches 0 rows (clean 400). Doing the UPDATE and
|
||||
// INSERTs as separate autocommit statements left a cross-table TOCTOU window
|
||||
// where both presses could win and race the same output files.
|
||||
//
|
||||
// Re-release guard (M8-3): allow when never released, OR nothing is currently
|
||||
// in progress and at least one job terminally failed. The old "every job
|
||||
// failed" guard left a one-sided failure (zip done, html failed) permanently
|
||||
// unrecoverable.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let claim = sqlx::query(
|
||||
"UPDATE event SET export_released_at = NOW()
|
||||
WHERE slug = $1
|
||||
AND (
|
||||
export_released_at IS NULL
|
||||
OR (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM export_job j
|
||||
WHERE j.event_id = event.id AND j.status IN ('running', 'pending')
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM export_job j
|
||||
WHERE j.event_id = event.id AND j.status = 'failed'
|
||||
)
|
||||
)
|
||||
)",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if claim.rows_affected() == 0 {
|
||||
tx.rollback().await.ok();
|
||||
return Err(AppError::BadRequest(
|
||||
"Galerie wurde bereits freigegeben.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Enqueue export jobs
|
||||
// Enqueue export jobs, resetting any prior terminal rows so the workers
|
||||
// re-run cleanly (including an already-`done` sibling on re-release).
|
||||
for export_type in ["zip", "html"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
|
||||
ON CONFLICT (event_id, type) DO NOTHING",
|
||||
ON CONFLICT (event_id, type)
|
||||
DO UPDATE SET status = 'pending', progress_pct = 0,
|
||||
error_message = NULL, completed_at = NULL",
|
||||
)
|
||||
.bind(event.id)
|
||||
.bind(export_type)
|
||||
.execute(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Spawn export workers
|
||||
tx.commit().await?;
|
||||
|
||||
// Spawn export workers only after the claim+enqueue is durably committed.
|
||||
crate::services::export::spawn_export_jobs(
|
||||
event.id,
|
||||
event.name,
|
||||
|
||||
88
backend/src/handlers/me.rs
Normal file
88
backend/src/handlers/me.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because
|
||||
//! these aren't about acquiring / refreshing a session — they're about reading my own
|
||||
//! state once I'm already signed in.
|
||||
//!
|
||||
//! Current routes:
|
||||
//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The
|
||||
//! account page loads this once on mount instead of issuing several round trips.
|
||||
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::handlers::upload::compute_storage_quota;
|
||||
use crate::models::user::User;
|
||||
use crate::services::config;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct QuotaDto {
|
||||
pub enabled: bool,
|
||||
pub used_bytes: i64,
|
||||
pub limit_bytes: Option<i64>,
|
||||
pub active_uploaders: i64,
|
||||
pub free_disk_bytes: i64,
|
||||
}
|
||||
|
||||
pub async fn get_quota(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<QuotaDto>, AppError> {
|
||||
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
let estimate = compute_storage_quota(&state).await;
|
||||
|
||||
Ok(Json(QuotaDto {
|
||||
enabled: estimate.limit_bytes.is_some(),
|
||||
used_bytes: user.total_upload_bytes,
|
||||
limit_bytes: estimate.limit_bytes,
|
||||
active_uploaders: estimate.active_uploaders,
|
||||
free_disk_bytes: estimate.free_disk_bytes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MeContextDto {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
/// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured.
|
||||
pub privacy_note: String,
|
||||
pub quota_enabled: bool,
|
||||
pub storage_quota_enabled: bool,
|
||||
/// Whether uploads are currently locked for the event, so the client can show
|
||||
/// a banner + disable the upload affordance on load (not just via SSE).
|
||||
pub uploads_locked: bool,
|
||||
}
|
||||
|
||||
pub async fn get_context(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<MeContextDto>, AppError> {
|
||||
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
|
||||
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||
let uploads_locked = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.map(|e| e.uploads_locked_at.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Json(MeContextDto {
|
||||
user_id: user.id,
|
||||
display_name: user.display_name,
|
||||
role: user.role.as_str().to_string(),
|
||||
privacy_note,
|
||||
quota_enabled,
|
||||
storage_quota_enabled,
|
||||
uploads_locked,
|
||||
}))
|
||||
}
|
||||
117
backend/src/handlers/media.rs
Normal file
117
backend/src/handlers/media.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Authenticated media gateway.
|
||||
//!
|
||||
//! Replaces the raw `/media` `ServeDir`. Every media byte now flows through a
|
||||
//! signature check plus a DB lookup, so:
|
||||
//! - soft-deleted uploads 404 (`find_by_id` filters `deleted_at`) — closes C3,
|
||||
//! - ban-hidden uploaders' artifacts 404 (mirrors the `v_feed` rule) — H2,
|
||||
//! - the export archives (which have no `upload` row) are unreachable — C1,
|
||||
//! - HTML/SVG can't be served as an active document — the response carries
|
||||
//! `nosniff` + a locked-down CSP (defense-in-depth behind the upload-time
|
||||
//! allowlist) — C2 sink.
|
||||
//!
|
||||
//! Access is authorized by the embedded HMAC signature (see `media_token`), not
|
||||
//! a Bearer header, because `<img>`/`<video>` cannot send one.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::Response;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::models::user::User;
|
||||
use crate::services::media_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MediaQuery {
|
||||
pub exp: i64,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
State(state): State<AppState>,
|
||||
Path((kind, id)): Path<(String, Uuid)>,
|
||||
Query(q): Query<MediaQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !media_token::verify(
|
||||
&state.config.jwt_secret,
|
||||
&kind,
|
||||
id,
|
||||
q.exp,
|
||||
&q.sig,
|
||||
Utc::now().timestamp(),
|
||||
) {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Ungültige oder abgelaufene Medien-URL.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// `find_by_id` filters `deleted_at IS NULL`, so soft-deleted uploads 404.
|
||||
let upload = Upload::find_by_id(&state.pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||
|
||||
// Hide artifacts of users whose uploads were hidden by a host (ban + hide),
|
||||
// matching the `usr.uploads_hidden = FALSE` predicate in `v_feed`.
|
||||
let uploader = User::find_by_id(&state.pool, upload.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||
if uploader.uploads_hidden {
|
||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
let (rel_path, content_type) = match kind.as_str() {
|
||||
"original" => (upload.original_path.clone(), upload.mime_type.clone()),
|
||||
"preview" => (
|
||||
upload
|
||||
.preview_path
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
|
||||
"image/jpeg".to_string(),
|
||||
),
|
||||
"thumbnail" => (
|
||||
upload
|
||||
.thumbnail_path
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
|
||||
"image/jpeg".to_string(),
|
||||
),
|
||||
_ => return Err(AppError::NotFound("Unbekannter Medientyp.".into())),
|
||||
};
|
||||
|
||||
stream_file(&state.config.media_path.join(&rel_path), &content_type).await
|
||||
}
|
||||
|
||||
async fn stream_file(path: &std::path::Path, content_type: &str) -> Result<Response, AppError> {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, StatusCode};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_LENGTH, metadata.len())
|
||||
// URLs are signed + time-boxed; cache only privately, aligned to the
|
||||
// 1h URL-stability bucket.
|
||||
.header(header::CACHE_CONTROL, "private, max-age=3600")
|
||||
// Defense-in-depth against any active content slipping past the
|
||||
// upload-time allowlist: never sniff, never script.
|
||||
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||
.header(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||
)
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| AppError::Internal(e.into()))
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod host;
|
||||
pub mod me;
|
||||
pub mod media;
|
||||
pub mod social;
|
||||
pub mod sse;
|
||||
pub mod test_admin;
|
||||
pub mod upload;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -8,6 +9,7 @@ use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::models::comment::{Comment, CommentDto};
|
||||
use crate::models::hashtag::{self, Hashtag};
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn toggle_like(
|
||||
@@ -15,13 +17,12 @@ pub async fn toggle_like(
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Check if user is banned
|
||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||
// Ban is already rejected by the AuthUser extractor. Scope the target to the
|
||||
// caller's event and reject deleted uploads (M1: no cross-event IDOR, no
|
||||
// likes on soft-deleted posts).
|
||||
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
if user.is_banned {
|
||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||
}
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
// Try to insert; if conflict, delete (toggle)
|
||||
let result = sqlx::query(
|
||||
@@ -51,12 +52,30 @@ pub async fn toggle_like(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ListCommentsQuery {
|
||||
/// RFC3339 timestamp — return only comments older than this. Pass the
|
||||
/// `created_at` of the oldest currently-loaded comment to fetch the next
|
||||
/// older page.
|
||||
pub before: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
const COMMENT_PAGE_SIZE: i64 = 50;
|
||||
|
||||
pub async fn list_comments(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthUser,
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Query(q): Query<ListCommentsQuery>,
|
||||
) -> Result<Json<Vec<CommentDto>>, AppError> {
|
||||
let comments = Comment::list_for_upload(&state.pool, upload_id).await?;
|
||||
// M1: a pure read behind any valid token must still be scoped to the
|
||||
// caller's event and must not leak comments on soft-deleted uploads.
|
||||
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
let comments =
|
||||
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
|
||||
Ok(Json(comments))
|
||||
}
|
||||
|
||||
@@ -71,15 +90,19 @@ pub async fn add_comment(
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Json(body): Json<AddCommentRequest>,
|
||||
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
||||
// M1: scope the target upload to the caller's event and reject deleted
|
||||
// posts. (Ban is already handled by the AuthUser extractor.)
|
||||
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
if user.is_banned {
|
||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||
}
|
||||
|
||||
let text = body.body.trim();
|
||||
if text.is_empty() || text.len() > 500 {
|
||||
let text_chars = text.chars().count();
|
||||
if text_chars == 0 || text_chars > 500 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
|
||||
));
|
||||
|
||||
@@ -3,32 +3,51 @@ use std::time::Duration;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::Json;
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::auth::jwt;
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::models::session::Session;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SseQuery {
|
||||
pub token: String,
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
/// SSE stream endpoint. Accepts JWT via query param since EventSource
|
||||
/// doesn't support custom headers.
|
||||
#[derive(Serialize)]
|
||||
pub struct StreamTicketResponse {
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
/// Mint a short-lived single-use SSE ticket. The browser's `EventSource` cannot
|
||||
/// send an `Authorization` header, so the alternative used to be passing the JWT
|
||||
/// as `?token=...` — which leaks the bearer token into access logs, referer
|
||||
/// headers, and browser history. The client now exchanges its Bearer token for
|
||||
/// an opaque ticket via this endpoint and passes that on the stream open.
|
||||
pub async fn issue_ticket(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Json<StreamTicketResponse> {
|
||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
||||
Json(StreamTicketResponse { ticket })
|
||||
}
|
||||
|
||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
||||
/// [`issue_ticket`]) — never the raw JWT.
|
||||
pub async fn stream(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SseQuery>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
|
||||
// Verify token
|
||||
let _claims = jwt::verify_token(&q.token, &state.config.jwt_secret)
|
||||
.map_err(|_| AppError::Unauthorized("Token ungültig.".into()))?;
|
||||
let token_hash = state
|
||||
.sse_tickets
|
||||
.consume(&q.ticket)
|
||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&q.token);
|
||||
Session::find_by_token_hash(&state.pool, &token_hash)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
|
||||
85
backend/src/handlers/test_admin.rs
Normal file
85
backend/src/handlers/test_admin.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Test-only admin routes. **Compiled in always, but only registered when
|
||||
//! `EVENTSNAP_TEST_MODE=1` is set in the environment.** The route returns a hard
|
||||
//! 404 in production builds because [`crate::main`] skips registering the handler.
|
||||
//!
|
||||
//! These exist to give the Playwright E2E suite a quick "reset everything"
|
||||
//! escape hatch without forcing tests to maintain raw SQL fixtures or spin up a
|
||||
//! fresh database container per test.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Truncates every event-scoped table, wipes media on disk, and reseeds the
|
||||
/// `config` table from migration defaults. Requires an admin JWT — even with
|
||||
/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
||||
pub async fn truncate_all(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Truncate in dependency order doesn't matter with CASCADE, but listing the
|
||||
// tables explicitly makes the blast radius obvious in code review.
|
||||
sqlx::query(
|
||||
r#"TRUNCATE
|
||||
comment_hashtag,
|
||||
upload_hashtag,
|
||||
hashtag,
|
||||
"like",
|
||||
comment,
|
||||
export_job,
|
||||
upload,
|
||||
session,
|
||||
"user",
|
||||
event,
|
||||
config
|
||||
RESTART IDENTITY CASCADE"#,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Reseed config — mirrors migrations 005 and 009. Kept in sync by hand
|
||||
// because pulling SQL out of the migration files at runtime is fragile.
|
||||
sqlx::query(
|
||||
r#"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'),
|
||||
('rate_limits_enabled', 'false'),
|
||||
('upload_rate_enabled', 'false'),
|
||||
('feed_rate_enabled', 'false'),
|
||||
('export_rate_enabled', 'false'),
|
||||
('join_rate_enabled', 'false'),
|
||||
('quota_enabled', 'false'),
|
||||
('storage_quota_enabled', 'false'),
|
||||
('upload_count_quota_enabled', 'false'),
|
||||
('privacy_note', '')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"#,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Wipe media directory. Best-effort: if it doesn't exist, that's fine.
|
||||
let _ = tokio::fs::remove_dir_all(&state.config.media_path).await;
|
||||
let _ = tokio::fs::create_dir_all(&state.config.media_path).await;
|
||||
|
||||
// The rate limiter holds an in-memory HashMap; clear it so a previous test's
|
||||
// counters don't leak into the next one.
|
||||
state.rate_limiter.clear();
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Returns whether the truncate endpoint is enabled. Used by the e2e harness
|
||||
/// during global-setup to fail loud if the test backend was started without
|
||||
/// `EVENTSNAP_TEST_MODE=1`.
|
||||
pub fn is_test_mode() -> bool {
|
||||
std::env::var("EVENTSNAP_TEST_MODE").as_deref() == Ok("1")
|
||||
}
|
||||
@@ -11,34 +11,68 @@ use crate::error::AppError;
|
||||
use crate::models::hashtag::{self, Hashtag};
|
||||
use crate::models::upload::{Upload, UploadDto};
|
||||
use crate::models::user::User;
|
||||
use crate::services::config;
|
||||
use crate::state::AppState;
|
||||
|
||||
const MAX_CAPTION_LENGTH: usize = 2000;
|
||||
|
||||
/// Derive the canonical MIME type and a *safe* file extension purely from the
|
||||
/// file's magic bytes, against a strict allowlist. The client-declared
|
||||
/// `Content-Type` and the client filename are never trusted: the old code
|
||||
/// skipped validation entirely for `application/*` and copied the extension
|
||||
/// verbatim from the filename, which allowed storing `{uuid}.html`/`.svg` with
|
||||
/// a script payload (stored XSS) and `/`-bearing extensions (path pollution +
|
||||
/// export DoS).
|
||||
///
|
||||
/// `infer` cannot fingerprint HTML/SVG, so the reject-by-default arm is exactly
|
||||
/// what closes the XSS vector — anything not positively identified as an allowed
|
||||
/// raster image or video is refused. Returns `(canonical_mime, safe_ext, is_video)`.
|
||||
fn classify(data: &[u8]) -> Result<(&'static str, &'static str, bool), AppError> {
|
||||
let kind = infer::get(data)
|
||||
.ok_or_else(|| AppError::BadRequest("Dateityp konnte nicht erkannt werden.".into()))?;
|
||||
match kind.mime_type() {
|
||||
"image/jpeg" => Ok(("image/jpeg", "jpg", false)),
|
||||
"image/png" => Ok(("image/png", "png", false)),
|
||||
"image/webp" => Ok(("image/webp", "webp", false)),
|
||||
// infer reports HEIC/HEIF as image/heif
|
||||
"image/heif" | "image/heic" => Ok(("image/heic", "heic", false)),
|
||||
"video/mp4" => Ok(("video/mp4", "mp4", true)),
|
||||
"video/quicktime" => Ok(("video/quicktime", "mov", true)),
|
||||
"video/webm" => Ok(("video/webm", "webm", true)),
|
||||
other => Err(AppError::BadRequest(format!(
|
||||
"Dateityp nicht erlaubt: {other}. Erlaubt sind JPEG, PNG, WebP, HEIC, MP4, MOV, WebM."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
||||
// Rate limit: N uploads per hour per user
|
||||
let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
|
||||
if let Err(retry_after_secs) = state
|
||||
.rate_limiter
|
||||
.check_with_retry(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
|
||||
{
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
|
||||
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||
let upload_rate_on = config::get_bool(&state.pool, "upload_rate_enabled", true).await;
|
||||
if rate_limits_on && upload_rate_on {
|
||||
let upload_rate = config::get_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("upload:{}", auth.user_id),
|
||||
upload_rate,
|
||||
Duration::from_secs(3600),
|
||||
) {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
// Ban is already rejected by the AuthUser extractor. We still load the user
|
||||
// for the uploader's display name in the response DTO.
|
||||
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
if user.is_banned {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||
}
|
||||
|
||||
// Check if uploads are locked
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
@@ -50,12 +84,20 @@ pub async fn upload(
|
||||
}
|
||||
|
||||
// Read config limits from DB
|
||||
let max_image_mb: i64 = get_config_i64(&state.pool, "max_image_size_mb", 20).await;
|
||||
let max_video_mb: i64 = get_config_i64(&state.pool, "max_video_size_mb", 500).await;
|
||||
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await;
|
||||
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
|
||||
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut file_name: Option<String> = None;
|
||||
let mut content_type: Option<String> = None;
|
||||
// Bound concurrent upload-body buffering to cap aggregate RAM (H3). Acquired
|
||||
// *before* the multipart body is read, so requests waiting on a permit hold
|
||||
// only a connection, not a buffered ~550 MB file. The permit is held for the
|
||||
// rest of the handler (body read + write) and released on return.
|
||||
let _upload_permit = state
|
||||
.upload_limiter
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
let mut file_data: Option<axum::body::Bytes> = None;
|
||||
let mut caption: Option<String> = None;
|
||||
let mut hashtags_csv: Option<String> = None;
|
||||
|
||||
@@ -63,12 +105,13 @@ pub async fn upload(
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
file_name = field.file_name().map(|s| s.to_string());
|
||||
content_type = field.content_type().map(|s| s.to_string());
|
||||
// The client-declared filename and Content-Type are deliberately
|
||||
// ignored — the stored MIME and extension are derived from the
|
||||
// file's magic bytes (see `classify`). Kept as `Bytes` (one copy
|
||||
// off the wire) rather than an extra `.to_vec()`.
|
||||
file_data = Some(
|
||||
field.bytes().await
|
||||
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
||||
.to_vec(),
|
||||
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?,
|
||||
);
|
||||
}
|
||||
"caption" => {
|
||||
@@ -88,11 +131,27 @@ pub async fn upload(
|
||||
}
|
||||
|
||||
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?;
|
||||
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let size = data.len() as i64;
|
||||
|
||||
// Validate file size
|
||||
let max_bytes = if mime.starts_with("video/") {
|
||||
// Validate caption length. Counted in chars (code points) to match the
|
||||
// "Zeichen" wording in the error message — `.len()` would be bytes and
|
||||
// reject perfectly valid German/emoji captions early.
|
||||
if let Some(ref cap) = caption {
|
||||
if cap.chars().count() > MAX_CAPTION_LENGTH {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
||||
MAX_CAPTION_LENGTH
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Derive canonical MIME + safe extension from magic bytes against a strict
|
||||
// allowlist. The client Content-Type and filename are never trusted.
|
||||
let (canonical_mime, safe_ext, is_video) = classify(&data)?;
|
||||
let canonical_mime = canonical_mime.to_string();
|
||||
|
||||
// Validate file size against the per-category limit.
|
||||
let max_bytes = if is_video {
|
||||
max_video_mb * 1024 * 1024
|
||||
} else {
|
||||
max_image_mb * 1024 * 1024
|
||||
@@ -104,41 +163,120 @@ pub async fn upload(
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine file extension
|
||||
let ext = file_name
|
||||
.as_deref()
|
||||
.and_then(|n| n.rsplit('.').next())
|
||||
.unwrap_or(if mime.starts_with("video/") { "mp4" } else { "jpg" });
|
||||
|
||||
let upload_id = Uuid::new_v4();
|
||||
let event_slug = &state.config.event_slug;
|
||||
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
|
||||
let relative_path = format!("originals/{event_slug}/{upload_id}.{safe_ext}");
|
||||
let absolute_path = state.config.media_path.join(&relative_path);
|
||||
|
||||
// Ensure directory exists and write file
|
||||
if let Some(parent) = absolute_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| AppError::Internal(e.into()))?;
|
||||
}
|
||||
tokio::fs::write(&absolute_path, &data).await.map_err(|e| AppError::Internal(e.into()))?;
|
||||
// Per-user storage quota — dynamic formula based on available disk space and the
|
||||
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||
// disable it on trusted instances. `None` ⇒ enforcement off.
|
||||
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||
let quota_limit: Option<i64> = if quota_on && storage_quota_on {
|
||||
compute_storage_quota(&state).await.limit_bytes
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Update user's total upload bytes
|
||||
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
||||
// Event-wide file-count cap. Unlike the per-user budget, this can't be reset
|
||||
// by minting a fresh account via /join, so it bounds total uploads no matter
|
||||
// how many accounts an abuser creates. Wires up the previously-dead
|
||||
// `upload_count_quota_*` config (default off to preserve existing behavior).
|
||||
if quota_on && config::get_bool(&state.pool, "upload_count_quota_enabled", false).await {
|
||||
let max_count = config::get_i64(&state.pool, "upload_count_quota_max", 10_000).await;
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
if count >= max_count {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Das Upload-Limit für dieses Event ist erreicht.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve quota and insert the row in a single transaction so the byte
|
||||
// counter and the upload row are atomic (M6: no quota leak if we crash
|
||||
// between them) and the reservation is a conditional UPDATE that row-locks
|
||||
// (M5: concurrent uploads can no longer all pass a stale snapshot and
|
||||
// overshoot the limit). The file is written before commit and unlinked on
|
||||
// any rollback so a failed transaction leaves nothing behind.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let reserved: Option<(Uuid,)> = if let Some(limit) = quota_limit {
|
||||
sqlx::query_as(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = total_upload_bytes + $2
|
||||
WHERE id = $1 AND total_upload_bytes + $2 <= $3
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.bind(size)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
.bind(limit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = total_upload_bytes + $2
|
||||
WHERE id = $1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.bind(size)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
if reserved.is_none() {
|
||||
// The user exists (checked above), so a missing row here means the
|
||||
// conditional limit guard rejected the reservation → over quota.
|
||||
tx.rollback().await.ok();
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// Insert upload record
|
||||
let upload = Upload::create(
|
||||
&state.pool,
|
||||
// Write the file. On any failure, roll back (releasing the reservation) and
|
||||
// remove a possibly-partial file.
|
||||
if let Some(parent) = absolute_path.parent() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
tx.rollback().await.ok();
|
||||
return Err(AppError::Internal(e.into()));
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&absolute_path, data.as_ref()).await {
|
||||
tx.rollback().await.ok();
|
||||
return Err(AppError::Internal(e.into()));
|
||||
}
|
||||
|
||||
let upload = match Upload::create(
|
||||
&mut *tx,
|
||||
auth.event_id,
|
||||
auth.user_id,
|
||||
&relative_path,
|
||||
&mime,
|
||||
&canonical_mime,
|
||||
size,
|
||||
caption.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tx.rollback().await.ok();
|
||||
let _ = tokio::fs::remove_file(&absolute_path).await;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
let _ = tokio::fs::remove_file(&absolute_path).await;
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// Process hashtags from caption and explicit CSV
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
@@ -164,7 +302,7 @@ pub async fn upload(
|
||||
// Spawn compression task
|
||||
state
|
||||
.compression
|
||||
.process(upload.id, relative_path, mime.clone());
|
||||
.process(upload.id, relative_path, canonical_mime.clone());
|
||||
|
||||
// Broadcast SSE event
|
||||
let dto = UploadDto {
|
||||
@@ -173,7 +311,13 @@ pub async fn upload(
|
||||
uploader_name: user.display_name,
|
||||
preview_url: None,
|
||||
thumbnail_url: None,
|
||||
mime_type: mime,
|
||||
original_url: Some(crate::services::media_token::signed_url(
|
||||
&state.config.jwt_secret,
|
||||
"original",
|
||||
upload.id,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)),
|
||||
mime_type: canonical_mime,
|
||||
caption,
|
||||
hashtags: tags,
|
||||
like_count: 0,
|
||||
@@ -182,10 +326,10 @@ pub async fn upload(
|
||||
created_at: upload.created_at,
|
||||
};
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "new-upload".to_string(),
|
||||
data: serde_json::to_string(&dto).unwrap_or_default(),
|
||||
});
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"new-upload",
|
||||
serde_json::to_string(&dto).unwrap_or_default(),
|
||||
));
|
||||
|
||||
Ok((StatusCode::CREATED, Json(dto)))
|
||||
}
|
||||
@@ -238,7 +382,15 @@ pub async fn delete_upload(
|
||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
||||
}
|
||||
|
||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
||||
if let Some(paths) = Upload::soft_delete(&state.pool, upload_id).await? {
|
||||
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
|
||||
}
|
||||
|
||||
// Tell other clients to drop the photo immediately (mirrors host delete).
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"upload-deleted",
|
||||
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -252,12 +404,98 @@ async fn drain_multipart(mut mp: Multipart) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
||||
/// Snapshot of the dynamic per-user quota used both by the upload pre-check and the
|
||||
/// `GET /me/quota` endpoint. `limit_bytes = None` means quota enforcement is currently
|
||||
/// off (the frontend hides the widget in that case).
|
||||
pub struct QuotaEstimate {
|
||||
pub limit_bytes: Option<i64>,
|
||||
pub active_uploaders: i64,
|
||||
pub free_disk_bytes: i64,
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
/// Computes the per-user storage quota using
|
||||
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes =
|
||||
/// None` whenever the storage quota is currently disabled — callers should skip the
|
||||
/// check (upload handler) or hide the UI (quota endpoint).
|
||||
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||
let tolerance = config::get_f64(&state.pool, "quota_tolerance", 0.75).await;
|
||||
|
||||
let (active_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let active = active_count.max(1);
|
||||
|
||||
let media_path = state.config.media_path.to_string_lossy().to_string();
|
||||
let free_disk = sysinfo::Disks::new_with_refreshed_list()
|
||||
.iter()
|
||||
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
|
||||
.map(|d| d.available_space())
|
||||
.unwrap_or_else(|| {
|
||||
sysinfo::Disks::new_with_refreshed_list()
|
||||
.iter()
|
||||
.find(|d| d.mount_point().to_string_lossy() == "/")
|
||||
.map(|d| d.available_space())
|
||||
.unwrap_or(0)
|
||||
}) as i64;
|
||||
|
||||
let limit_bytes = if quota_on && storage_quota_on {
|
||||
Some(((free_disk as f64 * tolerance) / active as f64).floor() as i64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
QuotaEstimate {
|
||||
limit_bytes,
|
||||
active_uploaders: active,
|
||||
free_disk_bytes: free_disk,
|
||||
tolerance,
|
||||
}
|
||||
}
|
||||
|
||||
// The original-file download is now served by the authenticated, signed media
|
||||
// gateway (`handlers::media::serve`). The old unauthenticated `get_original`
|
||||
// alias was the H2 vulnerability and has been removed.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::classify;
|
||||
|
||||
#[test]
|
||||
fn classify_accepts_allowed_image_types() {
|
||||
// PNG signature
|
||||
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
|
||||
let (mime, ext, is_video) = classify(&png).expect("png allowed");
|
||||
assert_eq!(mime, "image/png");
|
||||
assert_eq!(ext, "png");
|
||||
assert!(!is_video);
|
||||
|
||||
// JPEG signature
|
||||
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let (mime, ext, _) = classify(&jpeg).expect("jpeg allowed");
|
||||
assert_eq!(mime, "image/jpeg");
|
||||
assert_eq!(ext, "jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_rejects_unrecognized_and_html_svg() {
|
||||
// Plain HTML / script payload — infer cannot fingerprint it, so it must
|
||||
// be rejected (this is the stored-XSS vector the old code let through
|
||||
// via a declared `application/*` Content-Type).
|
||||
let html = b"<html><script>alert(1)</script></html>";
|
||||
assert!(classify(html).is_err());
|
||||
|
||||
// SVG (text-based) is likewise unrecognized → rejected.
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>x</script></svg>";
|
||||
assert!(classify(svg).is_err());
|
||||
|
||||
// Empty / garbage.
|
||||
assert!(classify(&[]).is_err());
|
||||
assert!(classify(&[0x00, 0x01, 0x02, 0x03]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use anyhow::Result;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -31,7 +30,23 @@ async fn main() -> Result<()> {
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let pool = db::create_pool(&config.database_url).await?;
|
||||
let state = AppState::new(pool, config.clone());
|
||||
|
||||
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
|
||||
// stuck `compression_status='processing'` uploads and `status='running'` export
|
||||
// jobs. Must run before the server starts taking requests so clients never see
|
||||
// the half-state.
|
||||
services::maintenance::startup_recovery(&pool).await;
|
||||
|
||||
let state = AppState::new(pool.clone(), config.clone());
|
||||
|
||||
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
|
||||
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
|
||||
services::maintenance::spawn_periodic_tasks(
|
||||
pool,
|
||||
state.rate_limiter.clone(),
|
||||
state.sse_tickets.clone(),
|
||||
config.media_path.clone(),
|
||||
);
|
||||
|
||||
// Ensure media directories exist
|
||||
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
||||
@@ -42,13 +57,18 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||
// Upload — body limit disabled; size validation is done inside the handler
|
||||
// Upload — cap the request body so a single request can't buffer the box
|
||||
// into OOM. Sized to the largest allowed video (500 MB) plus multipart
|
||||
// overhead; the per-category limit is still enforced inside the handler.
|
||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
||||
.route_layer(DefaultBodyLimit::disable()))
|
||||
.route_layer(DefaultBodyLimit::max(550 * 1024 * 1024)))
|
||||
.route(
|
||||
"/api/v1/upload/{id}",
|
||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||
)
|
||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||
// Feed
|
||||
.route("/api/v1/feed", get(handlers::feed::feed))
|
||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||
@@ -62,6 +82,7 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
|
||||
// SSE
|
||||
.route("/api/v1/stream", get(handlers::sse::stream))
|
||||
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
|
||||
// Host Dashboard
|
||||
.route("/api/v1/host/event", get(handlers::host::get_event_status))
|
||||
.route("/api/v1/host/event/close", post(handlers::host::close_event))
|
||||
@@ -71,6 +92,10 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
|
||||
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
|
||||
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
|
||||
.route(
|
||||
"/api/v1/host/users/{id}/pin-reset",
|
||||
post(handlers::host::reset_user_pin),
|
||||
)
|
||||
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
||||
// Export (all authenticated users)
|
||||
@@ -85,14 +110,45 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
|
||||
|
||||
// Serve media files from disk
|
||||
let media_service = ServeDir::new(&config.media_path);
|
||||
// Test-only route: a hard reset for the Playwright E2E harness. The handler
|
||||
// is compiled in always, but the route is only attached when
|
||||
// `EVENTSNAP_TEST_MODE=1`. In production the call returns 404 — the route
|
||||
// simply isn't there.
|
||||
let api = if handlers::test_admin::is_test_mode() {
|
||||
tracing::warn!(
|
||||
"EVENTSNAP_TEST_MODE=1 — registering /api/v1/admin/__truncate. \
|
||||
DO NOT enable this in production."
|
||||
);
|
||||
api.route(
|
||||
"/api/v1/admin/__truncate",
|
||||
post(handlers::test_admin::truncate_all),
|
||||
)
|
||||
} else {
|
||||
api
|
||||
};
|
||||
|
||||
// Media is served exclusively through the authenticated, signed gateway —
|
||||
// there is no raw static mount. The gateway verifies an HMAC signature and
|
||||
// consults the DB (deleted / ban-hidden / type), so private artifacts and
|
||||
// the export archives are never reachable by guessing a path.
|
||||
// Trace spans log the request *path* only, never the query string — signed
|
||||
// media URLs carry a replayable `?sig=` capability that must not land in
|
||||
// access logs.
|
||||
let trace_layer = TraceLayer::new_for_http().make_span_with(
|
||||
|req: &axum::http::Request<axum::body::Body>| {
|
||||
tracing::info_span!(
|
||||
"request",
|
||||
method = %req.method(),
|
||||
path = %req.uri().path(),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
.nest_service("/media", media_service)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.route("/media/{kind}/{id}", get(handlers::media::serve))
|
||||
.layer(trace_layer)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
||||
|
||||
@@ -40,15 +40,35 @@ impl Comment {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_for_upload(pool: &PgPool, upload_id: Uuid) -> Result<Vec<CommentDto>, sqlx::Error> {
|
||||
/// Paginated comment listing — returns up to `limit` rows in chronological
|
||||
/// order (oldest first). If `before` is set, only comments older than that
|
||||
/// timestamp are returned, enabling backward cursor pagination ("load
|
||||
/// earlier"). Without the LIMIT a hot post with thousands of comments could
|
||||
/// OOM the server on a single GET.
|
||||
pub async fn list_for_upload(
|
||||
pool: &PgPool,
|
||||
upload_id: Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<CommentDto>, sqlx::Error> {
|
||||
// Two-step: pick the newest `limit` rows older than `before`, then flip
|
||||
// them back into ascending order so the caller can render top-to-bottom.
|
||||
sqlx::query_as::<_, CommentDto>(
|
||||
"SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name, c.body, c.created_at
|
||||
FROM comment c
|
||||
JOIN \"user\" u ON u.id = c.user_id
|
||||
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
|
||||
ORDER BY c.created_at ASC",
|
||||
"SELECT * FROM (
|
||||
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
|
||||
c.body, c.created_at
|
||||
FROM comment c
|
||||
JOIN \"user\" u ON u.id = c.user_id
|
||||
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
|
||||
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $3
|
||||
) page
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.bind(before)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
@@ -69,4 +89,25 @@ impl Comment {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
|
||||
/// comment doesn't exist or belongs to a different event.
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE comment
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND upload_id IN (SELECT id FROM upload WHERE event_id = $2)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +67,46 @@ impl Hashtag {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract #hashtags from text (caption or body).
|
||||
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
|
||||
/// ASCII letters, digits, and underscore — emoji / punctuation / accented
|
||||
/// characters are rejected. This is deliberately strict: hashtags are an index
|
||||
/// (used for filtering and SQL JOINs), and tags like `#🎉` or `#!?` accumulate
|
||||
/// noise without helping anyone find content.
|
||||
pub fn extract_hashtags(text: &str) -> Vec<String> {
|
||||
const MAX_TAG_LEN: usize = 40;
|
||||
text.split_whitespace()
|
||||
.filter(|w| w.starts_with('#') && w.len() > 1)
|
||||
.map(|w| w.trim_start_matches('#').to_lowercase())
|
||||
.filter(|t| !t.is_empty())
|
||||
.filter_map(|w| w.strip_prefix('#'))
|
||||
.map(|t| {
|
||||
t.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
})
|
||||
.filter(|t| !t.is_empty() && t.chars().count() <= MAX_TAG_LEN)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_hashtags;
|
||||
|
||||
#[test]
|
||||
fn ascii_word_chars_extracted() {
|
||||
assert_eq!(
|
||||
extract_hashtags("hello #wedding #Day_2 #cake!"),
|
||||
vec!["wedding", "day_2", "cake"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emojis_and_punctuation_excluded() {
|
||||
// The 🎉 tag drops out entirely (no ASCII chars after #), the next tag
|
||||
// stops at the !? and yields only "fun".
|
||||
assert_eq!(extract_hashtags("#🎉 #!? #fun!"), vec!["fun"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_bare_hash_skipped() {
|
||||
assert_eq!(extract_hashtags("# #"), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::user::UserRole;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
@@ -12,6 +14,18 @@ pub struct Session {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Live identity reconciled from the DB for a valid, unexpired session. Used by
|
||||
/// the `AuthUser` extractor so role/ban/event are sourced from the `user` row
|
||||
/// rather than trusted from (potentially stale) JWT claims.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct AuthContext {
|
||||
pub session_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub event_id: Uuid,
|
||||
pub role: UserRole,
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
@@ -43,6 +57,25 @@ impl Session {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reconcile a session against the live `user` row in a single round-trip.
|
||||
/// Returns `None` when the session is missing/expired. The PK join to
|
||||
/// `"user"` is cheap and lets the extractor read the *current* role/ban
|
||||
/// state instead of the JWT claim.
|
||||
pub async fn find_auth_context(
|
||||
pool: &PgPool,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<AuthContext>, sqlx::Error> {
|
||||
sqlx::query_as::<_, AuthContext>(
|
||||
"SELECT s.id AS session_id, u.id AS user_id, u.event_id, u.role, u.is_banned
|
||||
FROM session s
|
||||
JOIN \"user\" u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
@@ -61,4 +94,15 @@ impl Session {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke every session belonging to a user — used on ban, role change, and
|
||||
/// PIN reset so existing JWTs stop working immediately. Returns the number
|
||||
/// of sessions removed. Best-effort: callers log but do not fail on error.
|
||||
pub async fn delete_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM session WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,20 @@ pub struct Upload {
|
||||
pub mime_type: String,
|
||||
pub original_size_bytes: i64,
|
||||
pub caption: Option<String>,
|
||||
pub compression_status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// On-disk artifact paths returned by the soft-delete methods so the caller can
|
||||
/// unlink the files after the DB commit.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct DeletedPaths {
|
||||
pub original: String,
|
||||
pub preview: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadDto {
|
||||
pub id: Uuid,
|
||||
@@ -25,6 +35,8 @@ pub struct UploadDto {
|
||||
pub uploader_name: String,
|
||||
pub preview_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// Signed gateway URL for the full-resolution original. Always present.
|
||||
pub original_url: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub caption: Option<String>,
|
||||
pub hashtags: Vec<String>,
|
||||
@@ -35,15 +47,21 @@ pub struct UploadDto {
|
||||
}
|
||||
|
||||
impl Upload {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
/// Generic over the executor so it can run inside the upload transaction
|
||||
/// (`&mut *tx`) alongside the quota-counter reservation, keeping the
|
||||
/// byte-counter and the row atomic (no quota leak on a mid-write crash).
|
||||
pub async fn create<'e, E>(
|
||||
executor: E,
|
||||
event_id: Uuid,
|
||||
user_id: Uuid,
|
||||
original_path: &str,
|
||||
mime_type: &str,
|
||||
original_size_bytes: i64,
|
||||
caption: Option<&str>,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
) -> Result<Self, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
@@ -55,7 +73,7 @@ impl Upload {
|
||||
.bind(mime_type)
|
||||
.bind(original_size_bytes)
|
||||
.bind(caption)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -68,6 +86,23 @@ impl Upload {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Event-scoped lookup used by host endpoints so a host of event A cannot
|
||||
/// reach uploads belonging to event B.
|
||||
pub async fn find_by_id_and_event(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM upload
|
||||
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_preview_path(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
@@ -94,12 +129,80 @@ impl Upload {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
|
||||
/// Done in a single transaction so a crash between the two writes can't leave
|
||||
/// the quota counter pointing at bytes the user has already deleted (which would
|
||||
/// silently lock them out of future uploads).
|
||||
///
|
||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
||||
/// delete action double-decrementing the counter.
|
||||
///
|
||||
/// Returns the artifact paths of the row that was deleted (`None` if nothing
|
||||
/// matched) so the caller can unlink the files after the commit.
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
Some(DeletedPaths { original, preview, thumbnail })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
|
||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||
/// can return a clean 404, and the artifact paths otherwise.
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Some(DeletedPaths { original, preview, thumbnail })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub async fn update_caption(
|
||||
@@ -114,4 +217,17 @@ impl Upload {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_compression_status(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
status: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE upload SET compression_status = $2 WHERE id = $1")
|
||||
.bind(id)
|
||||
.bind(status)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ pub enum UserRole {
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
UserRole::Guest => "guest",
|
||||
UserRole::Host => "host",
|
||||
UserRole::Admin => "admin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -3,24 +3,38 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::{broadcast, Semaphore};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::SseEvent;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompressionWorker {
|
||||
semaphore: Arc<Semaphore>,
|
||||
/// Separate permit pools for images and videos so a couple of slow/large
|
||||
/// videos (each holding a permit across the full ffmpeg wait) can never
|
||||
/// starve image-preview generation, and vice versa.
|
||||
image_sem: Arc<Semaphore>,
|
||||
video_sem: Arc<Semaphore>,
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
}
|
||||
|
||||
impl CompressionWorker {
|
||||
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize) -> Self {
|
||||
pub fn new(
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
image_concurrency: usize,
|
||||
video_concurrency: usize,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||
image_sem: Arc::new(Semaphore::new(image_concurrency)),
|
||||
video_sem: Arc::new(Semaphore::new(video_concurrency)),
|
||||
pool,
|
||||
media_path,
|
||||
sse_tx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +42,31 @@ impl CompressionWorker {
|
||||
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
||||
let worker = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = worker.semaphore.acquire().await;
|
||||
if let Err(e) = worker.do_process(upload_id, &original_path, &mime_type).await {
|
||||
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
||||
let is_video = mime_type.starts_with("video/");
|
||||
let sem = if is_video { &worker.video_sem } else { &worker.image_sem };
|
||||
let _permit = sem.acquire().await;
|
||||
match worker.do_process(upload_id, &original_path, &mime_type).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("compression completed for upload {upload_id}");
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-processed".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the detailed error (incl. paths) server-side only; the
|
||||
// SSE channel is broadcast to every client, so send a generic
|
||||
// message — never leak absolute filesystem paths.
|
||||
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-error".to_string(),
|
||||
data: serde_json::json!({
|
||||
"upload_id": upload_id,
|
||||
"error": "Verarbeitung fehlgeschlagen."
|
||||
}).to_string(),
|
||||
});
|
||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -41,6 +77,8 @@ impl CompressionWorker {
|
||||
original_path: &str,
|
||||
mime_type: &str,
|
||||
) -> Result<()> {
|
||||
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
|
||||
|
||||
let original = self.media_path.join(original_path);
|
||||
|
||||
if mime_type.starts_with("image/") {
|
||||
@@ -53,6 +91,7 @@ impl CompressionWorker {
|
||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
||||
}
|
||||
|
||||
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -71,10 +110,25 @@ impl CompressionWorker {
|
||||
let preview_path_clone = preview_path.clone();
|
||||
let mime_owned = mime_type.to_string();
|
||||
|
||||
// Run blocking image operations in a spawn_blocking task
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let img = image::open(&original)
|
||||
.context("failed to open image")?;
|
||||
// Run blocking image operations in a spawn_blocking task, bounded by a
|
||||
// hard timeout (mirrors the ffmpeg guard) so a pathological decode can't
|
||||
// hold the permit forever.
|
||||
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
use image::ImageReader;
|
||||
|
||||
// Reject decompression bombs *before* fully decoding: a small file can
|
||||
// otherwise expand to enormous dimensions and a very expensive resize.
|
||||
// 12000×12000 covers any real phone photo; max_alloc caps memory.
|
||||
let mut reader = ImageReader::open(&original)
|
||||
.context("failed to open image")?
|
||||
.with_guessed_format()
|
||||
.context("failed to read image header")?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(12_000);
|
||||
limits.max_image_height = Some(12_000);
|
||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().context("failed to decode image")?;
|
||||
|
||||
// Resize to max 800px wide, preserving aspect ratio
|
||||
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
|
||||
@@ -95,8 +149,12 @@ impl CompressionWorker {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
});
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
|
||||
Ok(join) => join.context("image task panicked")??,
|
||||
Err(_) => anyhow::bail!("image processing timeout after 120s"),
|
||||
}
|
||||
|
||||
Ok(format!("previews/{preview_filename}"))
|
||||
}
|
||||
@@ -112,7 +170,11 @@ impl CompressionWorker {
|
||||
let thumb_filename = format!("{upload_id}.jpg");
|
||||
let thumb_path = thumbs_dir.join(&thumb_filename);
|
||||
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a
|
||||
// cap, the held compression-worker semaphore permit is never released and the
|
||||
// pool eventually deadlocks (no further uploads ever processed). 120s is well
|
||||
// above the time to extract one frame from any sane input.
|
||||
let mut child = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
original.to_str().unwrap_or_default(),
|
||||
@@ -125,13 +187,36 @@ impl CompressionWorker {
|
||||
"-y",
|
||||
thumb_path.to_str().unwrap_or_default(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run ffmpeg")?;
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("failed to spawn ffmpeg")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("ffmpeg failed: {stderr}");
|
||||
let status = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
anyhow::bail!("ffmpeg timeout after 120s");
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
// Best-effort: drain stderr for the log.
|
||||
let mut stderr = Vec::new();
|
||||
if let Some(mut handle) = child.stderr.take() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let _ = handle.read_to_end(&mut stderr).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"ffmpeg failed: {}",
|
||||
String::from_utf8_lossy(&stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!("thumbnails/{thumb_filename}"))
|
||||
|
||||
49
backend/src/services/config.rs
Normal file
49
backend/src/services/config.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Reads of the runtime-tunable `config` table.
|
||||
//!
|
||||
//! Each handler used to keep a small local copy of these helpers; consolidating them
|
||||
//! here means one place to add a parser, one place to mock for tests, and one place to
|
||||
//! find when a key changes. New keys do not require code changes — they're picked up
|
||||
//! the next time someone calls `get_*`.
|
||||
//!
|
||||
//! Values are read with a default fallback so the app still starts if a key is missing
|
||||
//! (e.g. during a migration window). Production seeds keys via migrations 005 and 009.
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn fetch_raw(pool: &PgPool, key: &str) -> Option<String> {
|
||||
sqlx::query_as::<_, (String,)>("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(v,)| v)
|
||||
}
|
||||
|
||||
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String {
|
||||
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_i64(pool: &PgPool, key: &str, default: i64) -> i64 {
|
||||
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_usize(pool: &PgPool, key: &str, default: usize) -> usize {
|
||||
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
pub async fn get_f64(pool: &PgPool, key: &str, default: f64) -> f64 {
|
||||
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Parses common truthy spellings used by both the migration seeds and the admin form.
|
||||
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
|
||||
/// returns `default`.
|
||||
pub async fn get_bool(pool: &PgPool, key: &str, default: bool) -> bool {
|
||||
let Some(raw) = fetch_raw(pool, key).await else { return default };
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => true,
|
||||
"false" | "0" | "no" | "off" => false,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,14 @@ pub fn spawn_export_jobs(
|
||||
let sse_tx2 = sse_tx.clone();
|
||||
let event_name2 = event_name.clone();
|
||||
|
||||
// Per-run id so two runs (e.g. a re-release after a crash mid-export, where
|
||||
// startup_recovery marked the old run 'failed' but its task may still be
|
||||
// winding down) write to distinct temp paths and can't truncate each other's
|
||||
// output (M8). The final served filenames stay fixed.
|
||||
let run_id = Uuid::new_v4();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
|
||||
if let Err(e) = run_zip_export(event_id, run_id, &pool, &media_path, &sse_tx).await {
|
||||
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
|
||||
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
|
||||
}
|
||||
@@ -104,7 +110,7 @@ pub fn spawn_export_jobs(
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
|
||||
run_html_export(event_id, run_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
|
||||
{
|
||||
tracing::error!("HTML export failed for event {event_id}: {e:#}");
|
||||
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
|
||||
@@ -117,6 +123,7 @@ pub fn spawn_export_jobs(
|
||||
|
||||
async fn run_zip_export(
|
||||
event_id: Uuid,
|
||||
run_id: Uuid,
|
||||
pool: &PgPool,
|
||||
media_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
@@ -129,7 +136,8 @@ async fn run_zip_export(
|
||||
let exports_dir = media_path.join("exports");
|
||||
tokio::fs::create_dir_all(&exports_dir).await?;
|
||||
|
||||
let tmp_path = exports_dir.join("Gallery.zip.tmp");
|
||||
// Per-run temp name; final served name stays fixed.
|
||||
let tmp_path = exports_dir.join(format!("Gallery.{run_id}.zip.tmp"));
|
||||
let out_path = exports_dir.join("Gallery.zip");
|
||||
|
||||
{
|
||||
@@ -190,6 +198,7 @@ async fn run_zip_export(
|
||||
|
||||
async fn run_html_export(
|
||||
event_id: Uuid,
|
||||
run_id: Uuid,
|
||||
event_name: &str,
|
||||
pool: &PgPool,
|
||||
media_path: &Path,
|
||||
@@ -208,8 +217,8 @@ async fn run_html_export(
|
||||
let exports_dir = media_path.join("exports");
|
||||
tokio::fs::create_dir_all(&exports_dir).await?;
|
||||
|
||||
// 2. Create temp directory for media processing
|
||||
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
|
||||
// 2. Create temp directory for media processing (per-run, see run_id).
|
||||
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}_{run_id}"));
|
||||
let media_tmp = tmp_dir.join("media");
|
||||
tokio::fs::create_dir_all(&media_tmp).await?;
|
||||
|
||||
@@ -370,8 +379,8 @@ async fn run_html_export(
|
||||
|
||||
update_progress(pool, event_id, "html", 72).await;
|
||||
|
||||
// 5. Create ZIP
|
||||
let tmp_path = exports_dir.join("Memories.zip.tmp");
|
||||
// 5. Create ZIP (per-run temp name; final served name stays fixed).
|
||||
let tmp_path = exports_dir.join(format!("Memories.{run_id}.zip.tmp"));
|
||||
let out_path = exports_dir.join("Memories.zip");
|
||||
|
||||
{
|
||||
|
||||
73
backend/src/services/jobs.rs
Normal file
73
backend/src/services/jobs.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Shared shape for long-running background work.
|
||||
//!
|
||||
//! Today's [`compression`](crate::services::compression) and [`export`](crate::services::export)
|
||||
//! pipelines each implement their own progress + SSE plumbing. They could converge on the
|
||||
//! trait sketched here so future jobs (analytics, archival, ...) plug into one progress
|
||||
//! pipeline.
|
||||
//!
|
||||
//! This module is intentionally a *sketch*: the existing services are not yet wired to
|
||||
//! it. The aim is to (a) document the convention so new jobs follow it, (b) make the
|
||||
//! refactor mechanical when someone is ready to do it. See `docs/IDEAS.md` —
|
||||
//! "Maintainability principles" — for the rationale.
|
||||
//!
|
||||
//! Example of an eventual implementor:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct ZipExport { event_id: Uuid, /* … */ }
|
||||
//!
|
||||
//! impl BackgroundJob for ZipExport {
|
||||
//! fn name(&self) -> &'static str { "zip-export" }
|
||||
//! async fn run(self, ctx: JobContext) -> Result<()> {
|
||||
//! for (i, item) in items.iter().enumerate() {
|
||||
//! ctx.report(percent(i, items.len())).await?;
|
||||
//! // … write to zip …
|
||||
//! }
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handle handed to a running job: reports progress and emits SSE events.
|
||||
///
|
||||
/// Wraps the existing SSE broadcaster and an optional `export_job` row. Implementors
|
||||
/// don't need to know about `state.sse_tx` directly — they call [`JobContext::report`]
|
||||
/// and get the same effect.
|
||||
pub struct JobContext {
|
||||
pub job_id: Option<uuid::Uuid>,
|
||||
pub event_kind: &'static str,
|
||||
pub sse_tx: tokio::sync::broadcast::Sender<crate::state::SseEvent>,
|
||||
pub pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
impl JobContext {
|
||||
/// Update progress (0..=100) and broadcast an SSE tick. Cheap to call often —
|
||||
/// rate-limit at the call site if a job emits at > 10 Hz.
|
||||
pub async fn report(&self, percent: u8) -> Result<()> {
|
||||
if let Some(job_id) = self.job_id {
|
||||
sqlx::query("UPDATE export_job SET progress_pct = $1 WHERE id = $2")
|
||||
.bind(percent as i16)
|
||||
.bind(job_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
let _ = self.sse_tx.send(crate::state::SseEvent::new(
|
||||
self.event_kind,
|
||||
serde_json::json!({ "progress_pct": percent }).to_string(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One unit of work that publishes progress through a [`JobContext`].
|
||||
///
|
||||
/// `run` consumes `self`; spawn with `tokio::spawn` at the caller. Errors propagate;
|
||||
/// the caller is responsible for mapping them to `export_job.error_message` or
|
||||
/// equivalent. Implementors stay small — the trait deliberately has no `cancel`
|
||||
/// or `pause`; we have not needed those yet.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait BackgroundJob: Send + 'static {
|
||||
fn name(&self) -> &'static str;
|
||||
async fn run(self, ctx: JobContext) -> Result<()>;
|
||||
}
|
||||
109
backend/src/services/maintenance.rs
Normal file
109
backend/src/services/maintenance.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Startup recovery + periodic background hygiene.
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
//!
|
||||
//! 1. **Startup sweep** — when the server boots, fix rows left in an "in-progress"
|
||||
//! state by the previous (possibly crashed) instance. Compression and export jobs
|
||||
//! each leave a status row when they begin; if the process is killed mid-run, that
|
||||
//! row stays `'processing'` / `'running'` forever, blocking re-tries and leaving
|
||||
//! users staring at a spinner. Resetting them on startup recovers gracefully.
|
||||
//!
|
||||
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
|
||||
//! request: expired sessions (otherwise the table grows unboundedly), and the
|
||||
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
||||
//! accumulate).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::services::rate_limiter::RateLimiter;
|
||||
use crate::services::sse_tickets::SseTicketStore;
|
||||
|
||||
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
|
||||
/// before the HTTP server starts taking requests, so users never observe the
|
||||
/// half-state.
|
||||
pub async fn startup_recovery(pool: &PgPool) {
|
||||
// Uploads whose preview generation was interrupted. Marking them 'failed' is
|
||||
// safer than re-queueing — the original file is still on disk, the user can
|
||||
// delete + re-upload if they care, and we avoid double-processing risk.
|
||||
match sqlx::query(
|
||||
"UPDATE upload SET compression_status = 'failed'
|
||||
WHERE compression_status = 'processing'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.rows_affected() > 0 => {
|
||||
tracing::warn!(
|
||||
"startup recovery: reset {} stuck upload(s) from 'processing' to 'failed'",
|
||||
r.rows_affected()
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
|
||||
}
|
||||
|
||||
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
|
||||
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
|
||||
match sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'failed',
|
||||
error_message = COALESCE(error_message, 'Server-Neustart während des Exports')
|
||||
WHERE status = 'running'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.rows_affected() > 0 => {
|
||||
tracing::warn!(
|
||||
"startup recovery: reset {} stuck export job(s) from 'running' to 'failed'",
|
||||
r.rows_affected()
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::error!("startup recovery: failed to sweep export_job: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a background task that periodically:
|
||||
/// - deletes session rows whose `expires_at` is more than a day in the past
|
||||
/// - prunes the in-memory rate-limiter HashMap of empty windows
|
||||
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
|
||||
///
|
||||
/// Cadence is 1h — fine for both jobs at our scale.
|
||||
pub fn spawn_periodic_tasks(
|
||||
pool: PgPool,
|
||||
rate_limiter: RateLimiter,
|
||||
sse_tickets: SseTicketStore,
|
||||
media_path: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||
// Fire the first tick immediately, then hourly.
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
cleanup_sessions(&pool).await;
|
||||
rate_limiter.prune();
|
||||
sse_tickets.prune();
|
||||
// Reclaim disk for uploads soft-deleted more than the grace period
|
||||
// ago, and hard-delete those rows (FKs cascade).
|
||||
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn cleanup_sessions(pool: &PgPool) {
|
||||
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.rows_affected() > 0 => {
|
||||
tracing::info!("cleaned up {} expired session(s)", r.rows_affected());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!("session cleanup failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
83
backend/src/services/media_fs.rs
Normal file
83
backend/src/services/media_fs.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! Filesystem lifecycle for media artifacts.
|
||||
//!
|
||||
//! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on
|
||||
//! a fixed-size disk until something removes them. This module is that
|
||||
//! something: best-effort unlinking on delete, plus a periodic reaper that
|
||||
//! sweeps files belonging to soft-deleted rows (catching anything an in-process
|
||||
//! unlink missed — e.g. a crash between the DB commit and the unlink).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::models::upload::DeletedPaths;
|
||||
|
||||
/// Best-effort removal of an upload's three on-disk artifacts. A missing file is
|
||||
/// not an error (it may already be gone, or never existed for videos without a
|
||||
/// preview); anything else is logged but never propagated — losing the bytes
|
||||
/// must not fail the user's delete.
|
||||
pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) {
|
||||
let candidates = [
|
||||
Some(&paths.original),
|
||||
paths.preview.as_ref(),
|
||||
paths.thumbnail.as_ref(),
|
||||
];
|
||||
for rel in candidates.into_iter().flatten() {
|
||||
let abs = media_path.join(rel);
|
||||
match tokio::fs::remove_file(&abs).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaper: remove on-disk files for uploads that were soft-deleted more than
|
||||
/// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly
|
||||
/// DB-row-driven — it never walks the filesystem, so it can never remove a file
|
||||
/// belonging to a live upload.
|
||||
pub async fn reap_deleted(pool: &PgPool, media_path: &Path) {
|
||||
// Snapshot the exact rows (id + paths) we are about to unlink, and delete by
|
||||
// those ids — NOT by re-evaluating the `deleted_at < now - 1 day` predicate.
|
||||
// A row that crosses the 1-day line *during* the unlink loop would otherwise
|
||||
// be hard-deleted by the second predicate without ever having its files
|
||||
// unlinked → a permanent orphan.
|
||||
let rows: Vec<(uuid::Uuid, String, Option<String>, Option<String>)> = match sqlx::query_as(
|
||||
"SELECT id, original_path, preview_path, thumbnail_path
|
||||
FROM upload
|
||||
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "media reaper: query failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut ids = Vec::with_capacity(rows.len());
|
||||
for (id, original, preview, thumbnail) in &rows {
|
||||
unlink_media(media_path, &DeletedPaths {
|
||||
original: original.clone(),
|
||||
preview: preview.clone(),
|
||||
thumbnail: thumbnail.clone(),
|
||||
})
|
||||
.await;
|
||||
ids.push(*id);
|
||||
}
|
||||
|
||||
match sqlx::query("DELETE FROM upload WHERE id = ANY($1)")
|
||||
.bind(&ids)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()),
|
||||
Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"),
|
||||
}
|
||||
}
|
||||
122
backend/src/services/media_token.rs
Normal file
122
backend/src/services/media_token.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Stateless signed URLs for the authenticated media gateway.
|
||||
//!
|
||||
//! Media (`<img>`/`<video>` sources) cannot carry an `Authorization` header, so
|
||||
//! access is granted by an HMAC-SHA256 signature embedded in the URL. The
|
||||
//! feed/upload DTOs are serialized for an already-authenticated event member, so
|
||||
//! that is where fresh signatures are minted; the gateway handler verifies them
|
||||
//! without any DB/session state.
|
||||
//!
|
||||
//! HMAC is implemented over the in-tree `sha2` crate (no new dependency). The
|
||||
//! key is the app's `jwt_secret`.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Validity window of a signed URL.
|
||||
const TTL_SECS: i64 = 24 * 3600;
|
||||
/// Issue-time bucket. Expiry (and therefore the URL) is stable within this
|
||||
/// window so the browser caches image bytes across feed polls instead of
|
||||
/// re-downloading on every refresh.
|
||||
const BUCKET_SECS: i64 = 3600;
|
||||
|
||||
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
|
||||
const BLOCK: usize = 64;
|
||||
let mut key_block = [0u8; BLOCK];
|
||||
if key.len() > BLOCK {
|
||||
let digest = Sha256::digest(key);
|
||||
key_block[..32].copy_from_slice(&digest);
|
||||
} else {
|
||||
key_block[..key.len()].copy_from_slice(key);
|
||||
}
|
||||
|
||||
let mut ipad = [0x36u8; BLOCK];
|
||||
let mut opad = [0x5cu8; BLOCK];
|
||||
for i in 0..BLOCK {
|
||||
ipad[i] ^= key_block[i];
|
||||
opad[i] ^= key_block[i];
|
||||
}
|
||||
|
||||
let mut inner = Sha256::new();
|
||||
inner.update(ipad);
|
||||
inner.update(msg);
|
||||
let inner_digest = inner.finalize();
|
||||
|
||||
let mut outer = Sha256::new();
|
||||
outer.update(opad);
|
||||
outer.update(inner_digest);
|
||||
outer.finalize().into()
|
||||
}
|
||||
|
||||
fn sign(secret: &str, kind: &str, id: Uuid, exp: i64) -> String {
|
||||
let msg = format!("{kind}:{id}:{exp}");
|
||||
let mac = hmac_sha256(secret.as_bytes(), msg.as_bytes());
|
||||
let mut hex = String::with_capacity(64);
|
||||
for b in mac {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// Build a signed, time-boxed gateway URL for one of an upload's artifacts.
|
||||
/// `kind` is `original` | `preview` | `thumbnail`.
|
||||
pub fn signed_url(secret: &str, kind: &str, id: Uuid, now: i64) -> String {
|
||||
let exp = ((now / BUCKET_SECS) * BUCKET_SECS) + TTL_SECS;
|
||||
let sig = sign(secret, kind, id, exp);
|
||||
format!("/media/{kind}/{id}?exp={exp}&sig={sig}")
|
||||
}
|
||||
|
||||
/// Verify a signed URL: signature must match and the expiry must not have
|
||||
/// passed. Signature comparison is constant-time.
|
||||
pub fn verify(secret: &str, kind: &str, id: Uuid, exp: i64, sig: &str, now: i64) -> bool {
|
||||
if exp < now {
|
||||
return false;
|
||||
}
|
||||
let expected = sign(secret, kind, id, exp);
|
||||
constant_time_eq(expected.as_bytes(), sig.as_bytes())
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_verify_roundtrip() {
|
||||
let secret = "test_secret_at_least_32_chars_long_xxxx";
|
||||
let id = Uuid::new_v4();
|
||||
let now = 1_700_000_000;
|
||||
let url = signed_url(secret, "original", id, now);
|
||||
// Extract exp + sig from the query string.
|
||||
let (_, query) = url.split_once('?').unwrap();
|
||||
let mut exp = 0i64;
|
||||
let mut sig = String::new();
|
||||
for pair in query.split('&') {
|
||||
let (k, v) = pair.split_once('=').unwrap();
|
||||
match k {
|
||||
"exp" => exp = v.parse().unwrap(),
|
||||
"sig" => sig = v.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(verify(secret, "original", id, exp, &sig, now));
|
||||
// Tampered kind / id / sig must fail.
|
||||
assert!(!verify(secret, "preview", id, exp, &sig, now));
|
||||
assert!(!verify(secret, "original", Uuid::new_v4(), exp, &sig, now));
|
||||
assert!(!verify(secret, "original", id, exp, "deadbeef", now));
|
||||
// Wrong secret must fail.
|
||||
assert!(!verify("other_secret_at_least_32_chars_long_yy", "original", id, exp, &sig, now));
|
||||
// Expired must fail.
|
||||
assert!(!verify(secret, "original", id, exp, &sig, exp + 1));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
pub mod compression;
|
||||
pub mod config;
|
||||
pub mod export;
|
||||
pub mod jobs;
|
||||
pub mod maintenance;
|
||||
pub mod media_fs;
|
||||
pub mod media_token;
|
||||
pub mod password;
|
||||
pub mod rate_limiter;
|
||||
pub mod sse_tickets;
|
||||
|
||||
25
backend/src/services/password.rs
Normal file
25
backend/src/services/password.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! bcrypt hashing/verification offloaded to the blocking pool.
|
||||
//!
|
||||
//! bcrypt cost-12 is ~250–400ms of synchronous CPU. Called directly in an async
|
||||
//! handler it pins a Tokio worker for that whole time, so a handful of
|
||||
//! concurrent joins/recovers/admin-logins can starve every other request
|
||||
//! (feed, SSE, upload). Routing the work through `spawn_blocking` keeps the
|
||||
//! async reactor responsive.
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Hash a secret with the given bcrypt cost, off the async reactor.
|
||||
pub async fn hash(plain: String, cost: u32) -> Result<String, AppError> {
|
||||
tokio::task::spawn_blocking(move || bcrypt::hash(&plain, cost))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||
}
|
||||
|
||||
/// Verify a secret against a bcrypt hash, off the async reactor. Returns `false`
|
||||
/// on any error (mirrors the previous `unwrap_or(false)` fail-closed behavior).
|
||||
pub async fn verify(plain: String, hash: String) -> bool {
|
||||
tokio::task::spawn_blocking(move || bcrypt::verify(&plain, &hash).unwrap_or(false))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -41,15 +41,85 @@ impl RateLimiter {
|
||||
Err(remaining.as_secs().max(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
|
||||
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
|
||||
pub fn clear(&self) {
|
||||
self.windows.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Drop keys whose windows are empty after expiring old timestamps. Called from a
|
||||
/// background task (see [`crate::services::maintenance`]) so that long-lived
|
||||
/// processes don't accumulate one HashMap entry per IP that ever connected.
|
||||
///
|
||||
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
|
||||
/// of which endpoint's window it was tracked under (the longest window today is
|
||||
/// 24h for export downloads). If we ever add longer windows, raise this constant.
|
||||
pub fn prune(&self) {
|
||||
let now = Instant::now();
|
||||
let ceiling = Duration::from_secs(24 * 60 * 60);
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let before = map.len();
|
||||
map.retain(|_, ts| {
|
||||
ts.retain(|&t| now.duration_since(t) < ceiling);
|
||||
!ts.is_empty()
|
||||
});
|
||||
let dropped = before.saturating_sub(map.len());
|
||||
if dropped > 0 {
|
||||
tracing::debug!("rate limiter pruned {dropped} idle keys");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
|
||||
/// to a provided socket address string.
|
||||
/// Extract the client IP for rate-limit keys.
|
||||
///
|
||||
/// Prefers `X-Real-IP`, which our reverse proxy (Caddy) overwrites with the real
|
||||
/// TCP peer (`header_up X-Real-IP {remote_host}`) — a client cannot spoof it.
|
||||
/// Falls back to the **rightmost** `X-Forwarded-For` token (the entry the proxy
|
||||
/// appended), never the leftmost: the leftmost is fully client-controlled, and
|
||||
/// trusting it let an attacker rotate the key to bypass the join cap, the
|
||||
/// recover throttle, and the admin-login floor.
|
||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||
if let Some(ip) = headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return ip.to_owned();
|
||||
}
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.split(',').next_back())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::client_ip;
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
#[test]
|
||||
fn prefers_x_real_ip() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-real-ip", "9.9.9.9".parse().unwrap());
|
||||
h.insert("x-forwarded-for", "1.1.1.1, 9.9.9.9".parse().unwrap());
|
||||
assert_eq!(client_ip(&h, "fb"), "9.9.9.9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xff_uses_rightmost_not_spoofable_leftmost() {
|
||||
// Attacker prepends a forged entry; the proxy-appended real peer is last.
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-forwarded-for", "1.2.3.4, 203.0.113.7".parse().unwrap());
|
||||
assert_eq!(client_ip(&h, "fb"), "203.0.113.7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_no_headers() {
|
||||
assert_eq!(client_ip(&HeaderMap::new(), "fb"), "fb");
|
||||
}
|
||||
}
|
||||
|
||||
74
backend/src/services/sse_tickets.rs
Normal file
74
backend/src/services/sse_tickets.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
/// Short-lived single-use tickets that let `EventSource` clients open the SSE
|
||||
/// stream without putting the JWT in the URL (where it would leak via access
|
||||
/// logs / referer / browser history).
|
||||
///
|
||||
/// Flow: client `POST /api/v1/stream/ticket` with `Authorization: Bearer <jwt>`,
|
||||
/// server returns an opaque ticket, client passes it as `?ticket=...` on the
|
||||
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
||||
const TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SseTicketStore {
|
||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry {
|
||||
token_hash: String,
|
||||
issued_at: Instant,
|
||||
}
|
||||
|
||||
impl SseTicketStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
||||
pub fn issue(&self, token_hash: String) -> String {
|
||||
let ticket = random_ticket();
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
map.insert(
|
||||
ticket.clone(),
|
||||
Entry {
|
||||
token_hash,
|
||||
issued_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
ticket
|
||||
}
|
||||
|
||||
/// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is
|
||||
/// not expired. Single-use: the ticket is removed regardless of whether it
|
||||
/// was still fresh, so a replay can't slip through after expiry.
|
||||
pub fn consume(&self, ticket: &str) -> Option<String> {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
let entry = map.remove(ticket)?;
|
||||
if entry.issued_at.elapsed() > TTL {
|
||||
return None;
|
||||
}
|
||||
Some(entry.token_hash)
|
||||
}
|
||||
|
||||
/// Drop expired entries — called from the background maintenance task so a
|
||||
/// long-running process doesn't accumulate stale tickets.
|
||||
pub fn prune(&self) {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
|
||||
}
|
||||
}
|
||||
|
||||
fn random_ticket() -> String {
|
||||
// 192 bits of randomness, base32-ish hex. Plenty of entropy and URL-safe.
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 24];
|
||||
rng.fill(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{broadcast, Semaphore};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::services::compression::CompressionWorker;
|
||||
use crate::services::rate_limiter::RateLimiter;
|
||||
use crate::services::sse_tickets::SseTicketStore;
|
||||
|
||||
/// Max concurrent in-flight `/upload` requests. Each holds its whole file in
|
||||
/// memory while reading the multipart body, so this bounds aggregate upload RAM
|
||||
/// to ~N × the 550 MB body cap (≈2.2 GB here) — keeping headroom for Postgres +
|
||||
/// the app on an 8 GB box. The per-user rate limiter is a *count* limiter
|
||||
/// (10/hr), not a concurrency cap, so this is the actual OOM guard. Tunable.
|
||||
pub const UPLOAD_MAX_CONCURRENCY: usize = 4;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SseEvent {
|
||||
@@ -11,6 +21,17 @@ pub struct SseEvent {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
impl SseEvent {
|
||||
/// Standardised constructor. Prefer this over building the struct inline so the
|
||||
/// event-type strings stay consistent across handlers.
|
||||
pub fn new(event_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
Self {
|
||||
event_type: event_type.into(),
|
||||
data: data.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
@@ -18,19 +39,30 @@ pub struct AppState {
|
||||
pub sse_tx: broadcast::Sender<SseEvent>,
|
||||
pub compression: CompressionWorker,
|
||||
pub rate_limiter: RateLimiter,
|
||||
pub sse_tickets: SseTicketStore,
|
||||
/// Caps concurrent upload-body buffering (see UPLOAD_MAX_CONCURRENCY).
|
||||
pub upload_limiter: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: PgPool, config: AppConfig) -> Self {
|
||||
let (sse_tx, _) = broadcast::channel(256);
|
||||
let compression =
|
||||
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2);
|
||||
// Independent image (2) and video (2) permit pools — see CompressionWorker.
|
||||
let compression = CompressionWorker::new(
|
||||
pool.clone(),
|
||||
config.media_path.clone(),
|
||||
2,
|
||||
2,
|
||||
sse_tx.clone(),
|
||||
);
|
||||
Self {
|
||||
pool,
|
||||
config,
|
||||
sse_tx,
|
||||
compression,
|
||||
rate_limiter: RateLimiter::new(),
|
||||
sse_tickets: SseTicketStore::new(),
|
||||
upload_limiter: Arc::new(Semaphore::new(UPLOAD_MAX_CONCURRENCY)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};
|
||||
@@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/Dy1jDy4J.js";export{o as load_css,r as start};
|
||||
@@ -1 +1 @@
|
||||
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/Dy1jDy4J.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};
|
||||
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/eAGLaJx1.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};
|
||||
@@ -1 +1 @@
|
||||
{"version":"1775501323159"}
|
||||
{"version":"1778876725548"}
|
||||
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link href="/_app/immutable/entry/start.ctwmcI8C.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Dy1jDy4J.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.YjNZv4co.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/eAGLaJx1.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CcONa1Mr.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BJ__EZ0W.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.jfkZT8Zg.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BTH3knpg.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bb9JxzU7.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/RsTAN2PN.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bxn6SmGf.js" rel="modulepreload">
|
||||
@@ -18,15 +18,15 @@
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_ftrcoq = {
|
||||
__sveltekit_19z1hjw = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.ctwmcI8C.js"),
|
||||
import("/_app/immutable/entry/app.jfkZT8Zg.js")
|
||||
import("/_app/immutable/entry/start.YjNZv4co.js"),
|
||||
import("/_app/immutable/entry/app.BTH3knpg.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Auto-merged by `docker compose up`. Exposes Postgres for LOCAL tooling only —
|
||||
# bound to 127.0.0.1 so that even when this committed file lands on the prod host
|
||||
# the DB is NOT reachable on the public IP (a `0.0.0.0` publish would also bypass
|
||||
# ufw). Never widen this to 0.0.0.0.
|
||||
services:
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
@@ -21,6 +21,13 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
# Force production mode here (not via .env, which is easy to forget) so the
|
||||
# backend's JWT-secret guard actually fires on this deployment.
|
||||
environment:
|
||||
APP_ENV: production
|
||||
# Backstop the in-app upload RAM caps: if anything slips the per-request
|
||||
# bounds, the container is OOM-killed instead of the 8 GB host.
|
||||
mem_limit: 3g
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -28,6 +35,13 @@ services:
|
||||
- media_data:/media
|
||||
expose:
|
||||
- "3000"
|
||||
# /health is unauthenticated; start_period covers the boot-time migrations.
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -36,9 +50,16 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3001/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
@@ -49,9 +70,13 @@ services:
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
# Gate on readiness so Caddy doesn't proxy to a not-yet-listening upstream
|
||||
# (which otherwise shows brief 502s on boot/restart).
|
||||
depends_on:
|
||||
- app
|
||||
- frontend
|
||||
app:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
196
docs/CONCEPT_DIASHOW.md
Normal file
196
docs/CONCEPT_DIASHOW.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Live Diashow Concept
|
||||
|
||||
> **Status: SHIPPED.** Implementation lives at
|
||||
> [frontend/src/lib/diashow/](../frontend/src/lib/diashow/) and
|
||||
> [frontend/src/routes/diashow/+page.svelte](../frontend/src/routes/diashow/+page.svelte).
|
||||
> Treat this doc as the design reference; code is the source of truth.
|
||||
|
||||
## Goal
|
||||
|
||||
A fullscreen, auto-advancing slideshow that any user can start from their device. Suitable
|
||||
for a venue projector or TV running off a single phone/laptop. Two behaviours combine in one
|
||||
view:
|
||||
|
||||
1. **Live drain** — when a new post is uploaded mid-event, it appears on the next slide
|
||||
transition.
|
||||
2. **Shuffle fallback** — between new uploads (and they will be rare in quiet stretches), the
|
||||
diashow rotates through everything posted so far, in shuffled order.
|
||||
|
||||
The user does **not** need to be Host or Admin. Any guest can start the diashow on their own
|
||||
device. There is no global "the room's diashow" — each device runs its own (though they will
|
||||
look very similar if started at the same time).
|
||||
|
||||
---
|
||||
|
||||
## Behavioural model
|
||||
|
||||
Two FIFO queues live in the client:
|
||||
|
||||
| Queue | Source | Drain priority |
|
||||
|----------------|-------------------------------------------|----------------|
|
||||
| `liveQueue` | SSE events for new processed uploads | First |
|
||||
| `shuffleQueue` | Snapshot of all known uploads, shuffled | When live empty |
|
||||
|
||||
### Slide-advance algorithm
|
||||
|
||||
```ts
|
||||
function nextSlide(): Slide | null {
|
||||
// 1. Drain live posts first (FIFO).
|
||||
if (liveQueue.length) return liveQueue.shift()!;
|
||||
|
||||
// 2. Refill shuffle queue from `allKnown` if drained.
|
||||
if (!shuffleQueue.length) {
|
||||
shuffleQueue = shuffle(
|
||||
[...allKnown.values()].filter(s => !recentlyShown.has(s.id))
|
||||
);
|
||||
}
|
||||
return shuffleQueue.shift() ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
A small ring buffer `recentlyShown` (last ~5 IDs) prevents the same picture coming back
|
||||
within seconds when the shuffle queue is rebuilt.
|
||||
|
||||
### Live insertion
|
||||
|
||||
```ts
|
||||
sseClient.on('upload-processed', (msg) => {
|
||||
if (allKnown.has(msg.upload_id)) return;
|
||||
const slide = await fetchUpload(msg.upload_id); // or use payload directly
|
||||
allKnown.set(slide.id, slide);
|
||||
liveQueue.push(slide);
|
||||
});
|
||||
```
|
||||
|
||||
Listen on **`upload-processed`**, not `new-upload` — the preview/thumbnail must exist before
|
||||
we try to display the slide.
|
||||
|
||||
### Deletion / hiding
|
||||
|
||||
```ts
|
||||
sseClient.on('upload-deleted', ({ upload_id }) => {
|
||||
allKnown.delete(upload_id);
|
||||
liveQueue = liveQueue.filter(s => s.id !== upload_id);
|
||||
shuffleQueue = shuffleQueue.filter(s => s.id !== upload_id);
|
||||
if (currentSlide?.id === upload_id) advanceImmediately();
|
||||
});
|
||||
```
|
||||
|
||||
Hidden uploads (banned user with `uploads_hidden=true`) need either a new SSE event or to
|
||||
piggyback `upload-deleted` for diashow purposes. Simplest path: emit `upload-deleted` for
|
||||
hidden posts to all subscribers (the live feed already filters them via `v_feed`, so this is
|
||||
a backwards-compatible signal).
|
||||
|
||||
---
|
||||
|
||||
## Initial pool
|
||||
|
||||
On start:
|
||||
|
||||
1. Call `GET /api/v1/feed?limit=200` (or paginate-and-drain in the background while the
|
||||
diashow runs).
|
||||
2. Push every returned upload into `allKnown`.
|
||||
3. Build the first `shuffleQueue` from it.
|
||||
4. Open the SSE stream and route `upload-processed` / `upload-deleted` into the queues.
|
||||
|
||||
If the event is empty, show a friendly placeholder:
|
||||
*"Noch keine Beiträge — neue erscheinen automatisch."*
|
||||
|
||||
---
|
||||
|
||||
## Frontend surface
|
||||
|
||||
### Entry point
|
||||
|
||||
A small **Diashow / "Präsentation starten"** action visible:
|
||||
|
||||
- In the feed header (icon next to the list/grid toggle) on tablet/desktop layouts.
|
||||
- In the Account page on mobile (less prominent — diashow is primarily a venue-screen
|
||||
feature).
|
||||
|
||||
Tapping it navigates to the `/diashow` route (full-screen, bottom nav hidden).
|
||||
|
||||
### Route: `/diashow`
|
||||
|
||||
- Fullscreen request via `element.requestFullscreen()` after first user gesture.
|
||||
- **Screen Wake Lock**: `navigator.wakeLock.request('screen')` to keep the screen on during
|
||||
long shows. Renew on `visibilitychange` if needed.
|
||||
- Default dwell: **6 seconds** per slide. Configurable via overlay control: 3 / 6 / 10 s.
|
||||
- Tap or `Escape` reveals an overlay with: pause/resume, dwell selector, **transition
|
||||
picker**, exit.
|
||||
- Transitions: crossfade (≈400 ms) by default; Ken Burns, zoom, slide, etc. available as
|
||||
pluggable components — see "Pluggable transitions" below.
|
||||
- Videos: autoplay muted, fit-to-screen, advance on `ended` or after `max(dwell, 12 s)`,
|
||||
whichever first.
|
||||
- Preload the next slide's media into a hidden `<img>`/`<video>` to avoid flashing.
|
||||
- **Media source respects the user's data mode**
|
||||
(see [FEATURES.md §2.5](FEATURES.md)). In Saver mode the diashow loads `preview_url`;
|
||||
in Original mode it loads the original. The data-usage warning is shown once when the
|
||||
mode is toggled in My Account — the diashow itself stays silent.
|
||||
|
||||
### Pluggable transitions
|
||||
|
||||
Each transition is a **drop-in Svelte component** under
|
||||
`frontend/src/lib/diashow/transitions/` (path finalised at implementation time). The
|
||||
interface is intentionally tiny:
|
||||
|
||||
```ts
|
||||
// pseudocode — the real shape lands with the feature
|
||||
export interface SlideTransition {
|
||||
id: string; // 'crossfade', 'kenburns', ...
|
||||
label: string; // shown in the dwell/transition picker
|
||||
durationMs: number; // default; can be overridden per-event
|
||||
// The actual motion is implemented by mounting the component with `from` / `to` slides.
|
||||
}
|
||||
```
|
||||
|
||||
A small registry maps `id → component`; the settings popover renders that registry as a
|
||||
dropdown. **Adding a new animation is one new file plus one line in the registry — no
|
||||
other changes required.** This is the maintainability target called out in
|
||||
[FEATURES.md §2.9](FEATURES.md) and [IDEAS.md](IDEAS.md) ("Animation pack").
|
||||
|
||||
The same pattern is a candidate for whole-event "themes" later — a bundle of (transition
|
||||
+ dwell + optional background-music defaults).
|
||||
|
||||
### Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|--------------------------------------------|--------------------------------------------------------|
|
||||
| Empty event | Placeholder card; live SSE will trigger the first show |
|
||||
| All known uploads are still compressing | Same placeholder — wait for `upload-processed` |
|
||||
| Network drop / SSE reconnect | EventSource auto-reconnects; queues survive |
|
||||
| Current slide gets deleted | Advance immediately |
|
||||
| Event is closed (no new uploads possible) | Diashow keeps running on shuffle queue indefinitely |
|
||||
| Banned user's content (`uploads_hidden`) | Removed via `upload-deleted` signal (see Deletion) |
|
||||
|
||||
---
|
||||
|
||||
## Backend changes
|
||||
|
||||
**Essentially none.** The diashow reuses:
|
||||
|
||||
- `GET /api/v1/feed` (initial pool)
|
||||
- `GET /api/v1/stream` SSE (`upload-processed`, `upload-deleted`)
|
||||
|
||||
Optional small additions:
|
||||
|
||||
1. Emit `upload-deleted` (or a new `upload-hidden`) when a host bans a user with
|
||||
`hide_uploads=true`, so that diashow clients can scrub the relevant slides without
|
||||
reloading.
|
||||
2. Consider raising the cap on `GET /api/v1/feed?limit=` for diashow clients (or paginate
|
||||
the initial pool in the background — preferred, no API change needed).
|
||||
|
||||
---
|
||||
|
||||
## Future extensions (not in scope for v1)
|
||||
|
||||
The big ones live in [IDEAS.md](IDEAS.md) under "Diashow extensions" — most notably the
|
||||
**global / synchronised diashow** where multiple screens share one server-side cursor.
|
||||
Short list of others kept here for context:
|
||||
|
||||
- **Curated highlights mode** — only show uploads tagged with a specific hashtag, or
|
||||
Host-pinned "Story" uploads (depends on the story-highlights feature).
|
||||
- **Audio bed** — host can pick a background track; mute videos so they don't fight the
|
||||
music.
|
||||
- **Slide caption / uploader chyron** — small lower-third with the uploader's name and
|
||||
caption. Out by default to keep the visual clean.
|
||||
@@ -1,5 +1,15 @@
|
||||
# HTML Viewer Export Concept
|
||||
|
||||
> **Status: IMPLEMENTED.** Viewer source: [frontend/export-viewer/](../frontend/export-viewer/).
|
||||
> Pre-built output committed to [backend/static/export-viewer/](../backend/static/export-viewer/).
|
||||
> Backend export pipeline: [backend/src/services/export.rs](../backend/src/services/export.rs).
|
||||
>
|
||||
> Outstanding follow-ups:
|
||||
> - The export-viewer's `tailwind.config.js` does not yet extend the main app's config. Visual
|
||||
> drift risk — see "Shared Tailwind Config" section below.
|
||||
> - Service-worker (offline PWA caching) is still "Future" — fine for v1 since the ZIP is
|
||||
> already fully offline by virtue of relative paths.
|
||||
|
||||
## Overview
|
||||
|
||||
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
# Mobile-First UI/UX Redesign Concept
|
||||
|
||||
> **Status: IMPLEMENTED (v0.15).** This document captures the design intent. The redesign
|
||||
> has shipped — see [BottomNav.svelte](../frontend/src/lib/components/BottomNav.svelte),
|
||||
> [UploadSheet.svelte](../frontend/src/lib/components/UploadSheet.svelte),
|
||||
> [CameraCapture.svelte](../frontend/src/lib/components/CameraCapture.svelte),
|
||||
> [feed/+page.svelte](../frontend/src/routes/feed/+page.svelte),
|
||||
> [account/+page.svelte](../frontend/src/routes/account/+page.svelte),
|
||||
> [host/+page.svelte](../frontend/src/routes/host/+page.svelte),
|
||||
> [admin/+page.svelte](../frontend/src/routes/admin/+page.svelte). Use this doc as the design
|
||||
> reference; treat code as the source of truth for current behaviour.
|
||||
|
||||
## Overview
|
||||
|
||||
EventSnap is intended for mobile use at live events, but the current UI is desktop-oriented.
|
||||
This document describes a full mobile-first redesign covering navigation, the feed/gallery,
|
||||
account page, host dashboard, and admin dashboard.
|
||||
EventSnap is intended for mobile use at live events. This document describes the full
|
||||
mobile-first design covering navigation, the feed/gallery, account page, host dashboard,
|
||||
and admin dashboard.
|
||||
|
||||
---
|
||||
|
||||
@@ -406,6 +416,46 @@ Same structure as Host Nutzerverwaltung, with any additional admin-only actions
|
||||
|
||||
---
|
||||
|
||||
## Touch gestures vs. desktop buttons (planned extension)
|
||||
|
||||
Where a gesture is more ergonomic on mobile than a button, EventSnap prefers the gesture
|
||||
on touch and mirrors it as an explicit button on desktop. Inspired by Instagram, WhatsApp
|
||||
and Telegram — long-press for context, swipe to dismiss, double-tap to react.
|
||||
|
||||
| Surface | Touch gesture | Desktop equivalent |
|
||||
|-----------------------------------------|-------------------------------------|------------------------------------------|
|
||||
| Post card | Long-press → context bottom sheet | ⋯ kebab in the card corner |
|
||||
| Comment row | Long-press → bottom sheet | ⋯ next to the comment timestamp |
|
||||
| User row (Host / Admin dashboards) | Long-press → bottom sheet | Inline buttons (ban, promote, reset PIN) |
|
||||
| Lightbox | Swipe left / right | ←/→ arrow keys + on-screen chevrons |
|
||||
| Lightbox | Swipe down to close | Esc + ✕ button |
|
||||
| Bottom sheet | Swipe down to dismiss | Click backdrop or × in the sheet header |
|
||||
| Feed | Pull to refresh | Refresh icon next to the view toggle |
|
||||
| Post (any) | Double-tap → like | Click the heart icon |
|
||||
|
||||
**Discoverability rule:** every gesture must have a visible button equivalent on the same
|
||||
page. Gestures are never the *only* path to an action. Helps with stylus users,
|
||||
accessibility, and people who don't know the gesture vocabulary.
|
||||
|
||||
**Context bottom-sheet pattern** (used by every long-press above):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ▬ (drag handle) │
|
||||
│ │
|
||||
│ 🗑 Löschen │ ← destructive action red
|
||||
│ 📥 Original anzeigen │
|
||||
│ 🔗 Teilen │
|
||||
│ 🚩 Melden │ (only on others' content)
|
||||
│ │
|
||||
│ [ Abbrechen ] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each sheet is composed from a shared `<ContextSheet>` component (planned) with a single
|
||||
`actions: ContextAction[]` prop. Adding a new gesture context = define the actions array
|
||||
where needed. Drop-in, one file.
|
||||
|
||||
## Design Principles Summary
|
||||
|
||||
| Principle | Application |
|
||||
@@ -418,3 +468,4 @@ Same structure as Host Nutzerverwaltung, with any additional admin-only actions
|
||||
| No role clutter in nav | Role links only in Account, bar stays clean |
|
||||
| Collapsible sections | Long management pages stay usable on small phones |
|
||||
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
|
||||
| Gestures over chrome | Long-press for context menus, swipe to dismiss, double-tap to react — always with a button fallback for desktop and accessibility |
|
||||
|
||||
313
docs/FEATURES.md
Normal file
313
docs/FEATURES.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# EventSnap — Feature Set & Capability Matrix
|
||||
|
||||
This document is the authoritative, code-cross-checked summary of what EventSnap can do today
|
||||
and what is planned. For the design rationale of each area see [PROJECT.md](../PROJECT.md);
|
||||
for journeys / step-by-step flows see [USER_JOURNEYS.md](USER_JOURNEYS.md).
|
||||
|
||||
Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ out of scope**
|
||||
|
||||
---
|
||||
|
||||
## 1. Capability matrix by role
|
||||
|
||||
| Capability | Guest | Host | Admin | Notes |
|
||||
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
|
||||
| **Onboarding & sessions** | | | | |
|
||||
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
|
||||
| First-visit guided tour (4 steps) | ✓ | ✓ | ✓ | Dismissed once, flag in `localStorage` |
|
||||
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
|
||||
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
|
||||
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
|
||||
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
|
||||
| Log out / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
|
||||
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
|
||||
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
|
||||
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
|
||||
| Admin password login (separate route) | | | ✓ | 1-day token; lives in `sessionStorage` |
|
||||
| Reset another user's PIN (one-time display modal) | | ✓* | ✓ | Host: guests only. Admin: hosts + guests. New PIN shown once to the requester; user signs in with it; PIN is stored on their device on next login. \* Host cannot reset another Host's PIN |
|
||||
| | | | | |
|
||||
| **Posting** | | | | |
|
||||
| Pick photos/videos from device library (multi-select) | ✓ | ✓ | ✓ | Bottom-sheet source picker |
|
||||
| In-app camera capture (`getUserMedia`) | ✓ | ✓ | ✓ | Front/back toggle, photo, `MediaRecorder` video |
|
||||
| Caption + `#hashtag` extraction | ✓ | ✓ | ✓ | Optional; hashtags parsed server-side |
|
||||
| Edit own caption / hashtags after upload | ✓ | ✓ | ✓ | `PATCH /api/v1/upload/{id}` |
|
||||
| Delete own upload | ✓ | ✓ | ✓ | Long-press on the card (or the kebab menu on desktop) → **Löschen** in the context sheet. Comment-style trash icon also available on each post elsewhere as it's added. |
|
||||
| Delete own comment | ✓ | ✓ | ✓ | Trash icon in lightbox |
|
||||
| Background upload queue (survives reload) | ✓ | ✓ | ✓ | IndexedDB-persisted, sequential, retry |
|
||||
| Rate-limit auto-resume banner | ✓ | ✓ | ✓ | Countdown above bottom nav; resumes when window opens |
|
||||
| Chunked / resumable upload for > 100 MB | ◯ | ◯ | ◯ | Planned (v1.x) |
|
||||
| | | | | |
|
||||
| **Feed & social** | | | | |
|
||||
| Chronological list feed (full-width cards) | ✓ | ✓ | ✓ | Default view, infinite scroll |
|
||||
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
|
||||
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
|
||||
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
|
||||
| Fullscreen lightbox with swipe | ✓ | ✓ | ✓ | Swipe navigates the filtered set |
|
||||
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
|
||||
| Read comments on any post | ✓ | ✓ | ✓ | |
|
||||
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
|
||||
| Real-time feed via SSE | ✓ | ✓ | ✓ | `new-upload`, `new-comment`, `like-update`, `upload-processed`, `pin-reset`, `event-updated`, etc. |
|
||||
| Pause SSE when app is backgrounded | ✓ | ✓ | ✓ | Page Visibility API; reconnect on foreground |
|
||||
| Delta-fetch (`/feed/delta?since=`) on reconnect | ✓ | ✓ | ✓ | Runs on every visibility-restore; merges new + deleted uploads |
|
||||
| Individual file download button per post | ✓ | ✓ | ✓ | "Original anzeigen" in the post context sheet — streams via `/api/v1/upload/{id}/original` |
|
||||
| | | | | |
|
||||
| **Live diashow** (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) | | | | |
|
||||
| Start fullscreen auto-advancing slideshow | ✓ | ✓ | ✓ | Two queues: live (SSE) drains first, shuffle as fallback. Crossfade + Ken Burns transitions; pluggable. Respects data mode. |
|
||||
| | | | | |
|
||||
| **Moderation (Host)** | | | | |
|
||||
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
|
||||
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
|
||||
| Delete any upload | | ✓ | ✓ | |
|
||||
| Delete any comment | | ✓ | ✓ | |
|
||||
| Promote guest to Host | | ✓ | ✓ | |
|
||||
| Demote Host to guest | | ✓ | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
|
||||
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
|
||||
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
|
||||
| Unlock new uploads | | ✓ | ✓ | |
|
||||
| Release gallery → trigger export generation | | ✓ | ✓ | Enqueues both ZIP and HTML-viewer jobs |
|
||||
| | | | | |
|
||||
| **Instance configuration (Admin)** | | | | |
|
||||
| Live disk-usage / user / upload / banned stats | | | ✓ | Stats tab; queries `sysinfo` |
|
||||
| Edit per-file limits (image MB / video MB) | | | ✓ | Config tab; hot-reloadable from DB |
|
||||
| Edit per-endpoint rate limits | | | ✓ | Upload/hour, feed/min, export/day |
|
||||
| Toggle **all** rate limits on/off | | | ✓ | Master switch — when off, every limiter passes through |
|
||||
| Toggle **individual** rate limits on/off | | | ✓ | Per-endpoint switch (upload / feed / export / join) |
|
||||
| Toggle quota enforcement on/off (master + per-area) | | | ✓ | Master switch + per-area (storage / upload count). When off, nothing is enforced |
|
||||
| Edit quota tolerance | | | ✓ | Live `(free_disk × tolerance) / active_uploaders` formula enforced on upload |
|
||||
| Edit estimated guest count | | | ✓ | |
|
||||
| Edit compression-worker concurrency | | | ✓ | |
|
||||
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
|
||||
| Inspect export job list & progress | | | ✓ | |
|
||||
| Low-disk alert (< 10 GB free) | | | ◯ | Planned |
|
||||
| Event banner / cover image | | | ◯ | DB column exists, no UI |
|
||||
| | | | | |
|
||||
| **Quota visibility (Guest-facing)** | | | | |
|
||||
| Show current per-user quota estimate | ✓ | ✓ | ✓ | "Du hast X MB von Y MB genutzt." in My Account and on the upload screen. Computed from the live formula. Hidden when quota enforcement is toggled off |
|
||||
| | | | | |
|
||||
| **Export** | | | | |
|
||||
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
|
||||
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
|
||||
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
|
||||
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
|
||||
| Per-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
|
||||
| | | | | |
|
||||
| **Banned guest** (subset) | | | | |
|
||||
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
|
||||
| Can browse the feed | ✓ | | | |
|
||||
| Can still download the export once released | ✓ | | | Spec design choice |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature areas in detail
|
||||
|
||||
### 2.0 Touch-first interactions (mobile) vs. buttons (desktop)
|
||||
|
||||
EventSnap is mobile-first. Where it makes the UI cleaner, primary actions are reached via
|
||||
**gestures** on touch devices, with conventional **buttons** mirrored on tablet/desktop:
|
||||
|
||||
- **Long-press on a post** → context bottom sheet ("Löschen", "Original anzeigen", report,
|
||||
share). On desktop the same actions are a kebab/⋯ menu in the card's corner.
|
||||
- **Long-press on a comment** → context sheet with "Löschen" (own comments only) and
|
||||
"Kopieren".
|
||||
- **Swipe left/right in the lightbox** → navigate the filtered set.
|
||||
- **Swipe down on a bottom sheet** → dismiss.
|
||||
- **Pull-to-refresh on the feed** → force a delta-fetch even when SSE is up.
|
||||
- **Double-tap on a post** → like (Instagram-style), with a heart-burst animation. Tap the
|
||||
heart icon as the explicit alternative.
|
||||
|
||||
Design rule: **gestures should always have a discoverable button equivalent** somewhere on
|
||||
the page, so the app stays usable on a stylus, mouse, or for users who don't know the
|
||||
gesture vocabulary. Take inspiration from Instagram, WhatsApp, and Telegram for the
|
||||
"feels right" baseline — long-press for context, swipe to dismiss, double-tap to react.
|
||||
|
||||
### 2.1 Authentication and identity
|
||||
|
||||
EventSnap's identity model is "**a name + a 4-digit PIN, scoped to one event**". There is no
|
||||
email, no password, no account portal.
|
||||
|
||||
- **Joining.** On the join page the user types a display name. The server creates a `user`
|
||||
row, generates a 4-digit PIN, stores `bcrypt(pin)`, signs a 30-day JWT, and returns the
|
||||
PIN in clear text **once** in the response. The client persists the JWT and the PIN to
|
||||
`localStorage`.
|
||||
- **PIN visibility.** The PIN is shown to the user *prominently* once at registration, and
|
||||
remains visible in the My-Account page (read directly from `localStorage` — never sent
|
||||
back from the server).
|
||||
- **Returning on the same device.** A valid JWT in `localStorage` → straight to the feed.
|
||||
- **Returning on a new device.** Type the name on the join page → server detects the
|
||||
existing user → user is prompted for their PIN. `bcrypt.verify` → new JWT, fresh device
|
||||
is now bound to the same account.
|
||||
- **Lockout.** 3 wrong PIN attempts → 15-minute lockout per user (`pin_locked_until` column,
|
||||
migration 006).
|
||||
- **Name collisions.** Names are unique per event (case-insensitive, migration 007). If
|
||||
someone tries to join with a name already taken, the join page automatically presents the
|
||||
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
|
||||
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
|
||||
link routing to `/recover` for users who already know they want to sign in.
|
||||
- **PIN reset by Host / Admin.** Planned. If a guest loses their PIN and `localStorage` is
|
||||
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
|
||||
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
|
||||
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
|
||||
operator. The operator shows / sends the new PIN to the user, who then signs in via
|
||||
`/recover` — the PIN is persisted to `localStorage` on that device on a successful
|
||||
recovery, exactly like a brand-new join. Host cannot reset another Host's PIN; only
|
||||
Admins can.
|
||||
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
|
||||
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
|
||||
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by an admin. **Hosts
|
||||
may also demote other Hosts to guests** (planned) — but never themselves, to avoid
|
||||
locking the event out of moderation. Admins can demote anyone except admins.
|
||||
|
||||
### 2.2 Posting pipeline
|
||||
|
||||
The upload pipeline is built for flaky mobile networks:
|
||||
|
||||
1. **Source picker** (bottom sheet from the FAB): camera or gallery.
|
||||
2. **Preview screen** — staged files appear as thumbnails; user can remove individuals, add
|
||||
a caption (with `#hashtags`), and tap quick-tag chips derived from the caption.
|
||||
3. **Submit** — the client immediately returns to the feed (optimistic UX). Files enter an
|
||||
IndexedDB-persisted queue.
|
||||
4. **Queue worker** — runs sequentially (one upload at a time), per-file progress via XHR.
|
||||
Survives reloads and app backgrounding. A red badge on the FAB indicates active uploads.
|
||||
5. **Server-side processing** — multipart received → MIME-sniffed via `infer` → size
|
||||
validated → original stored → compression worker (bounded by a `tokio::sync::Semaphore`)
|
||||
resizes to an 800-px preview (images via the `image` crate + `oxipng` for PNG) or
|
||||
extracts a frame at the 1-second mark (videos via `ffmpeg`). Status is tracked in the new
|
||||
`compression_status` column (migration 008).
|
||||
6. **Real-time fan-out** — `new-upload` SSE first (no preview yet), then `upload-processed`
|
||||
when the preview/thumbnail is ready, so clients can swap a placeholder for the real image
|
||||
without re-fetching the feed.
|
||||
7. **Rate-limit-aware client** — when the server returns HTTP 429 with `Retry-After`, the
|
||||
queue parks remaining items and shows an inline countdown banner; uploads resume
|
||||
automatically.
|
||||
|
||||
### 2.3 Feed
|
||||
|
||||
- **Two layouts** — chronological list (default) and 3-column grid. Toggle in the header.
|
||||
- **List view** has no search; it's the consumption-focused mode (like an Instagram feed).
|
||||
- **Grid view** has the search bar — autocomplete suggestions are computed in-memory from
|
||||
the loaded uploads, so typing never hits the server.
|
||||
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
|
||||
hashtag + uploader combine with AND. Matches the redesign concept exactly.
|
||||
- **Lightbox** — fullscreen view, swipe navigates the *filtered* set, with embedded
|
||||
like/comment UI.
|
||||
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
|
||||
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
|
||||
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
|
||||
|
||||
### 2.4 Host / Admin tooling
|
||||
|
||||
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
|
||||
Nutzerverwaltung. Ban modal asks explicitly whether to hide the user's existing uploads
|
||||
from the public feed. Promote/demote, lock/unlock, release-gallery are one-tap.
|
||||
- **Admin dashboard** — same dashboard plus three more inner tabs (Stats, Config, Export,
|
||||
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
|
||||
guest count, and compression concurrency — all stored in the `config` table and read on
|
||||
each request, so changes take effect without a restart. Disk widget pulls from the
|
||||
`sysinfo` crate live.
|
||||
|
||||
### 2.5 Data mode (planned)
|
||||
|
||||
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
|
||||
guest can be on Saver on their phone and Original on their laptop.
|
||||
|
||||
| Mode | Default? | Feed loads... | Lightbox / diashow loads... | Warning shown? |
|
||||
|------------|:--------:|-----------------------------|------------------------------|:-------------:|
|
||||
| Datensparer (Saver) | ✓ | preview (compressed) | preview | no |
|
||||
| Original | | original | original | yes — "kann mobile Datennutzung erhöhen" once on enable |
|
||||
|
||||
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
|
||||
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
|
||||
|
||||
### 2.6 Rate limits and quotas — toggleable (planned)
|
||||
|
||||
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
|
||||
|
||||
- **Master switch — all rate limits.** When off, every limiter middleware short-circuits to
|
||||
pass-through. Useful for testing or trusted internal events.
|
||||
- **Per-endpoint switches.** Upload / feed / export / join each have their own toggle. The
|
||||
numeric input becomes informational while the toggle is off.
|
||||
- **Master switch — quotas.** When off, no quota check ever runs.
|
||||
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
|
||||
independently.
|
||||
|
||||
When a feature is toggled off, the relevant UI in the guest-facing app should adapt: e.g.
|
||||
the "Du hast X von Y MB genutzt" widget hides itself when storage quota is disabled. The
|
||||
quota estimate is computed from the same formula the server uses
|
||||
(`(free_disk × tolerance) / max(active_uploaders, 1)`) — surfaced in My Account *and* on
|
||||
the upload preview screen so guests know before they pick files.
|
||||
|
||||
### 2.7 Privacy note (Datenschutzhinweis, planned)
|
||||
|
||||
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
|
||||
Config). It's stored as a single config key (plain text, whitespace and newlines
|
||||
preserved, no HTML). Guests see it in their **My Account** page, rendered inside a
|
||||
preformatted block — no parsing, no markdown, just exactly what the admin typed. The
|
||||
first-visit onboarding guide gains a one-line nudge: *"Datenschutzhinweis findest du in
|
||||
deinem Account."*
|
||||
|
||||
Rationale: many real events (in Germany especially) need a per-event privacy statement
|
||||
without the operator wanting to ship a separate static page or rebuild the app.
|
||||
|
||||
### 2.8 Export
|
||||
|
||||
Two artifacts, both generated on demand after the host taps "Release gallery":
|
||||
|
||||
- **Gallery.zip** — full-quality originals only, structured into `Photos/` and `Videos/`,
|
||||
filenames `{date}_{time}_{username}_{id}.{ext}`, streamed via `async-zip` with no full
|
||||
archive in memory.
|
||||
- **Memories.zip** — the offline HTML viewer. Pre-built SvelteKit-static app from
|
||||
[frontend/export-viewer/](../frontend/export-viewer/), bundled with a generated
|
||||
`data.json` snapshot and a `media/` folder of thumbnails + full-size variants. Open
|
||||
`index.html` in any browser — no server required, no internet required. List/grid views,
|
||||
lightbox, hashtag chips, like counts, comments — all visually matched to the live app.
|
||||
|
||||
The export page shows live progress (SSE) while jobs run, then becomes a download button
|
||||
when complete.
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Maintainability and extensibility
|
||||
|
||||
EventSnap is small enough to be a single-developer project; it should stay easy to extend.
|
||||
A few principles to keep adding features cheap:
|
||||
|
||||
- **Diashow transitions are drop-in components.** Each animation implements a small
|
||||
interface and lives under `frontend/src/lib/diashow/transitions/`. Adding a transition is
|
||||
one file + one entry in a registry.
|
||||
- **Feature toggles live in the `config` table.** Today's rate-limit and quota switches
|
||||
follow the same pattern any new opt-in feature would use — no redeploy to flip
|
||||
behaviour.
|
||||
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
|
||||
diashow state — composable rather than copy-pasted into each route.
|
||||
- **Migrations are append-only.** Never edit a shipped migration; always add a new pair.
|
||||
- **Background jobs share one pipeline.** Export and compression already publish progress
|
||||
via the `export_job` row + SSE; future long-running work (analytics, archival) should
|
||||
plug into the same shape.
|
||||
|
||||
See [IDEAS.md](IDEAS.md) for a longer riff on these patterns.
|
||||
|
||||
## 3. Out of scope (intentionally not built)
|
||||
|
||||
These are explicit non-goals from [PROJECT.md §4](../PROJECT.md):
|
||||
|
||||
- Native iOS / Android apps
|
||||
- Multiple simultaneous events (multi-tenancy)
|
||||
- Email-based auth / password reset
|
||||
- Push notifications
|
||||
- User-to-user direct messaging
|
||||
- Payment / monetisation
|
||||
- CI/CD pipeline
|
||||
- "Save to camera roll" automation on iOS/Android — guests download the ZIP and use their
|
||||
platform file manager
|
||||
|
||||
---
|
||||
|
||||
## 4. See also
|
||||
|
||||
- [USER_JOURNEYS.md](USER_JOURNEYS.md) — step-by-step flows for every supported scenario.
|
||||
- [CONCEPT_MOBILE_UI.md](CONCEPT_MOBILE_UI.md) — design reference for the mobile layout.
|
||||
- [CONCEPT_HTML_VIEWER.md](CONCEPT_HTML_VIEWER.md) — export-viewer design.
|
||||
- [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md) — planned diashow design.
|
||||
- [IDEAS.md](IDEAS.md) — speculative extensions (global diashow, reactions, multi-tenancy, ...).
|
||||
- [PROJECT.md](../PROJECT.md) — full architectural blueprint and rationale.
|
||||
- [TEST_GUIDE.md](../TEST_GUIDE.md) — manual smoke-test script for the main flows.
|
||||
199
docs/IDEAS.md
Normal file
199
docs/IDEAS.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# EventSnap — Ideas & Future Extensions
|
||||
|
||||
A dumping ground for design ideas that are **not yet on the roadmap**. Everything here is a
|
||||
v2+ candidate, brainstormed once the core experience is stable. For shipped or actively
|
||||
planned scope see [FEATURES.md](FEATURES.md) and the `CONCEPT_*.md` design docs.
|
||||
|
||||
The bar to land here is low: "would be cool one day" qualifies. The bar to graduate to a
|
||||
`CONCEPT_*.md` is much higher (design committed, ready to build).
|
||||
|
||||
---
|
||||
|
||||
## Diashow extensions
|
||||
|
||||
### Global / synchronised diashow
|
||||
|
||||
Multiple devices show **the same slide at the same time** (e.g. a projector in the main
|
||||
hall plus tablets behind the bar plus a screen by the photo booth).
|
||||
|
||||
Sketch:
|
||||
- Server holds a single authoritative "current slide" cursor for the event.
|
||||
- New SSE event `diashow-tick` broadcasts `{ slide_id, started_at, next_at }`.
|
||||
- Each subscribed client renders locally — server only chooses ordering and pace.
|
||||
- Live-queue / shuffle-queue logic (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) lives
|
||||
server-side instead of client-side.
|
||||
- A "leader device" can claim the diashow, or the server runs it headlessly. Host UI lets
|
||||
Host start / stop the global diashow.
|
||||
- Plays well with venues that already have multiple displays — no need for HDMI splitters
|
||||
or chromecast hacks.
|
||||
|
||||
### Audio bed
|
||||
|
||||
- Host uploads or selects a background track (per-event).
|
||||
- Videos in the diashow auto-mute so they don't fight the music.
|
||||
- Optional ducking when a video has speech.
|
||||
|
||||
### Curated diashow mode
|
||||
|
||||
- Diashow filtered by a hashtag (`#highlights`) or to a Host-pinned set ("Story" feature).
|
||||
- Useful for the end-of-evening recap reel.
|
||||
|
||||
### Animation pack
|
||||
|
||||
- More transitions out of the box: zoom, slide, mosaic, dip-to-black, push.
|
||||
- Per-event "theme" preset — wedding-elegant, party-energetic, minimal, gallery-classic.
|
||||
- Builds on the maintainability principle below: each transition is a drop-in Svelte
|
||||
component, so growing the pack is trivial.
|
||||
|
||||
### Lower-third metadata
|
||||
|
||||
- Subtle chyron at the bottom of each slide: uploader name + timestamp + caption.
|
||||
- Off by default; toggle in the diashow settings popover.
|
||||
|
||||
### Smart pacing
|
||||
|
||||
- Detect video duration and let videos play their full length (with a cap), pause stills
|
||||
for the remainder. Avoids choppy 6-second cuts on a clip with key content at 0:08.
|
||||
- "Action density" heuristic — slow down for portraits, speed up for landscapes.
|
||||
|
||||
---
|
||||
|
||||
## Social
|
||||
|
||||
### Per-guest gallery
|
||||
|
||||
A first-class "All posts by Anna" view, navigable from a guest's avatar — not just a
|
||||
filter chip. Doubles as a personal "what did I post?" page.
|
||||
|
||||
### Story-style highlights
|
||||
|
||||
Host curates a best-of timeline pinned at the top of the feed (already in PROJECT.md's
|
||||
"Should Have"). Tap-through, fullscreen, ~5 s per story, like Instagram. Could double as
|
||||
the source for the curated-diashow mode above.
|
||||
|
||||
### Reactions beyond like
|
||||
|
||||
Multiple emoji reactions (❤️ 😂 😍 🎉 🥲) instead of just like. The DB design already keys
|
||||
the `like` table on `(upload_id, user_id)` — generalising to `(upload_id, user_id, kind)`
|
||||
is a small migration.
|
||||
|
||||
### Mentions and reply-threads in comments
|
||||
|
||||
- `@anna` in a comment becomes a tap-through to her profile / posts.
|
||||
- Threaded replies under each top-level comment.
|
||||
- Combined with PWA push, drives engagement.
|
||||
|
||||
### Collaborative captions
|
||||
|
||||
Co-authored captions when multiple uploaders are in the same photo — second tagger
|
||||
contributes their `#hashtags` to the same post.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
- PWA push for new comments on a guest's own posts.
|
||||
- Per-user opt-in; granular per-event preference (mute event, mute uploader, etc.).
|
||||
- Email digest after the event (1 message per guest) — optional, controversial vs. the
|
||||
"no email" identity model. Could be opt-in only.
|
||||
|
||||
---
|
||||
|
||||
## Capture & posting
|
||||
|
||||
- **Live-photo mode** — capture a 1–2 s clip alongside each still (Apple-style). Diashow
|
||||
could animate stills using the live clip as the Ken Burns source.
|
||||
- **Boomerang / GIF capture** — short looping clips.
|
||||
- **Client-side filters and stickers** — Instagram-style.
|
||||
- **Voice notes** attached to a photo — "first dance" voice memo + the photo.
|
||||
- **Bulk-upload presets** — pre-fill a caption for a batch ("Photos from the ceremony").
|
||||
|
||||
---
|
||||
|
||||
## Privacy & moderation
|
||||
|
||||
- **Per-post visibility** — "only visible to people with this hashtag" or
|
||||
"private to my friend group".
|
||||
- **Pre-moderation queue** — Host approves posts before they hit the public feed (default
|
||||
off; for sensitive events).
|
||||
- **Auto-blur** of detected faces of non-guests, or NSFW detection.
|
||||
- **Per-uploader watermark** on full-quality downloads.
|
||||
|
||||
---
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
- **Multiple events per instance** — picked by URL slug. Today the binary is single-event.
|
||||
- **Org accounts** — a wedding photographer running 4 weddings a month against the same
|
||||
deployment.
|
||||
- **Per-event admin** vs. **instance admin** roles.
|
||||
|
||||
---
|
||||
|
||||
## Internationalisation
|
||||
|
||||
- Localisation beyond German — English, French, Spanish, ...
|
||||
- Admin picks UI language during setup; per-user override.
|
||||
- Strings extracted into a small JSON catalogue — works well with `svelte-i18n` or similar.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
- **Year-in-pictures PDF** — host-curated layout, printable.
|
||||
- **ICS calendar attachment** of the event, included in the export ZIP.
|
||||
- **Direct upload to a guest's chosen cloud** (iCloud, Google Photos) — needs OAuth, adds
|
||||
a third-party integration where today there are none.
|
||||
|
||||
---
|
||||
|
||||
## Resilience / infrastructure
|
||||
|
||||
- **Distributed rate limiting** (Redis) for multi-instance / multi-event deploys.
|
||||
- **Object-storage backend** (S3 / MinIO) behind a feature flag — out of scope for the
|
||||
single-VPS use case but easy to add if multi-tenancy is ever pursued.
|
||||
- **Read replicas** for very large events.
|
||||
|
||||
---
|
||||
|
||||
## Maintainability principles to keep adding features cheap
|
||||
|
||||
The codebase is small today and should stay friendly to extension. A few patterns to lean
|
||||
into as the surface grows:
|
||||
|
||||
- **Diashow transitions as drop-in components.** Each transition implements a tiny
|
||||
interface (`enter`, `leave`, optional `duration`). Adding a new animation is one file in
|
||||
`frontend/src/lib/diashow/transitions/` and one line in a registry. Same idea for
|
||||
hashtag-filter operators.
|
||||
- **Per-feature toggle flags in the `config` table.** Today rate limits and quotas are
|
||||
individually toggleable (see [FEATURES.md](FEATURES.md)). The same pattern fits for any
|
||||
future opt-in feature — no need to redeploy to flip behaviour.
|
||||
- **Background-task trait on the server.** Export, compression, and (future) analytics
|
||||
jobs would all share a `BackgroundJob` interface that wires into the existing
|
||||
`export_job` progress + SSE pipeline. New long-running work plugs in by implementing the
|
||||
trait — no bespoke worker code per feature.
|
||||
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
|
||||
diashow state — each lives in its own store under `frontend/src/lib/`. New UI features
|
||||
consume the stores; cross-feature behaviour is composed, not copy-pasted.
|
||||
- **DTOs in one file** ([frontend/src/lib/types.ts](../frontend/src/lib/types.ts)),
|
||||
mirrored to the Rust DTOs. Changing a contract is exactly two edits.
|
||||
- **Migration-first schema evolution** — never edit an old migration; always add a new
|
||||
`0NN_*.up.sql` / `.down.sql` pair. Already the discipline; just keep it.
|
||||
|
||||
---
|
||||
|
||||
## Speculative / "would be cool"
|
||||
|
||||
Lower bar of plausibility — keep these around as conversation seeds:
|
||||
|
||||
- **AI-generated event summary** at release time (3-paragraph recap, key moments,
|
||||
funniest comment).
|
||||
- **AI auto-tagging** — suggested hashtags based on image content, opt-in per upload.
|
||||
- **Guest-of-honour mode** — special UI for the couple / birthday person showing
|
||||
*everything they're in*, prioritised by face detection.
|
||||
- **Live caption translation** for international weddings — auto-translate comments
|
||||
inline.
|
||||
- **Sound-reactive diashow** — slides advance in sync with music BPM picked up via the
|
||||
device mic.
|
||||
- **Photo-booth integration** — a fixed iPad at the venue posts to the feed with a single
|
||||
tap, no PIN.
|
||||
392
docs/MOBILE_TESTING_GUIDE.md
Normal file
392
docs/MOBILE_TESTING_GUIDE.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Mobile Testing Guide — EventSnap v0.15.0
|
||||
|
||||
## Setup
|
||||
|
||||
### Dev Servers
|
||||
|
||||
| Service | URL | Notes |
|
||||
|---------|-----|-------|
|
||||
| Frontend | `http://localhost:5173` | Vite dev server, hot-reload |
|
||||
| Backend API | `http://localhost:3000` | Rust/Axum |
|
||||
| Database | `localhost:5432` | PostgreSQL (Docker) |
|
||||
|
||||
The frontend dev server proxies `/api` and `/media` to the backend automatically.
|
||||
|
||||
**Mobile device access:** Connect your phone to the same Wi-Fi network.
|
||||
Find your machine's local IP (`ip a | grep 192.168` or `hostname -I`), then open
|
||||
`http://<your-ip>:5173` on your phone.
|
||||
|
||||
### Browser DevTools Mobile Emulation (quick testing without a phone)
|
||||
|
||||
1. Open Chrome → DevTools (`F12`) → Toggle device toolbar (`Ctrl+Shift+M`)
|
||||
2. Select **iPhone 14 Pro** or **Pixel 7** from the device dropdown
|
||||
3. Reload the page — safe-area insets and viewport are emulated
|
||||
4. To test touch gestures: enable "Touch" in the three-dot menu inside the device toolbar
|
||||
|
||||
---
|
||||
|
||||
## Test Accounts
|
||||
|
||||
Use the following to get all three roles:
|
||||
|
||||
| Role | How to get it |
|
||||
|------|--------------|
|
||||
| Guest | Join at `/join` with any name |
|
||||
| Host | Promote a guest via Host Dashboard, or set role in DB |
|
||||
| Admin | POST to `/api/v1/admin/login` or navigate to `/admin/login` |
|
||||
|
||||
Admin password: `admin123` (set in `.env`)
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — Bottom Navigation Bar
|
||||
|
||||
**Goal:** Verify the tab bar is present, thumb-accessible, and correct per role.
|
||||
|
||||
### 1.1 Bar Presence & Safe Area
|
||||
- [ ] Open `/feed` — a bottom tab bar with **Galerie**, a blue circle FAB, and **Konto** appears
|
||||
- [ ] On a real iPhone/Safari: the bar does **not** overlap the home indicator (safe-area padding)
|
||||
- [ ] On Chrome DevTools with an iPhone device: the bar is above the viewport bottom
|
||||
- [ ] Scroll down on a long feed — the bar stays **fixed** at the bottom at all times
|
||||
- [ ] The bar has a frosted-glass blur effect (`bg-white/90 backdrop-blur-md`)
|
||||
|
||||
### 1.2 Active Tab Indicator
|
||||
- [ ] On `/feed` — the Galerie icon is **blue**; Konto icon is gray
|
||||
- [ ] Tap **Konto** — navigates to `/account`; Konto icon turns blue, Galerie goes gray
|
||||
- [ ] Tap **Galerie** — navigates back to `/feed`
|
||||
|
||||
### 1.3 Role Gating
|
||||
- [ ] Log in as a **guest** — bar shows Galerie · FAB · Konto (3 items)
|
||||
- [ ] Log in as a **host** — same 3 items (dashboard links are inside Account, not the bar)
|
||||
- [ ] Log in as **admin** — same 3 items
|
||||
|
||||
### 1.4 Auth Pages Hide the Bar
|
||||
- [ ] Visit `/join` — **no** bottom bar
|
||||
- [ ] Visit `/recover` — **no** bottom bar
|
||||
- [ ] Visit `/admin/login` — **no** bottom bar
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — Upload FAB & Bottom Sheet
|
||||
|
||||
**Goal:** Verify the FAB opens the upload sheet and both source options work.
|
||||
|
||||
### 2.1 FAB Appearance
|
||||
- [ ] The FAB is a blue circle elevated ~12 px above the tab bar
|
||||
- [ ] A camera icon with an implicit "+" meaning is shown
|
||||
- [ ] When uploads are in the queue (pending or uploading), a **red badge number** appears on the FAB
|
||||
|
||||
### 2.2 Sheet Opens & Closes
|
||||
- [ ] Tap the FAB — a bottom sheet slides up smoothly (~300 ms) from below
|
||||
- [ ] Sheet shows: **Galerie** (blue icon), **Kamera** (purple icon), **Abbrechen**
|
||||
- [ ] Tap the gray backdrop — sheet slides **back down** and closes
|
||||
- [ ] Tap **Abbrechen** — sheet closes
|
||||
- [ ] Swipe the drag handle downward — sheet closes *(if touch gestures are enabled)*
|
||||
|
||||
### 2.3 Gallery Source
|
||||
- [ ] Tap **Galerie** — the native file picker opens
|
||||
- [ ] Select 1–3 images or videos
|
||||
- [ ] Sheet closes; you are navigated to `/upload` (the composer page)
|
||||
- [ ] Thumbnail strip at the top shows your selected files
|
||||
- [ ] **Bottom nav is gone** on this page (immersive full-screen)
|
||||
|
||||
### 2.4 Camera Source
|
||||
- [ ] Tap the FAB → **Kamera**
|
||||
- [ ] Browser asks for camera permission — grant it
|
||||
- [ ] Full-screen camera UI appears (existing CameraCapture component)
|
||||
- [ ] Take a photo
|
||||
- [ ] Camera closes; you are navigated to `/upload` with the captured image in the strip
|
||||
|
||||
### 2.5 Upload Composer Page
|
||||
- [ ] Back `×` button top-left → returns to `/feed`, clears pending files
|
||||
- [ ] Thumbnail strip scrolls horizontally when >3 files
|
||||
- [ ] Each thumbnail has a small `×` to remove it — tapping removes that file only
|
||||
- [ ] Caption `<textarea>` is **auto-focused** (keyboard opens on mobile)
|
||||
- [ ] Type `#party #spaß` in the caption — **quick-tag chips** appear below the textarea in real time
|
||||
- [ ] Quick-tag chips are read-only (they reflect what's already in the caption)
|
||||
- [ ] The **"Hochladen"** sticky button at the bottom shows the file count: "2 Dateien hochladen"
|
||||
- [ ] Button is **disabled** when the strip is empty
|
||||
- [ ] Also a smaller "Hochladen" button in the header (convenient on desktop/landscape)
|
||||
- [ ] Tap **Hochladen** — files are queued, you are returned to `/feed`
|
||||
- [ ] A **slim blue progress bar** appears just above the bottom tab bar while uploading
|
||||
- [ ] FAB shows a **red badge** during upload; badge disappears when done
|
||||
- [ ] A brief "Fertig" / completed state appears in the UploadQueue (check queue store)
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — Feed: List View
|
||||
|
||||
**Goal:** Verify the default chronological list view.
|
||||
|
||||
### 3.1 Default State
|
||||
- [ ] Open `/feed` — **list view** is active by default (≡ icon highlighted in the toggle)
|
||||
- [ ] Posts appear as full-width cards in reverse-chronological order (newest first)
|
||||
|
||||
### 3.2 List Card Anatomy
|
||||
For each card, verify:
|
||||
- [ ] **Avatar circle** with the uploader's initial letter and a deterministic color
|
||||
- [ ] **Display name** + **relative timestamp** ("vor 2 Min.", "vor 1 Std.", etc.)
|
||||
- [ ] **Media**: full-width image, or video with a play button overlay
|
||||
- [ ] **Caption** below the media (truncated to 3 lines with `...` if long)
|
||||
- [ ] **Like count** (❤️) and **Comment count** (💬) action buttons
|
||||
- [ ] Tapping the ❤️ toggles the like optimistically (count changes immediately)
|
||||
- [ ] Tapping 💬 or the media opens the **Lightbox Modal** (existing behavior, unchanged)
|
||||
|
||||
### 3.3 Hashtag Chips (List View Only)
|
||||
- [ ] Below the main header, hashtag filter chips are visible in **list view**
|
||||
- [ ] Tap a hashtag chip — feed re-fetches filtered by that tag
|
||||
- [ ] Tap **Alle** — returns to unfiltered feed
|
||||
- [ ] Chips are **not visible** when grid view is active
|
||||
|
||||
### 3.4 Infinite Scroll
|
||||
- [ ] Scroll to the bottom — more posts load automatically
|
||||
- [ ] A spinner appears briefly while loading more
|
||||
- [ ] Scroll sentinel triggers ~200 px before the actual bottom
|
||||
|
||||
### 3.5 Real-Time Updates (SSE)
|
||||
- [ ] Open the feed on two devices/tabs simultaneously
|
||||
- [ ] Upload a photo on one — it appears at the **top** of the other's list view in real time
|
||||
- [ ] Like a post on one — the count updates on the other
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Feed: Grid View & Search
|
||||
|
||||
**Goal:** Verify the 3-column grid, search bar, autocomplete, and filter chips.
|
||||
|
||||
### 4.1 Switching to Grid View
|
||||
- [ ] Tap the ⊞ grid icon in the header — view switches to a 3-column grid
|
||||
- [ ] The ≡/⊞ toggle shows ⊞ as active (white background, shadow)
|
||||
- [ ] Hashtag chips **disappear**; a **search bar** slides in below the header
|
||||
|
||||
### 4.2 Grid Layout
|
||||
- [ ] Grid is **3 columns** with equal square cells (no 2-column fallback on mobile)
|
||||
- [ ] Videos show a ▶ play button overlay
|
||||
- [ ] Tapping a cell opens the Lightbox Modal
|
||||
- [ ] Grid background is seamless (0.5px gap between cells)
|
||||
|
||||
### 4.3 Search Bar
|
||||
- [ ] Search bar shows: 🔍 icon, placeholder "Nutzer oder #Tag suchen…", × clear button
|
||||
- [ ] Tapping the bar focuses it and opens the keyboard
|
||||
- [ ] × button appears only when there is text in the input; tapping it clears the query
|
||||
|
||||
### 4.4 Autocomplete — On Focus (Empty)
|
||||
- [ ] Focus the search bar with no text — a dropdown appears with:
|
||||
- Up to 3 uploader names (person icon)
|
||||
- Up to 3 popular tags (#)
|
||||
- [ ] The dropdown disappears when the input loses focus (150 ms delay)
|
||||
|
||||
### 4.5 Autocomplete — Tag Suggestions
|
||||
- [ ] Type `#` — only **tag suggestions** appear (no users), sorted by frequency
|
||||
- [ ] Type `#par` — only tags starting with "par" remain (e.g. `#party`, `#parade`)
|
||||
- [ ] Tap a suggestion — it's added as a **blue filter chip** below the search bar; input clears
|
||||
|
||||
### 4.6 Autocomplete — User Suggestions
|
||||
- [ ] Type a partial name (e.g. `max`) — users matching "max" appear first, then tags containing "max"
|
||||
- [ ] Tap a user suggestion — chip added: shows the name without `#` prefix
|
||||
|
||||
### 4.7 Filter Chips
|
||||
- [ ] After selecting a tag filter — grid shows only posts with that tag in the caption
|
||||
- [ ] Select a second tag — grid shows posts with **either** tag (OR logic)
|
||||
- [ ] Select a user **and** a tag — grid shows posts by that user **that also** have that tag (AND across types)
|
||||
- [ ] Each chip has an **× remove button**; tapping it removes only that chip
|
||||
- [ ] When 2+ chips are active: **"Alle löschen"** link appears; tapping clears all filters
|
||||
- [ ] When no results match: "Keine Treffer für die gewählten Filter." + "Filter zurücksetzen" button
|
||||
|
||||
### 4.8 Switching Back to List View
|
||||
- [ ] Tap ≡ — list view returns; search bar gone; hashtag chips reappear
|
||||
- [ ] Active grid filters are **reset** when switching back to list (no stale state)
|
||||
|
||||
---
|
||||
|
||||
## Section 5 — Account Page
|
||||
|
||||
**Goal:** Verify the profile card, dashboard links, and leave-confirm flow.
|
||||
|
||||
### 5.1 Profile Card
|
||||
- [ ] Open `/account` via the Konto tab
|
||||
- [ ] **Avatar circle** shows your initial letter in a deterministic color
|
||||
- [ ] **Display name** and **role badge** (Gast / Gastgeber / Admin) shown
|
||||
- [ ] Session expiry date shown in small text below
|
||||
|
||||
### 5.2 Dashboard Links (Host/Admin Only)
|
||||
- [ ] Log in as a **guest** — no "Dashboards" section visible at all
|
||||
- [ ] Log in as a **host** — "Dashboards" section shows ⭐ **Host-Dashboard** → chevron
|
||||
- [ ] Tapping it navigates to `/host`
|
||||
- [ ] No Admin-Dashboard link visible
|
||||
- [ ] Log in as **admin** — both links appear:
|
||||
- [ ] ⭐ Host-Dashboard → `/host`
|
||||
- [ ] 🛡 Admin-Dashboard → `/admin`
|
||||
|
||||
### 5.3 PIN Card
|
||||
- [ ] Amber card shows the 4-digit PIN in large monospace font
|
||||
- [ ] **Kopieren** button copies to clipboard; label changes to "Kopiert!" for 2 seconds
|
||||
- [ ] If no PIN is stored: fallback message shown
|
||||
|
||||
### 5.4 Konto Section
|
||||
- [ ] **Gerät wechseln / PIN nutzen** → navigates to `/recover`
|
||||
- [ ] **Event verlassen** (red text) → tapping opens a **leave-confirm bottom sheet**
|
||||
- [ ] Sheet shows: "Event verlassen?", "Du wirst abgemeldet…", red "Abmelden" + "Abbrechen"
|
||||
- [ ] Tap backdrop — sheet closes, you remain logged in
|
||||
- [ ] Tap **Abbrechen** — same
|
||||
- [ ] Tap **Abmelden** — you are logged out and redirected to `/join`
|
||||
|
||||
### 5.5 No Stale Nav Links
|
||||
- [ ] **No** "Zur Galerie" link in the header (navigation is via the bottom bar)
|
||||
|
||||
---
|
||||
|
||||
## Section 6 — Host Dashboard
|
||||
|
||||
**Goal:** Verify the back arrow, collapsible sections, and all existing host actions still work.
|
||||
|
||||
### 6.1 Navigation
|
||||
- [ ] Open Host Dashboard via Account → ⭐ Host-Dashboard
|
||||
- [ ] Page shows a **← back arrow** in the top-left header
|
||||
- [ ] Tapping it navigates to `/account`
|
||||
- [ ] **No** shield/gallery header icons (removed)
|
||||
- [ ] Bottom tab bar is still visible
|
||||
|
||||
### 6.2 Statistiken Section (Collapsible)
|
||||
- [ ] Section is **expanded** by default with a downward chevron
|
||||
- [ ] Shows a 2×2 grid of stat cards: Gäste, Uploads, Uploads status (Offen/Gesperrt), Freigegeben (Ja/Nein)
|
||||
- [ ] Numbers are large and readable on mobile
|
||||
- [ ] Tap the **Statistiken** header button — section collapses (smooth max-height animation)
|
||||
- [ ] Chevron rotates 180° to point upward when collapsed
|
||||
- [ ] Tap again — section expands
|
||||
|
||||
### 6.3 Event-Einstellungen Section (Collapsible)
|
||||
- [ ] Collapse/expand works same as above
|
||||
- [ ] Shows **"Uploads sperren"** (amber) / **"Uploads wieder öffnen"** (green) button
|
||||
- [ ] Shows **"Galerie freigeben"** (blue) / "Galerie bereits freigegeben" (disabled gray)
|
||||
- [ ] Tap "Uploads sperren" — toast confirms, button switches to "Uploads wieder öffnen"
|
||||
- [ ] Existing functionality unchanged
|
||||
|
||||
### 6.4 Nutzerverwaltung Section (Collapsible)
|
||||
- [ ] **Search bar** at top of section filters the user list in real time (client-side)
|
||||
- [ ] Each user row shows name, role badge, banned badge (if applicable), upload count/bytes
|
||||
- [ ] **Sperren** button triggers the existing ban modal (confirm + hide-uploads checkbox)
|
||||
- [ ] **Entsperren** appears for banned users
|
||||
- [ ] **Host** button promotes a guest to host role
|
||||
- [ ] **Degradieren** appears for hosts (admin only)
|
||||
- [ ] Toast notifications appear above the bottom bar (not obscured by it)
|
||||
|
||||
---
|
||||
|
||||
## Section 7 — Admin Dashboard
|
||||
|
||||
**Goal:** Verify the inner tab bar, all 4 tabs, and the new Nutzer tab.
|
||||
|
||||
### 7.1 Navigation
|
||||
- [ ] Open Admin Dashboard via Account → 🛡 Admin-Dashboard
|
||||
- [ ] Page shows **← back arrow** → `/account`
|
||||
- [ ] **No** star/gallery header icons
|
||||
- [ ] Bottom tab bar visible
|
||||
|
||||
### 7.2 Inner Tab Bar
|
||||
- [ ] A second tab bar appears **below the main header**, sticky on scroll
|
||||
- [ ] 4 tabs: **Stats · Config · Export · Nutzer**
|
||||
- [ ] Active tab has a blue bottom border and blue text
|
||||
- [ ] Inactive tabs are gray
|
||||
- [ ] Tabs are scrollable horizontally (try narrowing viewport)
|
||||
- [ ] Switching tabs is instant with no page reload
|
||||
|
||||
### 7.3 Stats Tab
|
||||
- [ ] Shows a **2×2 grid** of metric cards: Gäste, Uploads, Kommentare, Speicher %
|
||||
- [ ] Values are large (`text-3xl`)
|
||||
- [ ] Below the grid: a full-width disk usage bar with color coding
|
||||
- Blue ≤ 74%, Amber 75–89%, Red ≥ 90%
|
||||
- [ ] Exact used/total/free values shown
|
||||
|
||||
### 7.4 Config Tab
|
||||
- [ ] Shows stacked label + full-width input for each of the 8 config keys
|
||||
- [ ] Inputs are `type="number"` with large touch targets
|
||||
- [ ] A **"Speichern"** button is **sticky at the bottom** of the tab (always visible, even on long scroll)
|
||||
- [ ] Edit a value → tap Speichern → toast "Konfiguration gespeichert."
|
||||
- [ ] Tap Speichern with no changes → toast "Keine Änderungen."
|
||||
|
||||
### 7.5 Export Tab
|
||||
- [ ] **"Galerie freigeben"** button triggers gallery release
|
||||
- [ ] **"Aktualisieren"** button refreshes the jobs list only (no full page flash)
|
||||
- [ ] Export jobs listed with status chips: Ausstehend (gray) / Läuft (blue) / Fertig (green) / Fehlgeschlagen (red)
|
||||
- [ ] Running jobs show a progress bar
|
||||
- [ ] Failed jobs show the error message in red
|
||||
|
||||
### 7.6 Nutzer Tab (New)
|
||||
- [ ] Users are loaded from `/host/users` (admin shares host permissions)
|
||||
- [ ] **Search bar** filters list in real time
|
||||
- [ ] Same ban/unban/promote/demote actions as Host dashboard
|
||||
- [ ] After an action (e.g. ban) only the users list refreshes, not the whole page
|
||||
|
||||
---
|
||||
|
||||
## Section 8 — Toast Position
|
||||
|
||||
- [ ] On host/admin pages, toasts appear at `bottom-24` (above the bottom nav bar)
|
||||
- [ ] Toasts are **not** obscured by the nav bar
|
||||
|
||||
---
|
||||
|
||||
## Section 9 — Desktop Usability (Second Citizen)
|
||||
|
||||
**Goal:** Confirm all pages are still usable on a wide viewport.
|
||||
|
||||
### 9.1 Layout Centering
|
||||
- [ ] On a 1280px+ viewport, all pages center their content at `max-w-2xl` or `max-w-3xl`
|
||||
- [ ] Bottom tab bar spans full width but content columns remain centered
|
||||
- [ ] No content is clipped or overflows horizontally
|
||||
|
||||
### 9.2 Feed Desktop
|
||||
- [ ] List view: cards are centered, readable at 672px max width
|
||||
- [ ] Grid view: 3 columns at max-width — cells are larger and look good
|
||||
- [ ] Search bar is full-width within the max-width container
|
||||
|
||||
### 9.3 Upload Composer Desktop
|
||||
- [ ] Upload page is full-height, centered column
|
||||
- [ ] Both the header "Hochladen" button AND the sticky bottom button are present
|
||||
- [ ] Desktop users can click the header button (more convenient without reaching to bottom)
|
||||
|
||||
### 9.4 Host / Admin Desktop
|
||||
- [ ] Host collapsible sections work with mouse clicks
|
||||
- [ ] Admin inner tabs work with mouse clicks; all 4 tabs visible without scrolling at 1280px
|
||||
- [ ] Config tab sticky save is visible on desktop scroll
|
||||
|
||||
---
|
||||
|
||||
## Section 10 — Edge Cases
|
||||
|
||||
### 10.1 Upload with No Files Selected
|
||||
- [ ] Navigate directly to `/upload` in the browser
|
||||
- [ ] No files pending → "Keine Dateien ausgewählt" screen shown with "Zurück" button
|
||||
- [ ] "Hochladen" button is disabled
|
||||
|
||||
### 10.2 Rate Limiting
|
||||
- [ ] Upload rapidly beyond the configured limit (default: 10/hour)
|
||||
- [ ] A `429` response is received
|
||||
- [ ] The countdown banner appears above the bottom nav: "Upload-Limit erreicht. Wird in X Sek. automatisch fortgesetzt."
|
||||
- [ ] After the countdown, the queue resumes automatically
|
||||
|
||||
### 10.3 SSE Reconnect
|
||||
- [ ] Stop the backend briefly and restart
|
||||
- [ ] The feed reconnects (SSE) — new uploads appear once the backend is back
|
||||
|
||||
### 10.4 Back Navigation from Upload
|
||||
- [ ] Pick files → navigate to `/upload`
|
||||
- [ ] Tap `×` → files are discarded (`clearPending()` runs, object URLs are revoked)
|
||||
- [ ] Navigate back to `/upload` directly — "Keine Dateien ausgewählt" shown (not stale files)
|
||||
|
||||
### 10.5 Grid Filter Persistence
|
||||
- [ ] Set a filter chip in grid view
|
||||
- [ ] Switch to list view — filter is cleared (list always shows full unfiltered feed)
|
||||
- [ ] Switch back to grid — search bar is empty, no stale chips
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (Not Bugs)
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| "Anzeigename ändern" in Account | Deferred — shown as disabled; requires `/me` PATCH endpoint |
|
||||
| Upload count in Account profile card | Deferred — requires `/me` GET endpoint |
|
||||
| CSS collapse animation on host sections | Uses `max-h` trick; may be slightly sluggish for very large user lists |
|
||||
| Autocomplete results | Derived from currently-loaded posts only; new posts via SSE update the pool automatically |
|
||||
51
docs/SECURITY-BACKLOG.md
Normal file
51
docs/SECURITY-BACKLOG.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Security & Hardening Backlog
|
||||
|
||||
Tracks the deliberately-deferred items from the 2026-06-27 audit and its review passes. The
|
||||
Critical→Medium findings and the cheap "bucket 🅰" LOWs are fixed on
|
||||
`fix/audit-2026-06-27-critical-medium`. What remains is recorded here so the decisions are
|
||||
explicit, not forgotten.
|
||||
|
||||
## 🅱 Worth a tracked ticket (real, not one-liners)
|
||||
|
||||
- **Moderation UI gap** — the backend `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}`
|
||||
endpoints have **no frontend caller**, so a host cannot remove a guest's content from the UI.
|
||||
This is a functional hole, not polish. Needs a host-facing "remove" action wired to those
|
||||
endpoints (a `ContextSheet` action gated on host role).
|
||||
- **Feed reactivity** — a single global SSE event (`like-update`/`new-comment`/`upload-processed`)
|
||||
triggers `loadFeed(true)`, which full-replaces the list with the first 20, collapsing a
|
||||
scrolled feed; and owner-deleted uploads aren't broadcast to other clients (only host deletes
|
||||
are). Patch the affected item from the SSE payload instead of full-reloading; emit
|
||||
`upload-deleted` from the owner `delete_upload` path too.
|
||||
- **Media HMAC domain separation** — the signed-media tokens reuse `jwt_secret` as the HMAC key.
|
||||
Correct today (different message structure), but a dedicated derived key
|
||||
(`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't
|
||||
weaken the other.
|
||||
- **Quota mount-detection + low-disk guard** — `compute_storage_quota` picks the disk via
|
||||
`starts_with` (root is a wildcard prefix → can match the wrong device) and there's no hard
|
||||
min-free-space precheck when the quota is disabled. Use longest-prefix match; add an
|
||||
unconditional 507/429 when free space is critically low.
|
||||
|
||||
## 🅲 Consciously won't-fix at ~100-guest single-box scale
|
||||
|
||||
Diminishing returns vs. the deployment's actual threat model. Revisit only if the scale or
|
||||
tenancy model changes.
|
||||
|
||||
- Rate-limiter `HashMap` key LRU/cap (attacker-chosen `recover:{ip}:{name}` keys accumulate up to
|
||||
the 24h prune ceiling) — bounded and pruned; not worth an LRU.
|
||||
- "Last host" / host↔host role-churn guard — operational, low blast radius.
|
||||
- Performance micro-indexes (`idx_like_user_upload`, comment pagination index) — current queries
|
||||
are sub-ms at this row count.
|
||||
- Optimistic-like in-flight guard, ownership-snapshot-at-mount, assorted copy tweaks — UX polish.
|
||||
|
||||
## By-design notes (documented, not bugs)
|
||||
|
||||
- **Banned user retains ≤24h media access via already-held signed URLs.** The media gateway
|
||||
authorizes by *signature + uploader visibility*, not requester identity, and signed URLs are
|
||||
time-boxed (24h, bucketed). A banned/revoked-session user cannot mint new URLs (every API call
|
||||
401/403s) but can replay URLs they already hold until expiry — only for content they already
|
||||
saw. Accepted: tightening this would require per-request identity on every `<img>` load, which
|
||||
the `<img>`-can't-send-a-Bearer constraint precludes. Shorten the TTL in `media_token.rs` if a
|
||||
stricter bound is ever needed.
|
||||
- **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser
|
||||
caching and the stable bucketed URL (so warm fetches don't hit the backend at all). Could be one
|
||||
JOIN if it ever shows up in profiling.
|
||||
292
docs/USER_JOURNEYS.md
Normal file
292
docs/USER_JOURNEYS.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# EventSnap — User Journeys
|
||||
|
||||
This document walks through every supported user scenario step-by-step. For a quick "who
|
||||
can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
|
||||
[TEST_GUIDE.md](../TEST_GUIDE.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. First-time guest (the happy path)
|
||||
|
||||
1. Guest scans the QR code / opens the event link.
|
||||
2. Lands on the **join page** (`/join`), sees the event name. A small
|
||||
*"Ich habe bereits einen Account"* link is visible below the form for returning users
|
||||
— it routes to `/recover`.
|
||||
3. Types display name → taps **Beitreten**.
|
||||
4. Server creates the account, generates a 4-digit PIN, stores `bcrypt(PIN)`, signs a
|
||||
30-day JWT.
|
||||
5. A **PIN modal** appears: large monospace digits, a **Kopieren** button, a warning that
|
||||
this PIN is the only way to sign in on another device. PIN is also written to
|
||||
`localStorage`.
|
||||
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
|
||||
7. The **first-visit onboarding overlay** appears: dismissible steps (welcome, upload,
|
||||
hashtags, PIN, and a brief pointer to the **Datenschutzhinweis** in My Account).
|
||||
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
|
||||
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
|
||||
|
||||
## 2. Returning guest, same device
|
||||
|
||||
1. App finds a valid JWT in `localStorage`.
|
||||
2. Redirected straight to `/feed`, no input required.
|
||||
|
||||
## 3. Returning guest, new device or cleared storage
|
||||
|
||||
1. Guest opens the event link on the new device → join page.
|
||||
2. Types the **same name** they used before.
|
||||
3. Server detects the existing account → the join page transforms into a recovery prompt:
|
||||
*"„Name" ist bereits vergeben"* with a **PIN input** and an **Anmelden** button, plus
|
||||
an **Anderen Namen wählen** escape hatch.
|
||||
4. Guest types their PIN → `bcrypt.verify` succeeds → new JWT issued for the existing
|
||||
`user_id`. PIN is written to `localStorage` on this device too.
|
||||
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
|
||||
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
|
||||
|
||||
## 4. PIN forgotten — Host or Admin resets it (planned)
|
||||
|
||||
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
|
||||
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
|
||||
|
||||
1. Guest approaches the Host: *"I can't sign in on my new phone."*
|
||||
2. Host opens the **Host Dashboard → Nutzerverwaltung** and finds the user.
|
||||
3. Host taps **PIN zurücksetzen** on that row.
|
||||
4. A confirmation prompt explains what happens; on confirm the server generates a fresh
|
||||
4-digit PIN, replaces `recovery_pin_hash` with the new bcrypt, clears any active
|
||||
`pin_locked_until`, and returns the new plaintext PIN in the response.
|
||||
5. A **modal shows the new PIN ONCE** — large, with a copy button. The Host shows the
|
||||
screen to the guest or sends it via another channel (SMS, slip of paper, …). Closing
|
||||
the modal forgets the plaintext on the operator's device too.
|
||||
6. Guest goes to `/recover` (or taps "Ich habe bereits einen Account" on `/join`), enters
|
||||
their name + the new PIN, signs in, and the PIN is persisted to `localStorage` on
|
||||
their device — exactly like a fresh join.
|
||||
|
||||
**Permission rules:**
|
||||
- Host can reset PINs for **guests** only.
|
||||
- Admin can reset PINs for **hosts and guests** (not other admins; admins use the
|
||||
password login).
|
||||
- Anyone whose PIN was reset retains all their uploads, comments, and likes — only the
|
||||
PIN changes.
|
||||
|
||||
**If no Host or Admin is reachable**, the guest can still re-join under a new name (a
|
||||
clean account; their previous uploads remain attributed to the abandoned account, which
|
||||
the Host can clean up later).
|
||||
|
||||
## 5. Posting a photo / video
|
||||
|
||||
1. Guest taps the central **📷+ FAB** in the bottom nav.
|
||||
2. A **bottom sheet** slides up offering **Kamera** (in-app capture) or **Galerie** (file
|
||||
picker, multi-select).
|
||||
3a. **Camera path** — [CameraCapture](../frontend/src/lib/components/CameraCapture.svelte)
|
||||
opens the back camera (`facingMode: 'environment'`), with toggle for front camera,
|
||||
photo button, and a video-record button using `MediaRecorder`.
|
||||
3b. **Gallery path** — native picker, multiple selection.
|
||||
4. **Preview screen** (`/upload`) shows staged files as horizontal thumbnails. The user can:
|
||||
- Remove individual files.
|
||||
- Type a caption with `#hashtags`.
|
||||
- Tap quick-tag chips (derived from the caption) to copy a hashtag into the caption.
|
||||
5. Taps **Hochladen** → returns immediately to the feed (optimistic UX). The slim progress
|
||||
bar above the bottom nav and the red badge on the FAB indicate active uploads.
|
||||
6. The client uploads files **one at a time** (XHR with progress) from an IndexedDB queue.
|
||||
7. Each upload triggers a server-side compression job; once the preview is ready the feed
|
||||
updates via `upload-processed` SSE — placeholders swap for actual previews.
|
||||
|
||||
## 6. Posting under rate limits
|
||||
|
||||
1. Hit the per-hour upload limit (default 10 / hour, configurable).
|
||||
2. Server returns **HTTP 429** with a `Retry-After` header on the next upload attempt.
|
||||
3. Client parks pending items in **Wartend** state and shows an amber banner:
|
||||
*"Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."*
|
||||
4. Countdown ticks down. When it reaches 0, the queue resumes automatically.
|
||||
|
||||
## 7. Liking and commenting
|
||||
|
||||
1. Tap the heart icon on a card or in the lightbox → like is recorded; count increments
|
||||
optimistically; server returns the canonical count via `like-update` SSE.
|
||||
2. Tap the comment icon → opens the lightbox with the comments list.
|
||||
3. Type a comment → `POST /api/v1/upload/{id}/comment`. Hashtags inside the comment are
|
||||
parsed and attached.
|
||||
4. The user can delete their own comments (trash icon next to them).
|
||||
|
||||
## 8. Filtering the gallery
|
||||
|
||||
1. Toggle to **grid view** (icon top-right of the feed header).
|
||||
2. A search bar appears below the header (auto-focused).
|
||||
3. Type a name or `#hashtag` — autocomplete suggestions are derived **in memory** from the
|
||||
loaded uploads.
|
||||
4. Tap a suggestion → it becomes an **active filter chip** and the search bar clears.
|
||||
5. Filter logic:
|
||||
- Multiple hashtag chips: OR
|
||||
- Multiple uploader chips: OR
|
||||
- One uploader + one hashtag: AND
|
||||
6. Open a post → swipe in the lightbox navigates the **filtered set**, not the full feed.
|
||||
|
||||
## 9. Hosting the event — moderation
|
||||
|
||||
1. Host opens **My Account** → taps **⭐ Host-Dashboard**.
|
||||
2. **Stats section** — guest count, upload count, lock status, release status.
|
||||
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
|
||||
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
|
||||
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
|
||||
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
|
||||
`export-progress` keeps it live; `export-available` notifies all guests when ready.
|
||||
5. **Nutzerverwaltung** — search users; per-user controls:
|
||||
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie
|
||||
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
|
||||
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
|
||||
- **Entsperren** lifts the ban.
|
||||
- **Host** promotes a guest to host.
|
||||
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
||||
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
|
||||
only an Admin can demote themselves out of moderation. Admins see Degradieren on
|
||||
every Host row.
|
||||
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
|
||||
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
|
||||
rows.
|
||||
6. **Deleting content** — Host can delete any upload or comment via the moderation routes
|
||||
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
|
||||
reachable by long-pressing the content (planned, see §15).
|
||||
|
||||
## 10. Banned-guest experience
|
||||
|
||||
1. The banned user's next authenticated request returns HTTP 403 with a clear message
|
||||
("Du bist gesperrt.").
|
||||
2. They can still browse the read-only feed (and download the export once it's released).
|
||||
3. They cannot upload, like, or comment.
|
||||
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
|
||||
feed for everyone (the `v_feed` view already enforces this).
|
||||
|
||||
## 11. Admin — instance configuration
|
||||
|
||||
1. Admin opens `/admin/login`, types the admin password (compared against
|
||||
`ADMIN_PASSWORD_HASH`). Receives a separate 1-day admin JWT (in `sessionStorage`).
|
||||
2. Admin dashboard has four inner tabs:
|
||||
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
|
||||
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
|
||||
export), quota tolerance, estimated guest count, compression-worker concurrency,
|
||||
plus the **Datenschutzhinweis** free-text editor and **on/off toggles** for the rate
|
||||
limiters and quotas (planned — see §16). Whitelist on the server side rejects
|
||||
unknown keys. Values are read from the `config` table on each request — no restart
|
||||
needed.
|
||||
- **Export**: list of past export jobs with status badges (pending / running / done /
|
||||
failed) and progress bars; refresh button re-polls.
|
||||
- **Nutzer**: same user list as Host, with the additional Demote action and (planned)
|
||||
PIN-reset on host rows.
|
||||
|
||||
## 12. Releasing the export and downloading
|
||||
|
||||
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
|
||||
2. Server sets `event.export_released_at` and enqueues two background jobs.
|
||||
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
|
||||
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
||||
4. HTML-viewer job: copies the pre-built viewer assets from
|
||||
[backend/static/export-viewer/](../backend/static/export-viewer/) (embedded via
|
||||
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
|
||||
variants for each upload, and assembles `Memories.zip`.
|
||||
5. Both jobs complete → server broadcasts `export-available` SSE.
|
||||
6. Any user opens `/export`:
|
||||
- Before release: friendly "Export not yet available" banner.
|
||||
- During generation: progress bars per artifact.
|
||||
- After completion: two cards (**ZIP-Archiv** and **HTML-Viewer**) with download
|
||||
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
|
||||
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
|
||||
browser download.
|
||||
7. Downloads are rate-limited per IP (default 3 / day).
|
||||
|
||||
## 13. Diashow (planned)
|
||||
|
||||
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
||||
|
||||
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
|
||||
on mobile).
|
||||
2. Navigates to `/diashow` — fullscreen, bottom nav hidden, screen wake-lock acquired.
|
||||
3. Initial pool fetched from `GET /api/v1/feed`. Slides crossfade every ~6 s.
|
||||
4. New uploads (`upload-processed` SSE) push to a live queue; the next slide transition
|
||||
pops from the live queue first, otherwise from a shuffled queue.
|
||||
5. `upload-deleted` removes that ID from both queues; if it's the current slide, advance
|
||||
immediately.
|
||||
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
|
||||
|
||||
## 14. Picking a data mode (planned)
|
||||
|
||||
1. Guest opens **My Account** → scrolls to **Datennutzung**.
|
||||
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
|
||||
3. Selecting **Original** shows a one-time warning bottom-sheet:
|
||||
*"Original-Dateien werden geladen — das kann deine mobile Datennutzung deutlich
|
||||
erhöhen. Trotzdem aktivieren?"* with **Abbrechen** / **Aktivieren** buttons.
|
||||
4. Choice persists in `localStorage` (per-device). The feed, lightbox, and diashow all
|
||||
read this flag and load originals instead of compressed previews when Original is on.
|
||||
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
|
||||
bundled `_thumb` / `_full` variants.
|
||||
|
||||
## 15. Leaving an event
|
||||
|
||||
1. User opens **My Account** → taps **🚪 Event verlassen**.
|
||||
2. Bottom-sheet confirmation: "Event verlassen?" with **Abmelden** and **Bleiben**.
|
||||
3. Confirming calls `DELETE /api/v1/session` (invalidates the session row), clears the JWT
|
||||
and PIN from `localStorage`, and redirects to the join page.
|
||||
|
||||
## 16. Reading the Datenschutzhinweis (planned)
|
||||
|
||||
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
|
||||
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
|
||||
and newlines preserved exactly as the Admin typed them). No HTML, no markdown — the
|
||||
admin's plain text is shown verbatim.
|
||||
3. The first-visit onboarding overlay carries a one-line reminder of where to find this:
|
||||
*"Datenschutzhinweis findest du in deinem Account."*
|
||||
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
|
||||
tall textarea with a save button. Saved to a single `config` key.
|
||||
|
||||
## 17. Mobile-first gestures (planned)
|
||||
|
||||
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
|
||||
ergonomic. Buttons are always present as fallback for desktop and accessibility.
|
||||
|
||||
| Gesture | Action |
|
||||
|-------------------------------------------|-------------------------------------------------------|
|
||||
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
|
||||
| Long-press on a post (other) | Bottom sheet → Original anzeigen, Teilen, Melden (planned) |
|
||||
| Long-press on a comment (own) | Bottom sheet → Löschen |
|
||||
| Long-press on a comment (other) | Bottom sheet → Kopieren |
|
||||
| Long-press on a user row (Host) | Bottom sheet → Sperren, Promote/Demote, PIN zurücksetzen |
|
||||
| Swipe left/right in the lightbox | Navigate the filtered set |
|
||||
| Swipe down on any bottom sheet | Dismiss |
|
||||
| Pull-to-refresh on the feed | Force a delta-fetch |
|
||||
| Double-tap on a post | Like (heart-burst animation) |
|
||||
|
||||
On desktop the same actions surface as kebab/⋯ menus, click-able icons in card corners,
|
||||
and keyboard shortcuts in the lightbox (← → for navigate, Esc to close).
|
||||
|
||||
Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press for
|
||||
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
|
||||
land).
|
||||
|
||||
## 18. Admin toggles a rate limit or quota off (planned)
|
||||
|
||||
1. Admin opens **Admin Dashboard → Config**.
|
||||
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
|
||||
export / join).
|
||||
3. Admin flips, e.g., **Upload-Limit aktiv** off. The numeric input for "uploads per hour"
|
||||
stays visible but greyed out (still editable for when the toggle goes back on).
|
||||
4. **Speichern** persists to the `config` table. The next upload request bypasses the
|
||||
limiter entirely.
|
||||
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
|
||||
bytes / upload count).
|
||||
6. When the storage-quota toggle is off, the **"X von Y MB genutzt"** widget in the
|
||||
guest's My Account and upload screen hides itself (no quota → no number to show).
|
||||
|
||||
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
|
||||
Toggling off is the explicit escape hatch for testing or trusted internal events.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases worth knowing
|
||||
|
||||
| Case | Behaviour |
|
||||
|-------------------------------------------------------|---------------------------------------------------------------------------------|
|
||||
| Browser tab backgrounded for > 5 min | SSE closes on `visibilitychange: hidden`; reopens on visible |
|
||||
| Upload finishes while user is on `/account` | Feed updates anyway — the queue + SSE are global stores |
|
||||
| Event "closed" while files are still in the queue | Server rejects with a friendly error; client surfaces it in the queue UI |
|
||||
| Network drops mid-upload | Queue retries the file; retry button available on permanent failure |
|
||||
| New device but the PIN was lost | Either re-join under a new name, or Host manually re-links (no self-service) |
|
||||
| Two guests pick the same name | Second one is offered the PIN-recovery form (case-insensitive UNIQUE, mig. 007) |
|
||||
| Compression fails for a file | Server emits `upload-error` SSE; the upload is still listed but marked degraded |
|
||||
| User deletes their own post (once UI is shipped) | Soft delete (`deleted_at`); SSE `upload-deleted`; vanishes from feed everywhere |
|
||||
6
e2e/.gitignore
vendored
Normal file
6
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.cache/
|
||||
.env.test
|
||||
*.log
|
||||
14
e2e/Caddyfile.test
Normal file
14
e2e/Caddyfile.test
Normal file
@@ -0,0 +1,14 @@
|
||||
# Caddyfile used only by the E2E test stack. Listens on the in-container :3101
|
||||
# (mapped to host :3101) and proxies API + media to the backend, everything else
|
||||
# to the SvelteKit frontend container — same layout as production but stripped
|
||||
# of HTTPS/Let's Encrypt.
|
||||
|
||||
:3101 {
|
||||
encode zstd gzip
|
||||
|
||||
reverse_proxy /api/* app:3000
|
||||
reverse_proxy /media/* app:3000
|
||||
reverse_proxy /health app:3000
|
||||
|
||||
reverse_proxy frontend:3001
|
||||
}
|
||||
287
e2e/README.md
Normal file
287
e2e/README.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# EventSnap E2E Suite
|
||||
|
||||
Playwright-driven end-to-end tests for the EventSnap stack. The suite spins
|
||||
up an isolated docker-compose stack on ports `:3101` (Caddy → frontend +
|
||||
backend) and `:55432` (Postgres), and exercises the SvelteKit frontend
|
||||
against a real Rust backend with rate limits and quotas disabled.
|
||||
|
||||
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
||||
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
||||
smoke matrix across nine browser/UA profiles to catch engine-level
|
||||
divergences.
|
||||
- **Phase 2** — adversarial inputs (XSS, SQL-injection, JWT forgery, MIME
|
||||
spoofing, oversize, brute-force) and browser chaos (storage purge,
|
||||
offline/slow-3G, multi-tab, clock skew, no-JS, quota exhaustion).
|
||||
- **Phase 3 (gestures only)** — touch-target audit, safe-area structural
|
||||
check, long-press → ContextSheet, double-tap → like, viewport reflow,
|
||||
plus `test.fixme` stubs for planned gestures (lightbox swipe, swipe-down
|
||||
dismiss, pull-to-refresh).
|
||||
|
||||
Phase 3 real-device compat (Android emulator + Samsung Internet via
|
||||
`connectOverCDP`, BrowserStack), visual regression, and a11y audits are
|
||||
sketched in the **Roadmap** at the bottom.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npm install
|
||||
npm run install:browsers # one-time: ~500 MB across chromium/firefox/webkit
|
||||
|
||||
# 1. Boot the test stack (rebuilds backend + frontend Docker images)
|
||||
npm run stack:up
|
||||
|
||||
# 2. Wait ~20s for migrations + warmup, then run tests
|
||||
npm run test:e2e # full Phase 1 suite on chromium-desktop
|
||||
npm run test:e2e:smoke # cross-UA smoke matrix (~9 projects × 1 test)
|
||||
npm run test:e2e:ui # interactive Playwright UI mode
|
||||
|
||||
# 3. After: tear the stack down (deletes volumes)
|
||||
npm run stack:down
|
||||
```
|
||||
|
||||
The CI workflow at `.github/workflows/e2e.yml` runs both jobs on every PR.
|
||||
|
||||
## What's tested
|
||||
|
||||
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
|
||||
or a security/chaos scenario. One folder per area:
|
||||
|
||||
| Folder | Phase | Journeys / Topic | Tests | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
|
||||
| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
|
||||
| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
|
||||
| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. |
|
||||
| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
|
||||
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
|
||||
| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. |
|
||||
| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
|
||||
| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
|
||||
| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. |
|
||||
|
||||
### Phase 2 — adversarial (`specs/07-adversarial/`)
|
||||
|
||||
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
||||
+ four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
||||
caption overflow). Asserts `window.__xssFired` never gets set and no
|
||||
`dialog` event fires.
|
||||
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
|
||||
payload sits in localStorage as the user's display name, rendering through
|
||||
`/account` keeps it as text.
|
||||
- **`file-upload-attacks.spec.ts`** — 9 tests. ELF body claimed as JPEG,
|
||||
oversize image vs `max_image_size_mb`, zero-byte, missing file field,
|
||||
path-traversal filename, NUL filename, `application/*` declared category
|
||||
bypass, SVG-with-script.
|
||||
- **`auth-tampering.spec.ts`** — 8 tests. `alg:none` forging admin role,
|
||||
signature tamper, payload tamper with original signature, logged-out
|
||||
session reuse, header without `Bearer `, missing Authorization,
|
||||
PIN brute-force lockout, admin password brute-force (documented finding —
|
||||
no lockout today, bcrypt cost is the only defense).
|
||||
- **`authorization-deep.spec.ts`** — 6 tests. Cross-user comment delete,
|
||||
banned user across like/comment/feed-read, host→admin escalation attempts.
|
||||
- **`ddos.spec.ts`** — 4 small-scale abuse tests. 20 parallel /join, 10 MB
|
||||
comment body, 10 concurrent SSE streams, malformed JSON.
|
||||
|
||||
### Phase 2 — browser chaos (`specs/08-browser-chaos/`)
|
||||
|
||||
- **`storage-purge.spec.ts`** — 5 tests. `localStorage.clear()` mid-session,
|
||||
cookies cleared (JWT in localStorage still works), sessionStorage cleared,
|
||||
admin force-relogin, PIN intentionally survives clearAuth.
|
||||
- **`indexeddb.spec.ts`** — 2 tests. Drop all IDB databases mid-session;
|
||||
stub IDB to undefined before navigation.
|
||||
- **`offline-network.spec.ts`** — 4 tests. `setOffline(true)` → reconnect,
|
||||
slow-3G via `page.route` delay, intermittent 503s, 429 from server (no
|
||||
infinite retry storm).
|
||||
- **`multi-tab.spec.ts`** — 3 tests. Same user two tabs, two users two
|
||||
contexts (storage isolated), logout in tab A doesn't sync to tab B
|
||||
(documented gap).
|
||||
- **`environment.spec.ts`** — 5 tests. JS disabled, localStorage quota
|
||||
exhausted, hostile CSS hiding nav, clock skew ±1h / -2d.
|
||||
|
||||
Pending tests covering features that need a Node-side multipart upload helper
|
||||
are marked `test.fixme` and will activate when that helper lands.
|
||||
|
||||
## Browser & UA matrix
|
||||
|
||||
| Project | Engine | UA / Device | Why |
|
||||
|---|---|---|---|
|
||||
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
||||
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
||||
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
|
||||
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
|
||||
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
|
||||
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
|
||||
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
||||
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
||||
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
||||
|
||||
Only the `@smoke` happy-path runs across all projects (controlled by
|
||||
`grep` in `playwright.config.ts`). The full Phase 1 suite is
|
||||
`chromium-desktop`-only by default to keep CI under 15 min.
|
||||
|
||||
### Samsung Internet — three escalation tiers
|
||||
|
||||
Samsung Internet ships on every Galaxy phone (~5% of mobile traffic in DE).
|
||||
It's **Blink-based**, so Tier-A catches ~90% of regressions. Real Samsung
|
||||
divergences (Smart Switch save-data mode, dark-mode injection, custom
|
||||
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
|
||||
|
||||
- **Tier A** *(this repo, free, in CI)*: Playwright Chromium with the
|
||||
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
|
||||
project in `playwright.config.ts`.
|
||||
- **Tier B** *(free, manual, future)*: Android Studio emulator on Linux →
|
||||
install Samsung Internet APK → enable `--remote-debugging-port=9222` →
|
||||
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
||||
`docs/samsung-emulator.md` (to be written).
|
||||
- **Tier C** *(paid, optional)*: BrowserStack or LambdaTest cloud devices.
|
||||
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
|
||||
|
||||
## Test isolation
|
||||
|
||||
Every test runs against a **freshly truncated database**:
|
||||
|
||||
1. `global-setup.ts` waits for `/health`, logs in admin, and disables every
|
||||
rate-limit and quota toggle via `PATCH /admin/config`.
|
||||
2. The auto-fixture `truncate` in `fixtures/test.ts` calls
|
||||
`POST /api/v1/admin/__truncate` before every test.
|
||||
3. The truncate endpoint is only registered when the backend is started
|
||||
with `EVENTSNAP_TEST_MODE=1` (see `backend/src/main.rs` and
|
||||
`backend/src/handlers/test_admin.rs`). Production builds return 404.
|
||||
|
||||
Single-worker by design (`workers: 1` in the config). Per-worker isolated
|
||||
DBs are a Phase-2+ change.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── docker-compose.test.yml # Isolated test stack: db :55432, caddy :3101
|
||||
├── Caddyfile.test # Proxies /api/* /media/* /health to backend
|
||||
├── playwright.config.ts # UA matrix + smoke grep
|
||||
├── global-setup.ts # admin login, rate-limit disable
|
||||
├── global-teardown.ts # (no-op; use `npm run stack:down`)
|
||||
├── fixtures/
|
||||
│ ├── api-client.ts # Typed wrapper over /api/v1/*
|
||||
│ ├── db.ts # Direct Postgres escape hatch (locked-PIN, etc.)
|
||||
│ ├── test.ts # Central test.extend (guest, host, signIn fixtures)
|
||||
│ └── media/ # sample.jpg, sample.mp4, not-an-image.jpg
|
||||
├── helpers/
|
||||
│ ├── sse-listener.ts # Async SSE iterator with waitForEvent()
|
||||
│ ├── storage-helpers.ts # localStorage/sessionStorage helpers
|
||||
│ └── fake-media.ts # Camera permissions (Chromium only)
|
||||
├── page-objects/
|
||||
│ ├── join-page.ts # /join
|
||||
│ ├── recover-page.ts # /recover
|
||||
│ ├── admin-login-page.ts # /admin/login
|
||||
│ ├── feed-page.ts # /feed + bottom nav
|
||||
│ ├── upload-sheet.ts # UploadSheet.svelte + /upload
|
||||
│ ├── lightbox.ts # LightboxModal.svelte
|
||||
│ ├── account-page.ts # /account
|
||||
│ ├── host-dashboard.ts # /host
|
||||
│ ├── admin-dashboard.ts # /admin
|
||||
│ └── export-page.ts # /export
|
||||
└── specs/
|
||||
├── __smoke/ # @smoke cross-UA matrix (1 spec)
|
||||
├── 01-auth/
|
||||
├── 02-upload/
|
||||
├── 03-feed/
|
||||
├── 04-host/
|
||||
├── 05-admin/
|
||||
└── 06-export/
|
||||
```
|
||||
|
||||
## Debugging a failure
|
||||
|
||||
- `npm run test:e2e:ui` — interactive UI with time-travel and selector probe.
|
||||
- `npm run test:e2e:headed` — watch the browser run live.
|
||||
- `npm run test:e2e:debug` — Playwright inspector with breakpoints.
|
||||
- `npm run stack:logs` — tail backend + Postgres logs during a failure.
|
||||
- `playwright-report/index.html` — opens the HTML report (auto-generated on every run).
|
||||
- Trace files (`test-results/**/trace.zip`) drag-and-drop into `https://trace.playwright.dev`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **One assertion per `expect`**. Bundling multiple expects in one statement
|
||||
loses the line-level failure context.
|
||||
- **Wait on data, not time**. Use `expect.poll` for DB checks; never `waitForTimeout` in production specs.
|
||||
- **`@smoke` tag** on each suite's happiest path so the matrix run stays under 2 min.
|
||||
- **`test.fixme`** for features that need infrastructure not yet built (Node-side multipart upload helper, real video fixtures, etc.). Fixme tests don't fail the suite but show up in the report.
|
||||
- **Page objects own selectors**. Specs never use raw locators.
|
||||
- **German text in assertions** is fine — it's not going to change frequently. When it does, the page object is the only file to update.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 2 — Adversarial & browser chaos ✅ landed
|
||||
|
||||
See the **What's tested** table above and the per-file breakdown.
|
||||
Known findings surfaced (documented in tests, not silent failures):
|
||||
|
||||
1. `/admin/login` has no rate-limit or lockout — bcrypt cost is the only defense.
|
||||
2. `localStorage` 'storage' event is not listened for, so logout in tab A
|
||||
doesn't synchronously sign out tab B (the next 401 from any API call
|
||||
clears it).
|
||||
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
||||
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
|
||||
+ CSP on `/media/*` if SVGs are ever expected as user content.
|
||||
|
||||
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
|
||||
|
||||
Runs only on the `chromium-mobile` project (Pixel 7 device descriptor with
|
||||
`hasTouch` and `isMobile`). The `chromium-desktop` project explicitly
|
||||
ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config.ts).
|
||||
|
||||
- **`touch-targets.spec.ts`** — 4 tests. Audits ≥ 44×44 px on bottom nav,
|
||||
FAB, join submit, admin-login submit, PIN-modal buttons. Uses
|
||||
`expect.soft` so a single failure surfaces the actual bounding-box
|
||||
dimensions instead of stopping the suite.
|
||||
- **`safe-area.spec.ts`** — 4 tests. Asserts `env(safe-area-inset-bottom)`
|
||||
is present in the inline style of every bottom-anchored UI element
|
||||
(bottom nav, UploadSheet, ContextSheet), and that the nav stays flush
|
||||
with the viewport bottom on a no-notch emulated device.
|
||||
- **`gestures-longpress.spec.ts`** — 3 tests. A 600 ms hold on a
|
||||
FeedListCard opens the ContextSheet; a 200 ms tap does not; the
|
||||
click-suppression logic prevents the lightbox from also opening at
|
||||
pointer-up. Driven via `page.mouse.down/up` because the `longpress`
|
||||
action listens for pointer events (mouse/touch/pen unified).
|
||||
- **`gestures-doubletap.spec.ts`** — 2 tests. Double-tap on a feed card
|
||||
image button records a like; double-tap inside the lightbox triggers
|
||||
the heart-burst animation and records a like. Assertions read the like
|
||||
count back via `/api/v1/feed` so they don't couple to specific badge
|
||||
markup.
|
||||
- **`viewport-reflow.spec.ts`** — 5 tests. Portrait, landscape, narrow
|
||||
(320×568), phablet (480×1024) — each asserts the bottom nav is
|
||||
visible, the FAB stays roughly centered, and there's no horizontal
|
||||
overflow on `<html>`. Plus a rotation test that confirms auth survives
|
||||
a viewport resize.
|
||||
- **`planned-gestures.spec.ts`** — 5 **`test.fixme`** stubs documenting
|
||||
the contracts for gestures from journey §17 that aren't shipped yet
|
||||
(lightbox swipe L/R, swipe-down to dismiss UploadSheet,
|
||||
pull-to-refresh, long-press on a comment). Flip `test.fixme` to `test`
|
||||
when wiring each gesture.
|
||||
|
||||
#### Driving gestures: the `helpers/touch.ts` module
|
||||
|
||||
- `longPress(page, locator, durationMs)` — holds the pointer down for
|
||||
the duration. Default 600 ms beats the action's 500 ms threshold.
|
||||
- `doubleTap(page, locator)` — two `mouse.down/up` pairs within the
|
||||
`doubletap` action's 300 ms window.
|
||||
- `swipe(page, from, to, steps)` — gradual mouse-driven move (used by
|
||||
the fixme stubs once swipe gestures land).
|
||||
- `inlineStyle(locator)` / `computedStyle(locator, prop)` — read raw
|
||||
`style` attributes (where `env(...)` strings live) and computed
|
||||
values.
|
||||
|
||||
### Phase 3 — Real-device compat & visual / a11y (not landed)
|
||||
- Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
|
||||
- Safe-area inset visual diff on iPhone notch.
|
||||
- Touch-target ≥ 44 px audit.
|
||||
- Tier B Samsung Internet via `connectOverCDP` on Android Studio emulator.
|
||||
- Tier C BrowserStack integration (paid, optional).
|
||||
- `@axe-core/playwright` accessibility audits.
|
||||
- Visual regression with screenshot diffs.
|
||||
|
||||
### Out of scope (handed to other tools)
|
||||
- Load testing → k6 / Vegeta.
|
||||
- API contract testing → backend `cargo test` integration tests.
|
||||
- Static asset auditing → Lighthouse CI.
|
||||
77
e2e/docker-compose.test.yml
Normal file
77
e2e/docker-compose.test.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
# Isolated EventSnap test stack. Mirrors production layout (Caddy → frontend +
|
||||
# backend) but on its own ports, its own volumes, with rate-limits disabled and
|
||||
# `EVENTSNAP_TEST_MODE=1` so the `/admin/__truncate` reset endpoint is live.
|
||||
#
|
||||
# Bring it up once before running the suite:
|
||||
# npm run stack:up
|
||||
# Tear it down (and wipe all volumes) after:
|
||||
# npm run stack:down
|
||||
#
|
||||
# Port 3101 is the only externally exposed port: Caddy fronts everything.
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: eventsnap_test
|
||||
POSTGRES_PASSWORD: eventsnap_test
|
||||
POSTGRES_DB: eventsnap_test
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U eventsnap_test -d eventsnap_test"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- "55432:5432" # exposed so the e2e harness can connect via pg for fixture setup
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgres://eventsnap_test:eventsnap_test@db:5432/eventsnap_test
|
||||
JWT_SECRET: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
|
||||
# bcrypt hash for the literal string "admin-test-pw" (cost 4 — fast for tests).
|
||||
# Generated once and committed. Verified with `bcrypt.compareSync`.
|
||||
# The $ characters are doubled to escape compose interpolation.
|
||||
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||
EVENT_SLUG: e2e-test-event
|
||||
EVENT_NAME: E2E Test Event
|
||||
APP_PORT: "3000"
|
||||
MEDIA_PATH: /media
|
||||
SESSION_EXPIRY_DAYS: "30"
|
||||
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
|
||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||
volumes:
|
||||
- media_data:/media
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- app
|
||||
environment:
|
||||
PORT: "3001"
|
||||
HOST: "0.0.0.0"
|
||||
ORIGIN: "http://localhost:3101"
|
||||
expose:
|
||||
- "3001"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
depends_on:
|
||||
- app
|
||||
- frontend
|
||||
volumes:
|
||||
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
||||
ports:
|
||||
- "3101:3101"
|
||||
|
||||
volumes:
|
||||
media_data:
|
||||
152
e2e/fixtures/api-client.ts
Normal file
152
e2e/fixtures/api-client.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tiny typed wrapper around the EventSnap REST API for use inside tests.
|
||||
* Used to seed data far faster than driving the UI through every join /
|
||||
* upload, and to set up adversarial states (banned users, locked PINs) that
|
||||
* the UI cannot reach.
|
||||
*
|
||||
* Auth: pass `token` on individual calls; no global state.
|
||||
*/
|
||||
|
||||
export const ADMIN_PASSWORD = 'admin-test-pw';
|
||||
|
||||
export class ApiClient {
|
||||
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: { token?: string; body?: unknown; expectedStatus?: number | number[] } = {}
|
||||
): Promise<{ status: number; body: T }> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
|
||||
const expected = opts.expectedStatus ?? [200, 201, 204];
|
||||
const allowed = Array.isArray(expected) ? expected : [expected];
|
||||
let body: unknown = undefined;
|
||||
if (res.status !== 204) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
body = text.length > 0 ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
if (!allowed.includes(res.status)) {
|
||||
throw new Error(
|
||||
`API ${method} ${path} → ${res.status} (expected ${allowed.join('/')}). Body: ${JSON.stringify(body)}`
|
||||
);
|
||||
}
|
||||
return { status: res.status, body: body as T };
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────
|
||||
async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
|
||||
const { body } = await this.request<any>('POST', '/join', {
|
||||
body: { display_name: displayName },
|
||||
expectedStatus: [201],
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) {
|
||||
return this.request<any>('POST', '/recover', {
|
||||
body: { display_name: displayName, pin },
|
||||
expectedStatus: opts.expectedStatus ?? [200],
|
||||
});
|
||||
}
|
||||
|
||||
async adminLogin(password: string = ADMIN_PASSWORD): Promise<string> {
|
||||
const { body } = await this.request<{ jwt: string }>('POST', '/admin/login', {
|
||||
body: { password },
|
||||
});
|
||||
return body.jwt;
|
||||
}
|
||||
|
||||
async logout(token: string) {
|
||||
return this.request<void>('DELETE', '/session', { token, expectedStatus: [204] });
|
||||
}
|
||||
|
||||
// ── Test-mode helpers ──────────────────────────────────────────────────
|
||||
async truncate(adminToken: string) {
|
||||
return this.request<void>('POST', '/admin/__truncate', {
|
||||
token: adminToken,
|
||||
expectedStatus: [204],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────
|
||||
async patchConfig(adminToken: string, patch: Record<string, string>) {
|
||||
return this.request<void>('PATCH', '/admin/config', {
|
||||
token: adminToken,
|
||||
body: patch,
|
||||
expectedStatus: [204],
|
||||
});
|
||||
}
|
||||
|
||||
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
||||
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', { token: adminToken });
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Host moderation ────────────────────────────────────────────────────
|
||||
async listUsers(token: string) {
|
||||
const { body } = await this.request<any[]>('GET', '/host/users', { token });
|
||||
return body;
|
||||
}
|
||||
|
||||
async setRole(token: string, userId: string, role: 'guest' | 'host') {
|
||||
return this.request<void>('PATCH', `/host/users/${userId}/role`, {
|
||||
token,
|
||||
body: { role },
|
||||
expectedStatus: [200, 204],
|
||||
});
|
||||
}
|
||||
|
||||
async banUser(token: string, userId: string, hideUploads = false) {
|
||||
return this.request<void>('POST', `/host/users/${userId}/ban`, {
|
||||
token,
|
||||
body: { hide_uploads: hideUploads },
|
||||
expectedStatus: [200, 204],
|
||||
});
|
||||
}
|
||||
|
||||
async closeEvent(token: string) {
|
||||
return this.request<void>('POST', '/host/event/close', { token, expectedStatus: [200, 204] });
|
||||
}
|
||||
|
||||
async openEvent(token: string) {
|
||||
return this.request<void>('POST', '/host/event/open', { token, expectedStatus: [200, 204] });
|
||||
}
|
||||
|
||||
// ── Feed ───────────────────────────────────────────────────────────────
|
||||
async getFeed(token: string) {
|
||||
const { body } = await this.request<any>('GET', '/feed', { token });
|
||||
return body;
|
||||
}
|
||||
|
||||
async getStats(adminToken: string) {
|
||||
const { body } = await this.request<any>('GET', '/admin/stats', { token: adminToken });
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Health ─────────────────────────────────────────────────────────────
|
||||
async waitForHealth(retries = 60): Promise<void> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/health`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
/* keep retrying */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
throw new Error(`Backend never became healthy at ${this.baseUrl}/health`);
|
||||
}
|
||||
}
|
||||
84
e2e/fixtures/db.ts
Normal file
84
e2e/fixtures/db.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Direct PostgreSQL escape hatch for setting up states the public API doesn't
|
||||
* expose — e.g. forcing a user into the locked-PIN state to assert the 429
|
||||
* recovery path, or expiring sessions for chaos tests.
|
||||
*
|
||||
* Most tests should NOT use this: prefer `ApiClient` so the tests exercise
|
||||
* the same code paths real users do. Reach for direct SQL only when the API
|
||||
* can't get you where you need to go.
|
||||
*/
|
||||
import { Client } from 'pg';
|
||||
|
||||
const CONN = {
|
||||
host: process.env.E2E_DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.E2E_DB_PORT ?? '55432'),
|
||||
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
|
||||
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
|
||||
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
|
||||
};
|
||||
|
||||
async function withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client(CONN);
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
export const db = {
|
||||
async lockUserPin(userId: string, minutesFromNow = 15) {
|
||||
await withClient((c) =>
|
||||
c.query(
|
||||
`UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
|
||||
[userId, String(minutesFromNow)]
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
async expireSession(userId: string) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [userId])
|
||||
);
|
||||
},
|
||||
|
||||
async setUploadCompressionStatus(uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed') {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
||||
);
|
||||
},
|
||||
|
||||
async countUploadsForUser(userId: string): Promise<number> {
|
||||
return withClient(async (c) => {
|
||||
const r = await c.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
|
||||
[userId]
|
||||
);
|
||||
return Number(r.rows[0].count);
|
||||
});
|
||||
},
|
||||
|
||||
async setExportReleased(slug: string, released: boolean) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
||||
slug,
|
||||
released ? new Date() : null,
|
||||
])
|
||||
);
|
||||
},
|
||||
|
||||
/** Insert a pre-baked export job row to skip the (slow) real compression path. */
|
||||
async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') {
|
||||
await withClient(async (c) => {
|
||||
const ev = await c.query<{ id: string }>(`SELECT id FROM event WHERE slug = $1`, [eventSlug]);
|
||||
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
||||
await c.query(
|
||||
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at)
|
||||
VALUES ($1, $2::export_type, $3::export_status, $4, $5)
|
||||
ON CONFLICT (event_id, type) DO UPDATE SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct`,
|
||||
[ev.rows[0].id, type, status, status === 'done' ? 100 : 0, status === 'done' ? new Date() : null]
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
103
e2e/fixtures/test.ts
Normal file
103
e2e/fixtures/test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Central `test.extend` for EventSnap E2E tests. Specs import `test` from here
|
||||
* (not `@playwright/test` directly) so they get the shared fixtures: API
|
||||
* client, DB helper, fresh admin token, and convenience factories.
|
||||
*
|
||||
* Every test runs against a freshly truncated database (via the `truncate`
|
||||
* auto-fixture). Because truncate wipes the `session` table, any admin JWT
|
||||
* obtained before truncate becomes invalid afterwards — so the
|
||||
* `truncate` fixture always does its own admin login, and the `adminToken`
|
||||
* fixture re-logs-in per test instead of caching. bcrypt cost 4 → ~10 ms,
|
||||
* negligible against the ~1 s per-test setup overhead.
|
||||
*/
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import { ApiClient } from './api-client';
|
||||
import { db } from './db';
|
||||
|
||||
type GuestHandle = {
|
||||
jwt: string;
|
||||
pin: string;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type Fixtures = {
|
||||
api: ApiClient;
|
||||
db: typeof db;
|
||||
adminToken: string;
|
||||
truncate: void;
|
||||
guest: (displayName?: string) => Promise<GuestHandle>;
|
||||
host: GuestHandle;
|
||||
/** Apply an existing guest's JWT + PIN to the page's localStorage and reload. */
|
||||
signIn: (page: Page, handle: GuestHandle) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
api: async ({}, use) => {
|
||||
await use(new ApiClient());
|
||||
},
|
||||
|
||||
db: async ({}, use) => {
|
||||
await use(db);
|
||||
},
|
||||
|
||||
// Auto-fixture: runs before every test, truncates the DB so each test starts
|
||||
// clean. Acquires its OWN admin token because the previous test's truncate
|
||||
// wiped the session row that backs any cached token.
|
||||
truncate: [
|
||||
async ({ api }, use) => {
|
||||
const token = await api.adminLogin();
|
||||
await api.truncate(token);
|
||||
await use();
|
||||
},
|
||||
{ auto: true, scope: 'test' },
|
||||
],
|
||||
|
||||
// Fresh admin login per test that asks for it. Comes AFTER the truncate
|
||||
// auto-fixture has run (truncate doesn't depend on adminToken, so the
|
||||
// dependency-free truncate runs first; this fixture then logs in on a
|
||||
// freshly reset DB).
|
||||
adminToken: async ({ api }, use) => {
|
||||
const token = await api.adminLogin();
|
||||
await use(token);
|
||||
},
|
||||
|
||||
guest: async ({ api }, use) => {
|
||||
let counter = 0;
|
||||
const factory = async (displayName?: string): Promise<GuestHandle> => {
|
||||
const name = displayName ?? `Gast${++counter}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const res = await api.join(name);
|
||||
return { jwt: res.jwt, pin: res.pin, userId: res.user_id, displayName: name };
|
||||
};
|
||||
await use(factory);
|
||||
},
|
||||
|
||||
host: async ({ api, guest, adminToken }, use) => {
|
||||
const h = await guest('TestHost');
|
||||
await api.setRole(adminToken, h.userId, 'host');
|
||||
// Role is encoded in the JWT — re-recover to get a fresh token with role=host.
|
||||
const { body } = await api.recover(h.displayName, h.pin);
|
||||
await use({ ...h, jwt: body.jwt });
|
||||
},
|
||||
|
||||
signIn: async ({}, use) => {
|
||||
const fn = async (page: Page, handle: GuestHandle) => {
|
||||
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ jwt, pin, userId, displayName }) => {
|
||||
localStorage.setItem('eventsnap_jwt', jwt);
|
||||
localStorage.setItem('eventsnap_pin', pin);
|
||||
localStorage.setItem('eventsnap_user_id', userId);
|
||||
localStorage.setItem('eventsnap_display_name', displayName);
|
||||
localStorage.setItem('eventsnap_guide_seen', 'true');
|
||||
},
|
||||
handle
|
||||
);
|
||||
await page.goto('/feed');
|
||||
};
|
||||
await use(fn);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
47
e2e/global-setup.ts
Normal file
47
e2e/global-setup.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Runs once before all tests. Waits for the test stack to be healthy, logs
|
||||
* in as admin, and flips all rate-limit/quota toggles off so tests don't
|
||||
* trip over them. Individual tests that *want* to assert rate-limit
|
||||
* behaviour re-enable the relevant flags in `beforeAll` and restore them
|
||||
* in `afterAll`.
|
||||
*
|
||||
* The admin token is written to `.cache/admin-token` so per-worker
|
||||
* fixtures can read it instead of logging in repeatedly.
|
||||
*/
|
||||
import { ApiClient } from './fixtures/api-client';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), '.cache');
|
||||
|
||||
export default async function globalSetup() {
|
||||
const api = new ApiClient();
|
||||
|
||||
console.log('[e2e] waiting for backend health…');
|
||||
await api.waitForHealth(120);
|
||||
|
||||
console.log('[e2e] logging in admin…');
|
||||
const adminToken = await api.adminLogin();
|
||||
|
||||
console.log('[e2e] resetting database…');
|
||||
await api.truncate(adminToken);
|
||||
|
||||
// Re-login because truncate wiped the session row backing the previous token.
|
||||
const freshAdminToken = await api.adminLogin();
|
||||
|
||||
console.log('[e2e] disabling rate limits & quotas for the test run…');
|
||||
await api.patchConfig(freshAdminToken, {
|
||||
rate_limits_enabled: 'false',
|
||||
upload_rate_enabled: 'false',
|
||||
feed_rate_enabled: 'false',
|
||||
export_rate_enabled: 'false',
|
||||
join_rate_enabled: 'false',
|
||||
quota_enabled: 'false',
|
||||
storage_quota_enabled: 'false',
|
||||
upload_count_quota_enabled: 'false',
|
||||
});
|
||||
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
await writeFile(join(CACHE_DIR, 'admin-token'), freshAdminToken, 'utf8');
|
||||
console.log('[e2e] global setup complete');
|
||||
}
|
||||
8
e2e/global-teardown.ts
Normal file
8
e2e/global-teardown.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Runs once after all tests. Currently a no-op: we leave the test stack
|
||||
* running so the developer can inspect the post-run state (open the report,
|
||||
* tail logs). Use `npm run stack:down` to actually tear the volumes down.
|
||||
*/
|
||||
export default async function globalTeardown() {
|
||||
// intentionally empty
|
||||
}
|
||||
15
e2e/helpers/fake-media.ts
Normal file
15
e2e/helpers/fake-media.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Camera/microphone faking. Chromium has the `--use-fake-device-for-media-stream`
|
||||
* launch arg (set in playwright.config.ts) which makes `getUserMedia` succeed
|
||||
* with a generated green-rectangle stream. WebKit and Firefox do not — those
|
||||
* UA projects skip camera-path tests entirely.
|
||||
*/
|
||||
import type { BrowserContext } from '@playwright/test';
|
||||
|
||||
export async function grantCameraPermissions(context: BrowserContext, origin: string) {
|
||||
await context.grantPermissions(['camera', 'microphone'], { origin });
|
||||
}
|
||||
|
||||
export function isFakeMediaSupported(browserName: string): boolean {
|
||||
return browserName === 'chromium';
|
||||
}
|
||||
88
e2e/helpers/sse-listener.ts
Normal file
88
e2e/helpers/sse-listener.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Subscribe to /api/v1/stream and collect events. Tests can then assert "a
|
||||
* `like-update` arrived for upload X within 5 seconds" without driving a
|
||||
* second browser tab.
|
||||
*
|
||||
* The backend authenticates the SSE endpoint via `?token=` query param
|
||||
* (the EventSource API can't set headers).
|
||||
*/
|
||||
|
||||
export type SseEvent = { type: string; data: any; receivedAt: number };
|
||||
|
||||
export class SseListener {
|
||||
private controller = new AbortController();
|
||||
private events: SseEvent[] = [];
|
||||
private closed = false;
|
||||
|
||||
constructor(private baseUrl: string = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') {}
|
||||
|
||||
async start(token: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/api/v1/stream?token=${encodeURIComponent(token)}`;
|
||||
// Use fetch with streaming since Node has no EventSource by default.
|
||||
const res = await fetch(url, { signal: this.controller.signal });
|
||||
if (!res.body) throw new Error('SSE response has no body');
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
(async () => {
|
||||
try {
|
||||
while (!this.closed) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// SSE messages are separated by \n\n
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const part of parts) {
|
||||
const evt = parseSseMessage(part);
|
||||
if (evt) this.events.push({ ...evt, receivedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* aborted */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async waitForEvent(
|
||||
type: string,
|
||||
predicate: (e: SseEvent) => boolean = () => true,
|
||||
timeoutMs = 10_000
|
||||
): Promise<SseEvent> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const hit = this.events.find((e) => e.type === type && predicate(e));
|
||||
if (hit) return hit;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`);
|
||||
}
|
||||
|
||||
allEvents(): SseEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.closed = true;
|
||||
this.controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseMessage(raw: string): { type: string; data: any } | null {
|
||||
const lines = raw.split('\n');
|
||||
let type = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) type = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
const dataStr = dataLines.join('\n');
|
||||
let data: any = dataStr;
|
||||
try {
|
||||
data = JSON.parse(dataStr);
|
||||
} catch {
|
||||
/* leave as string */
|
||||
}
|
||||
return { type, data };
|
||||
}
|
||||
46
e2e/helpers/storage-helpers.ts
Normal file
46
e2e/helpers/storage-helpers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Helpers for inspecting and manipulating browser storage from Playwright.
|
||||
* Centralised here so the storage-key strings match auth.ts in one place.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
jwt: 'eventsnap_jwt',
|
||||
pin: 'eventsnap_pin',
|
||||
userId: 'eventsnap_user_id',
|
||||
displayName: 'eventsnap_display_name',
|
||||
guideSeen: 'eventsnap_guide_seen',
|
||||
} as const;
|
||||
|
||||
export async function readStorage(page: Page) {
|
||||
return page.evaluate((keys) => {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const [name, key] of Object.entries(keys)) {
|
||||
out[name] = localStorage.getItem(key as string);
|
||||
}
|
||||
return out;
|
||||
}, STORAGE_KEYS);
|
||||
}
|
||||
|
||||
export async function clearLocalStorage(page: Page) {
|
||||
if (page.url() === 'about:blank') await page.goto('/');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
}
|
||||
|
||||
export async function clearSessionStorage(page: Page) {
|
||||
if (page.url() === 'about:blank') await page.goto('/');
|
||||
await page.evaluate(() => sessionStorage.clear());
|
||||
}
|
||||
|
||||
export async function clearAllStorage(page: Page) {
|
||||
// localStorage is only accessible on a real origin — about:blank throws SecurityError.
|
||||
// Navigate to the app origin first if we're on the blank starting page.
|
||||
if (page.url() === 'about:blank') {
|
||||
await page.goto('/');
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
103
e2e/helpers/touch.ts
Normal file
103
e2e/helpers/touch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Touch-gesture primitives for Phase 3 mobile specs.
|
||||
*
|
||||
* The app uses two custom Svelte actions:
|
||||
*
|
||||
* 1. `longpress` — listens for `pointerdown`, starts a 500 ms timer,
|
||||
* cancels on pointerup / pointermove > 10 px / pointercancel.
|
||||
* [frontend/src/lib/actions/longpress.ts]
|
||||
*
|
||||
* 2. `doubletap` — listens for `pointerup` and times consecutive
|
||||
* releases within 300 ms on roughly the same coordinate.
|
||||
* [frontend/src/lib/actions/doubletap.ts]
|
||||
*
|
||||
* Both listen to pointer events, which fire from mouse, touch, and pen
|
||||
* input. We drive them with `page.mouse` because it works identically
|
||||
* across all Playwright engines and respects the project's mobile
|
||||
* viewport — the action doesn't care whether the underlying device was
|
||||
* touch or mouse.
|
||||
*/
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error(`Cannot get bounding box for locator (not visible?)`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Hold the pointer down on `locator` for `durationMs`. Default 600 ms beats the 500 ms long-press threshold. */
|
||||
export async function longPress(page: Page, locator: Locator, durationMs = 600) {
|
||||
const { x, y } = await centerOf(locator);
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(durationMs);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two rapid pointer-event pairs within the doubletap action's 300 ms window.
|
||||
*
|
||||
* Dispatches synthetic `pointerdown`/`pointerup` directly on the element via
|
||||
* `page.evaluate` (not via `page.mouse`). Why: with real mouse events, the
|
||||
* first tap fires the element's `onclick` synchronously — which on a
|
||||
* FeedListCard image button means the lightbox opens *before* the second tap
|
||||
* lands. That makes mouse-driven double-tap impossible to test in headless
|
||||
* Chromium. Dispatching pointer events bypasses the synthetic click pipeline
|
||||
* entirely, so we can exercise the `doubletap` action's contract directly.
|
||||
*/
|
||||
export async function doubleTap(page: Page, locator: Locator) {
|
||||
await locator.evaluate((el: HTMLElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const mk = (type: string) =>
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
});
|
||||
el.dispatchEvent(mk('pointerdown'));
|
||||
el.dispatchEvent(mk('pointerup'));
|
||||
// Second tap < 300 ms later.
|
||||
return new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
el.dispatchEvent(mk('pointerdown'));
|
||||
el.dispatchEvent(mk('pointerup'));
|
||||
resolve();
|
||||
}, 50)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Touch swipe from start to end coordinates via the touchscreen API (mobile viewports). */
|
||||
export async function swipe(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
steps = 10
|
||||
) {
|
||||
// page.touchscreen.tap exists but doesn't expose move; fall back to dispatching
|
||||
// raw touch events via page.evaluate when needed. For now, mouse-based pointer
|
||||
// events are equivalent because the app's planned swipe handlers (when they
|
||||
// land) will use pointer events too.
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
await page.mouse.move(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Read computed style on a locator. Useful for asserting `padding-bottom` includes env(safe-area-inset-bottom). */
|
||||
export async function computedStyle(locator: Locator, prop: string): Promise<string> {
|
||||
return locator.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop);
|
||||
}
|
||||
|
||||
/** Read the raw inline `style` attribute (env() vars expand only inside computed style, not here). */
|
||||
export async function inlineStyle(locator: Locator): Promise<string> {
|
||||
return locator.evaluate((el) => (el as HTMLElement).getAttribute('style') ?? '');
|
||||
}
|
||||
48
e2e/helpers/upload-client.ts
Normal file
48
e2e/helpers/upload-client.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
|
||||
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
|
||||
* driving the UI. Crucial for MIME-spoofing, oversize, polyglot, and
|
||||
* filename-injection tests.
|
||||
*
|
||||
* Field shape matches [backend/src/handlers/upload.rs]:
|
||||
* - file (binary; carries filename + content_type in the part headers)
|
||||
* - caption (text, optional)
|
||||
* - hashtags (CSV text, optional)
|
||||
*/
|
||||
// Node 22+ ships FormData and Blob as globals — no import needed.
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
|
||||
export type UploadOptions = {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
caption?: string;
|
||||
hashtags?: string;
|
||||
};
|
||||
|
||||
export async function uploadRaw(token: string, body: Uint8Array | Buffer, opts: UploadOptions = {}) {
|
||||
const form = new FormData();
|
||||
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
|
||||
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
||||
if (opts.caption !== undefined) form.append('caption', opts.caption);
|
||||
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
|
||||
return fetch(`${BASE}/api/v1/upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form as any,
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience: read a fixture from disk and upload it. */
|
||||
export async function uploadFile(token: string, path: string, opts: UploadOptions = {}) {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const body = await readFile(path);
|
||||
return uploadRaw(token, body, opts);
|
||||
}
|
||||
|
||||
/** Tiny valid JPEG header — magic bytes only, useful for "claim image but is N MB of zeros" tests. */
|
||||
export const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
|
||||
/** Tiny valid PNG magic bytes. */
|
||||
export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
/** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */
|
||||
export const ELF_MAGIC = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
|
||||
286
e2e/package-lock.json
generated
Normal file
286
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"name": "eventsnap-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "eventsnap-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"pg": "^8.13.1",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
e2e/package.json
Normal file
26
e2e/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "eventsnap-e2e",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Playwright E2E suite for EventSnap. Spins up an isolated docker-compose test stack and exercises the SvelteKit frontend end-to-end.",
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test --grep @smoke",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "PWDEBUG=1 playwright test",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"stack:up": "docker compose -f docker-compose.test.yml up -d --build",
|
||||
"stack:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"stack:logs": "docker compose -f docker-compose.test.yml logs -f",
|
||||
"install:browsers": "playwright install --with-deps chromium firefox webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"pg": "^8.13.1",
|
||||
"@types/pg": "^8.11.10",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
29
e2e/page-objects/account-page.ts
Normal file
29
e2e/page-objects/account-page.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class AccountPage {
|
||||
readonly page: Page;
|
||||
readonly displayName: Locator;
|
||||
readonly pinDisplay: Locator;
|
||||
readonly leaveButton: Locator;
|
||||
readonly leaveConfirmButton: Locator;
|
||||
readonly privacyNote: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.displayName = page.locator('[data-testid="account-display-name"]');
|
||||
this.pinDisplay = page.locator('[data-testid="account-pin"]');
|
||||
this.leaveButton = page.getByRole('button', { name: /event verlassen/i });
|
||||
this.leaveConfirmButton = page.getByRole('button', { name: /^abmelden$/i });
|
||||
this.privacyNote = page.locator('[data-testid="privacy-note"]');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/account');
|
||||
}
|
||||
|
||||
async leaveEvent() {
|
||||
await this.leaveButton.click();
|
||||
await this.leaveConfirmButton.click();
|
||||
await this.page.waitForURL('**/join');
|
||||
}
|
||||
}
|
||||
33
e2e/page-objects/admin-dashboard.ts
Normal file
33
e2e/page-objects/admin-dashboard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class AdminDashboard {
|
||||
readonly page: Page;
|
||||
readonly tabStats: Locator;
|
||||
readonly tabConfig: Locator;
|
||||
readonly tabExport: Locator;
|
||||
readonly tabUsers: Locator;
|
||||
readonly userCount: Locator;
|
||||
readonly uploadCount: Locator;
|
||||
readonly imageSizeInput: Locator;
|
||||
readonly videoSizeInput: Locator;
|
||||
readonly privacyNoteTextarea: Locator;
|
||||
readonly saveConfigButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.tabStats = page.getByRole('button', { name: /stats/i });
|
||||
this.tabConfig = page.getByRole('button', { name: /config/i });
|
||||
this.tabExport = page.getByRole('button', { name: /^export$/i });
|
||||
this.tabUsers = page.getByRole('button', { name: /nutzer/i });
|
||||
this.userCount = page.locator('[data-testid="stat-user-count"]');
|
||||
this.uploadCount = page.locator('[data-testid="stat-upload-count"]');
|
||||
this.imageSizeInput = page.locator('input[name="max_image_size_mb"]');
|
||||
this.videoSizeInput = page.locator('input[name="max_video_size_mb"]');
|
||||
this.privacyNoteTextarea = page.locator('textarea[name="privacy_note"]');
|
||||
this.saveConfigButton = page.getByRole('button', { name: /speichern/i }).first();
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/admin');
|
||||
}
|
||||
}
|
||||
24
e2e/page-objects/admin-login-page.ts
Normal file
24
e2e/page-objects/admin-login-page.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class AdminLoginPage {
|
||||
readonly page: Page;
|
||||
readonly passwordInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.passwordInput = page.getByTestId('admin-password-input');
|
||||
this.submitButton = page.getByTestId('admin-login-submit');
|
||||
this.errorMessage = page.getByTestId('admin-login-error');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/admin/login');
|
||||
}
|
||||
|
||||
async login(password: string) {
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
21
e2e/page-objects/export-page.ts
Normal file
21
e2e/page-objects/export-page.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class ExportPage {
|
||||
readonly page: Page;
|
||||
readonly notAvailableBanner: Locator;
|
||||
readonly zipDownloadButton: Locator;
|
||||
readonly htmlDownloadButton: Locator;
|
||||
readonly htmlGuideModalContinue: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.notAvailableBanner = page.locator('[data-testid="export-not-available"]');
|
||||
this.zipDownloadButton = page.getByRole('button', { name: /zip.*herunter|herunter.*zip/i }).first();
|
||||
this.htmlDownloadButton = page.getByRole('button', { name: /html.*herunter|herunter.*html/i }).first();
|
||||
this.htmlGuideModalContinue = page.getByRole('button', { name: /^herunterladen$/i });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/export');
|
||||
}
|
||||
}
|
||||
47
e2e/page-objects/feed-page.ts
Normal file
47
e2e/page-objects/feed-page.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* FeedPage — the main `/feed` route. Selectors rely on the bottom nav's
|
||||
* aria-labels ("Galerie", "Hochladen", "Konto") and on data-testids that
|
||||
* may be added to FeedListCard / FeedGrid later. Wherever we use a
|
||||
* permissive locator (e.g. `.feed-card`) it is documented; tests that
|
||||
* depend on a specific card should narrow by image alt or caption text.
|
||||
*/
|
||||
export class FeedPage {
|
||||
readonly page: Page;
|
||||
readonly feedTab: Locator;
|
||||
readonly fab: Locator;
|
||||
readonly accountTab: Locator;
|
||||
readonly fabBadge: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.feedTab = page.getByRole('link', { name: 'Galerie' });
|
||||
this.fab = page.getByRole('button', { name: 'Hochladen' });
|
||||
this.accountTab = page.getByRole('link', { name: 'Konto' });
|
||||
// FAB badge — sibling span inside the FAB button.
|
||||
this.fabBadge = this.fab.locator('span.bg-red-500');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/feed');
|
||||
}
|
||||
|
||||
async openUploadSheet() {
|
||||
await this.fab.click();
|
||||
}
|
||||
|
||||
/** Locator for any feed card. Tests should refine with text or image alt. */
|
||||
cards(): Locator {
|
||||
return this.page.locator('[data-testid="feed-card"], article, .feed-card');
|
||||
}
|
||||
|
||||
cardByCaption(text: string): Locator {
|
||||
return this.page.locator('article', { hasText: text }).first();
|
||||
}
|
||||
|
||||
/** Count of currently visible feed cards. */
|
||||
async cardCount(): Promise<number> {
|
||||
return this.cards().count();
|
||||
}
|
||||
}
|
||||
36
e2e/page-objects/host-dashboard.ts
Normal file
36
e2e/page-objects/host-dashboard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class HostDashboard {
|
||||
readonly page: Page;
|
||||
readonly statsSection: Locator;
|
||||
readonly lockEventButton: Locator;
|
||||
readonly unlockEventButton: Locator;
|
||||
readonly releaseGalleryButton: Locator;
|
||||
readonly userSearchInput: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.statsSection = page.locator('[data-testid="host-stats"]');
|
||||
this.lockEventButton = page.getByRole('button', { name: /uploads sperren|event sperren|sperren/i }).first();
|
||||
this.unlockEventButton = page.getByRole('button', { name: /uploads freigeben|entsperren/i }).first();
|
||||
this.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i });
|
||||
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/host');
|
||||
}
|
||||
|
||||
userRow(displayName: string): Locator {
|
||||
return this.page.locator('tr,li,div', { hasText: displayName }).filter({ has: this.page.getByRole('button') }).first();
|
||||
}
|
||||
|
||||
async banUser(displayName: string, hideUploads = false) {
|
||||
const row = this.userRow(displayName);
|
||||
await row.getByRole('button', { name: /sperren/i }).first().click();
|
||||
if (hideUploads) {
|
||||
await this.page.getByRole('checkbox', { name: /ausblenden/i }).check();
|
||||
}
|
||||
await this.page.getByRole('button', { name: /bestätigen|sperren/i }).last().click();
|
||||
}
|
||||
}
|
||||
10
e2e/page-objects/index.ts
Normal file
10
e2e/page-objects/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { JoinPage } from './join-page';
|
||||
export { RecoverPage } from './recover-page';
|
||||
export { AdminLoginPage } from './admin-login-page';
|
||||
export { FeedPage } from './feed-page';
|
||||
export { UploadSheet } from './upload-sheet';
|
||||
export { Lightbox } from './lightbox';
|
||||
export { AccountPage } from './account-page';
|
||||
export { HostDashboard } from './host-dashboard';
|
||||
export { AdminDashboard } from './admin-dashboard';
|
||||
export { ExportPage } from './export-page';
|
||||
61
e2e/page-objects/join-page.ts
Normal file
61
e2e/page-objects/join-page.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class JoinPage {
|
||||
readonly page: Page;
|
||||
readonly nameInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
readonly pinModal: Locator;
|
||||
readonly pinDisplay: Locator;
|
||||
readonly pinCopyButton: Locator;
|
||||
readonly continueButton: Locator;
|
||||
readonly linkToRecover: Locator;
|
||||
// Inline recovery state (shown when the name is taken)
|
||||
readonly recoveryPinInput: Locator;
|
||||
readonly recoverySubmit: Locator;
|
||||
readonly recoveryError: Locator;
|
||||
readonly tryDifferentNameButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.nameInput = page.getByTestId('join-name-input');
|
||||
this.submitButton = page.getByTestId('join-submit');
|
||||
this.errorMessage = page.getByTestId('join-error');
|
||||
this.pinModal = page.getByTestId('pin-modal');
|
||||
this.pinDisplay = page.getByTestId('pin-display');
|
||||
this.pinCopyButton = page.getByTestId('pin-copy');
|
||||
this.continueButton = page.getByTestId('continue-to-feed');
|
||||
this.linkToRecover = page.getByTestId('link-to-recover');
|
||||
this.recoveryPinInput = page.getByTestId('recovery-pin-input');
|
||||
this.recoverySubmit = page.getByTestId('recovery-submit');
|
||||
this.recoveryError = page.getByTestId('recovery-error');
|
||||
this.tryDifferentNameButton = page.getByTestId('try-different-name');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/join');
|
||||
}
|
||||
|
||||
async fillName(name: string) {
|
||||
await this.nameInput.fill(name);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
/** End-to-end: type a name and submit, then either resolve when the PIN modal appears or surface the name-taken UI. */
|
||||
async joinAs(name: string): Promise<{ pin: string }> {
|
||||
await this.fillName(name);
|
||||
await this.submit();
|
||||
await this.pinModal.waitFor({ state: 'visible' });
|
||||
const pin = (await this.pinDisplay.textContent())?.trim() ?? '';
|
||||
return { pin };
|
||||
}
|
||||
|
||||
/** Continue to the gallery after the PIN modal appears. */
|
||||
async continueToFeed() {
|
||||
await this.continueButton.click();
|
||||
await this.page.waitForURL('**/feed');
|
||||
}
|
||||
}
|
||||
49
e2e/page-objects/lightbox.ts
Normal file
49
e2e/page-objects/lightbox.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class Lightbox {
|
||||
readonly page: Page;
|
||||
readonly closeButton: Locator;
|
||||
readonly nextButton: Locator;
|
||||
readonly prevButton: Locator;
|
||||
readonly likeButton: Locator;
|
||||
readonly likeCount: Locator;
|
||||
readonly commentInput: Locator;
|
||||
readonly commentSubmit: Locator;
|
||||
readonly commentsList: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.closeButton = page.getByRole('button', { name: /schließen|close/i });
|
||||
this.nextButton = page.getByRole('button', { name: /nächst|next/i });
|
||||
this.prevButton = page.getByRole('button', { name: /vorherig|previous/i });
|
||||
this.likeButton = page.getByRole('button', { name: /gefällt|like|heart/i }).first();
|
||||
this.likeCount = page.locator('[data-testid="like-count"]').first();
|
||||
this.commentInput = page.getByPlaceholder(/kommentar|comment/i);
|
||||
this.commentSubmit = page.getByRole('button', { name: /senden|send|post/i }).first();
|
||||
this.commentsList = page.locator('[data-testid="comments-list"]');
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.closeButton.click();
|
||||
}
|
||||
|
||||
async like() {
|
||||
await this.likeButton.click();
|
||||
}
|
||||
|
||||
async addComment(text: string) {
|
||||
await this.commentInput.fill(text);
|
||||
await this.commentSubmit.click();
|
||||
}
|
||||
|
||||
/** Swipe gesture support for mobile spec. */
|
||||
async swipeLeft() {
|
||||
const box = await this.page.locator('body').boundingBox();
|
||||
if (!box) return;
|
||||
const y = box.y + box.height / 2;
|
||||
await this.page.mouse.move(box.x + box.width * 0.8, y);
|
||||
await this.page.mouse.down();
|
||||
await this.page.mouse.move(box.x + box.width * 0.2, y, { steps: 10 });
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
}
|
||||
27
e2e/page-objects/recover-page.ts
Normal file
27
e2e/page-objects/recover-page.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
export class RecoverPage {
|
||||
readonly page: Page;
|
||||
readonly nameInput: Locator;
|
||||
readonly pinInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.nameInput = page.getByTestId('recover-name-input');
|
||||
this.pinInput = page.getByTestId('recover-pin-input');
|
||||
this.submitButton = page.getByTestId('recover-submit');
|
||||
this.errorMessage = page.getByTestId('recover-error');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/recover');
|
||||
}
|
||||
|
||||
async recover(name: string, pin: string) {
|
||||
await this.nameInput.fill(name);
|
||||
await this.pinInput.fill(pin);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
}
|
||||
55
e2e/page-objects/upload-sheet.ts
Normal file
55
e2e/page-objects/upload-sheet.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* UploadSheet — the bottom-sheet UploadSheet.svelte + the `/upload` preview
|
||||
* page. Camera button is only useful on Chromium because fake-media is a
|
||||
* Chromium-only launch flag.
|
||||
*
|
||||
* Note on the actual flow: tapping the FAB opens UploadSheet.svelte; tapping
|
||||
* "Galerie" inside it opens the native file picker which sets the staged
|
||||
* files into the pending-upload-store; the app then navigates to /upload
|
||||
* where the user can edit the caption and submit.
|
||||
*/
|
||||
export class UploadSheet {
|
||||
readonly page: Page;
|
||||
readonly fileInput: Locator;
|
||||
readonly cameraButton: Locator;
|
||||
readonly galleryButton: Locator;
|
||||
readonly captionInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
// The file input lives inside the UploadSheet bottom sheet AND inside the
|
||||
// /upload preview page. Both work with setInputFiles. `.first()` keeps strict
|
||||
// mode happy when both happen to exist mid-transition.
|
||||
this.fileInput = page.locator('input[type="file"]').first();
|
||||
this.cameraButton = page.getByRole('button', { name: /kamera/i });
|
||||
this.galleryButton = page.getByRole('button', { name: /galerie/i });
|
||||
this.captionInput = page.getByTestId('upload-caption');
|
||||
this.submitButton = page.getByTestId('upload-submit');
|
||||
}
|
||||
|
||||
/** Stage one or more files via the hidden file input — bypasses the native picker. */
|
||||
async stageFiles(paths: string[]) {
|
||||
await this.fileInput.setInputFiles(paths);
|
||||
}
|
||||
|
||||
async fillCaption(caption: string) {
|
||||
await this.captionInput.fill(caption);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
/** Helper: open sheet, stage one file, fill caption, submit. Returns when navigation back to feed completes. */
|
||||
async uploadOne(page: Page, filePath: string, caption = '') {
|
||||
await this.stageFiles([filePath]);
|
||||
// The app may auto-navigate to /upload; wait for the caption editor to be ready.
|
||||
await this.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
if (caption) await this.fillCaption(caption);
|
||||
await this.submit();
|
||||
await page.waitForURL('**/feed', { timeout: 15_000 });
|
||||
}
|
||||
}
|
||||
139
e2e/playwright.config.ts
Normal file
139
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* EventSnap E2E config.
|
||||
*
|
||||
* Defaults assume the test stack is already up (`npm run stack:up`). The
|
||||
* stack is *not* started by webServer because Playwright would clobber the
|
||||
* compose volumes between retries. CI calls `npm run stack:up` once before
|
||||
* `npm run test:e2e` runs.
|
||||
*
|
||||
* UA matrix: the `@smoke` happy-path runs across every project to catch
|
||||
* engine-level divergences. The rest of the suite only runs against
|
||||
* `chromium-desktop` to keep the wall-clock reasonable.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
outputDir: './test-results',
|
||||
fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel.
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: './playwright-report' }],
|
||||
],
|
||||
globalSetup: './global-setup.ts',
|
||||
globalTeardown: './global-teardown.ts',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 30_000,
|
||||
// Camera/mic permissions granted by default; the fake-media launch args
|
||||
// (set per-project below for Chromium) supply the actual stream.
|
||||
permissions: ['camera', 'microphone', 'clipboard-read', 'clipboard-write'],
|
||||
},
|
||||
|
||||
projects: [
|
||||
// ── Baseline desktop ─────────────────────────────────────────────────
|
||||
{
|
||||
name: 'chromium-desktop',
|
||||
// 09-mobile/ specs only run on the chromium-mobile project below — they
|
||||
// require `hasTouch: true` and a phone viewport.
|
||||
testIgnore: ['**/09-mobile/**'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
args: [
|
||||
'--use-fake-ui-for-media-stream',
|
||||
'--use-fake-device-for-media-stream',
|
||||
// A video file would be loaded with --use-file-for-fake-video-capture
|
||||
// — we ship a tiny .y4m in fixtures/media/ as a follow-up.
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Phase 3 mobile gestures ──────────────────────────────────────────
|
||||
// Runs ONLY the 09-mobile/ specs. Uses the Pixel 7 device descriptor so
|
||||
// hasTouch + isMobile + mobile viewport are all set, then dispatches
|
||||
// pointer events via page.mouse / locator helpers. Real touch events
|
||||
// come from `page.touchscreen` when needed.
|
||||
{
|
||||
name: 'chromium-mobile',
|
||||
testMatch: ['**/09-mobile/**/*.spec.ts'],
|
||||
use: { ...devices['Pixel 7'] },
|
||||
},
|
||||
|
||||
// ── Mobile UA smoke matrix (runs only @smoke specs in CI) ────────────
|
||||
{
|
||||
name: 'chromium-pixel7',
|
||||
use: { ...devices['Pixel 7'] },
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'chromium-galaxy-s22',
|
||||
use: {
|
||||
...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor.
|
||||
viewport: { width: 360, height: 780 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'samsung-internet',
|
||||
use: {
|
||||
...devices['Galaxy S9+'],
|
||||
viewport: { width: 360, height: 780 },
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'edge-android',
|
||||
use: {
|
||||
...devices['Pixel 7'],
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0',
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'chrome-ios',
|
||||
use: {
|
||||
...devices['iPhone 14 Pro'],
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.0.0 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'webkit-iphone',
|
||||
use: { ...devices['iPhone 14 Pro'] },
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'firefox-android',
|
||||
use: {
|
||||
...devices['Pixel 7'],
|
||||
defaultBrowserType: 'firefox',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
||||
},
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: 'firefox-desktop',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
grep: /@smoke/,
|
||||
},
|
||||
],
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user