Compare commits
1 Commits
bbec815854
...
feat/camer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e3097ca40 |
110
.github/workflows/e2e.yml
vendored
110
.github/workflows/e2e.yml
vendored
@@ -1,110 +0,0 @@
|
||||
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
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -8,19 +8,10 @@ backend/target/
|
||||
frontend/node_modules/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
frontend/export-viewer/node_modules/
|
||||
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
|
||||
|
||||
53
FOLLOWUPS.md
53
FOLLOWUPS.md
@@ -1,53 +0,0 @@
|
||||
# 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
|
||||
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.
|
||||
Idea / Planning phase. Greenfield personal project.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 │ │ Media Static Server │ │
|
||||
│ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
|
||||
│ │ │ │ stream │ │ previews, thumbnails) │ │
|
||||
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
|
||||
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
|
||||
│ │ │ │ stream │ │ output, embedded) │ │
|
||||
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
|
||||
@@ -245,14 +245,36 @@ 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:
|
||||
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
|
||||
app:
|
||||
build: ./backend # Multi-stage Rust Dockerfile
|
||||
env_file: .env
|
||||
depends_on: [db]
|
||||
volumes:
|
||||
- media_data:/media
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
env_file: .env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
ports: ["80:80", "443:443"]
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
depends_on: [app]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
media_data:
|
||||
caddy_data:
|
||||
```
|
||||
|
||||
### Caddyfile
|
||||
@@ -323,14 +345,11 @@ 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 |
|
||||
| `like-update` | `{ upload_id, like_count }` | Like toggled |
|
||||
| `new-like` | `{ upload_id, like_count }` | Like toggled |
|
||||
| `upload-deleted` | `{ upload_id }` | Upload deleted |
|
||||
| `event-closed` | `{}` | Host locks uploads |
|
||||
| `event-opened` | `{}` | Host unlocks uploads |
|
||||
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
|
||||
| `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=`
|
||||
|
||||
@@ -353,7 +372,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 | 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 |
|
||||
| Rate Limiting | `tower-governor` | Token-bucket per IP / per user; config from DB; hot-reloadable |
|
||||
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
|
||||
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
|
||||
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |
|
||||
@@ -398,9 +417,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), 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 |
|
||||
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) |
|
||||
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, lock/unlock uploads, release gallery export |
|
||||
| Admin | All Host permissions + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation |
|
||||
| Banned Guest | View feed only — cannot upload, like, comment, or export |
|
||||
|
||||
### Compliance
|
||||
@@ -454,33 +473,37 @@ 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/
|
||||
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
|
||||
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/ ...
|
||||
```
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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
|
||||
**`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.
|
||||
|
||||
**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.
|
||||
**`README.txt`** (in German, as the app's UI language):
|
||||
```
|
||||
Willkommen in der Event-Galerie!
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
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.")
|
||||
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.")
|
||||
|
||||
---
|
||||
|
||||
@@ -602,9 +625,7 @@ 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()
|
||||
-- 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).
|
||||
-- No UNIQUE(event_id, display_name) — PIN disambiguates name collisions
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────
|
||||
@@ -714,18 +735,7 @@ INSERT INTO config (key, value) VALUES
|
||||
('export_rate_per_day', '3'),
|
||||
('quota_tolerance', '0.75'),
|
||||
('estimated_guest_count', '100'),
|
||||
('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
|
||||
('compression_concurrency', '2')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
```
|
||||
|
||||
@@ -1163,11 +1173,7 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
|
||||
|
||||
| Decision | Chosen | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| 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 |
|
||||
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page | Simple for non-technical guests; no email required |
|
||||
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
|
||||
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
|
||||
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
|
||||
@@ -1209,7 +1215,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) |
|
||||
| (custom limiter, no crate) | Token-bucket / sliding window built in-tree at [services/rate_limiter.rs](backend/src/services/rate_limiter.rs) |
|
||||
| `tower-governor` | Token-bucket rate limiting (per IP and per user) |
|
||||
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
|
||||
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
|
||||
| `minijinja` | HTML export template rendering (`Memories.html`) |
|
||||
|
||||
62
README.md
62
README.md
@@ -150,12 +150,12 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
||||
┌───▼────┐
|
||||
│ db │
|
||||
│ :5432 │
|
||||
│(Postgres)│
|
||||
│(Postgres│
|
||||
└────────┘
|
||||
```
|
||||
|
||||
- `/api/*` and `/media/*` → Rust backend
|
||||
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||
- Everything else → SvelteKit frontend
|
||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
||||
|
||||
---
|
||||
@@ -174,57 +174,21 @@ 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)
|
||||
- [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).
|
||||
- [ ] 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
109
TEST_GUIDE.md
109
TEST_GUIDE.md
@@ -1,109 +0,0 @@
|
||||
## Frontend Testing — Step by Step
|
||||
|
||||
Please test each step in order and report any errors (console errors, wrong text, broken UI, API errors).
|
||||
|
||||
### Step 1 — Join flow + PIN modal
|
||||
1. Open **http://localhost:5173/** in your browser (or navigate there if already open)
|
||||
2. You should land on the **join page** (`/join`) with a name input
|
||||
3. Enter your name (e.g. `Max`) and click **Beitreten**
|
||||
4. ✅ Expected: A modal appears showing your 4-digit PIN in large monospace font with a "Kopieren" button
|
||||
5. Click **Weiter zur Galerie**
|
||||
|
||||
### Step 2 — Onboarding guide
|
||||
6. You should land on the **feed page** (`/feed`)
|
||||
7. ✅ Expected: A dark overlay appears at the bottom (or center on desktop) — the onboarding guide — showing step 1 of 4 with a step indicator and the Willkommen screen
|
||||
8. Click **Weiter** through all 4 steps, then **Los geht's!**
|
||||
9. ✅ Expected: Overlay disappears
|
||||
|
||||
### Step 3 — Feed & navigation
|
||||
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. 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. Tap **Kopieren** — check the clipboard contains your PIN
|
||||
15. ✅ Expected: Button briefly shows "Kopiert!"
|
||||
16. Tap the 🏠 **Feed** tab to go back
|
||||
|
||||
### Step 5 — Upload
|
||||
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
|
||||
23. Reload the page at `/feed`
|
||||
24. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
||||
|
||||
### Step 7 — Recover (open a private/incognito window)
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Admin & Host Features
|
||||
|
||||
For these steps you need an admin session. Go to **http://localhost:5173/admin/login** and enter the admin password (`admin123` for the dev environment). You'll be redirected to the dashboard automatically.
|
||||
|
||||
### Step 10 — Admin Dashboard: Stats & Config
|
||||
1. Go to **http://localhost:5173/admin**
|
||||
2. ✅ Expected: Stats card shows user count, upload count, comment count, and a disk-usage progress bar
|
||||
3. In the **Konfiguration** section, change **Upload-Limit pro Stunde** to a different value (e.g. `5`) and click **Speichern**
|
||||
4. ✅ Expected: Toast "Konfiguration gespeichert." appears briefly
|
||||
5. Reload — ✅ Expected: the changed value persists
|
||||
|
||||
### Step 11 — Admin Dashboard: Export Jobs
|
||||
6. The **Export-Jobs** section shows all past jobs (likely empty if gallery hasn't been released yet)
|
||||
7. Click **Aktualisieren** — ✅ Expected: list refreshes without a full page reload
|
||||
|
||||
### Step 12 — Host Dashboard: Event Controls
|
||||
8. Navigate to **http://localhost:5173/host** (or click "Host-Dashboard" from the admin page)
|
||||
9. ✅ Expected: Event name shown in the header; two status dots (Uploads open/locked, Export released/locked)
|
||||
10. Click **Uploads sperren**
|
||||
11. ✅ Expected: Toast "Uploads wurden gesperrt."; button changes to "Uploads wieder öffnen"; status dot turns red
|
||||
12. Try uploading a photo as a guest — ✅ Expected: "Uploads sind gesperrt." error
|
||||
13. Click **Uploads wieder öffnen** — ✅ Expected: dot turns green; uploads work again
|
||||
|
||||
### Step 13 — Host Dashboard: User Management
|
||||
14. The **Gäste** list shows all registered users with upload counts and sizes
|
||||
15. Find a guest and click **Host** next to their name
|
||||
16. ✅ Expected: Toast "X ist jetzt Host."; a blue "Host" badge appears next to their name
|
||||
17. As admin, a **Degradieren** button is now visible — click it
|
||||
18. ✅ Expected: Toast "X ist jetzt Gast."; badge disappears
|
||||
|
||||
### Step 14 — Host Dashboard: Ban & Unban
|
||||
19. Click **Sperren** next to a guest
|
||||
20. ✅ Expected: A confirmation modal opens asking what to do with their uploads, with a checkbox "Uploads aus der Galerie ausblenden"
|
||||
21. Leave the checkbox unchecked and click **Sperren**
|
||||
22. ✅ Expected: Toast "X wurde gesperrt."; a red "Gesperrt" badge appears; buttons change to **Entsperren**
|
||||
23. Try uploading as that banned user — ✅ Expected: "Du bist gesperrt." error
|
||||
24. Click **Entsperren** — ✅ Expected: ban lifted; badge gone
|
||||
|
||||
### Step 15 — Gallery Release & Export
|
||||
25. Make sure you have at least a few photos uploaded, then on the Host Dashboard click **Galerie freigeben**
|
||||
26. ✅ Expected: Toast "Galerie wurde freigegeben. Export wird vorbereitet…"; button becomes disabled "Galerie bereits freigegeben"
|
||||
27. Navigate to **http://localhost:5173/export** as any logged-in user
|
||||
28. ✅ Expected: Two cards — **ZIP-Archiv** and **HTML-Viewer** — both initially showing "Wird vorbereitet…" or a progress bar
|
||||
29. Wait for both to show "Bereit zum Download" (reload or wait for SSE to update the UI)
|
||||
30. Click **Download** on the ZIP card — ✅ Expected: `Gallery.zip` downloads
|
||||
31. Click **Download** on the HTML card — ✅ Expected: A guide modal appears explaining how to open the file; click **Herunterladen** to get `Memories.zip`
|
||||
32. In the Admin Dashboard → **Export-Jobs**, click **Aktualisieren** — ✅ Expected: both jobs show "Fertig" with green badges
|
||||
65
backend/Cargo.lock
generated
65
backend/Cargo.lock
generated
@@ -513,17 +513,6 @@ 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"
|
||||
@@ -907,9 +896,8 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"image",
|
||||
"include_dir",
|
||||
"infer",
|
||||
"jsonwebtoken",
|
||||
"minijinja",
|
||||
"oxipng",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
@@ -919,7 +907,6 @@ dependencies = [
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower_governor",
|
||||
@@ -1016,12 +1003,6 @@ 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"
|
||||
@@ -1595,25 +1576,6 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -1627,15 +1589,6 @@ 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"
|
||||
@@ -1878,6 +1831,12 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1894,6 +1853,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
||||
@@ -17,7 +17,6 @@ bcrypt = "0.15"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-util = { version = "0.7", features = ["io", "compat"] }
|
||||
futures = "0.3"
|
||||
sha2 = "0.10"
|
||||
rand = "0.9"
|
||||
@@ -29,8 +28,7 @@ sysinfo = "0.32"
|
||||
image = "0.25"
|
||||
oxipng = "9"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||
include_dir = "0.7"
|
||||
infer = "0.15"
|
||||
minijinja = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# --- Build stage ---
|
||||
FROM rust:1.88-alpine AS builder
|
||||
FROM rust:1.87-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
|
||||
|
||||
@@ -11,8 +11,6 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||
rm -rf src
|
||||
|
||||
COPY src ./src
|
||||
COPY static ./static
|
||||
COPY migrations ./migrations
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
# --- Runtime stage ---
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_user_event_name_ci;
|
||||
|
||||
CREATE INDEX idx_user_event_name
|
||||
ON "user"(event_id, display_name);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Deduplicate users with the same name (case-insensitive) per event,
|
||||
-- keeping the oldest account so no real data is lost.
|
||||
DELETE FROM "user"
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (event_id, LOWER(display_name)) id
|
||||
FROM "user"
|
||||
ORDER BY event_id, LOWER(display_name), created_at ASC
|
||||
);
|
||||
|
||||
-- Drop the old non-unique index (replaced below)
|
||||
DROP INDEX IF EXISTS idx_user_event_name;
|
||||
|
||||
-- Unique index enforces one account per name per event (case-insensitive)
|
||||
CREATE UNIQUE INDEX idx_user_event_name_ci
|
||||
ON "user" (event_id, LOWER(display_name));
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Remove compression_status field
|
||||
ALTER TABLE upload DROP COLUMN compression_status;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 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)';
|
||||
@@ -1,11 +0,0 @@
|
||||
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'
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use chrono::{Duration, Utc};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -14,8 +12,6 @@ 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::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -33,35 +29,14 @@ pub struct JoinResponse {
|
||||
|
||||
pub async fn join(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<JoinRequest>,
|
||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
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
|
||||
&& !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 display_name = body.display_name.trim();
|
||||
let name_chars = display_name.chars().count();
|
||||
if name_chars == 0 || name_chars > 50 {
|
||||
if display_name.is_empty() || display_name.len() > 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,
|
||||
@@ -70,14 +45,6 @@ pub async fn join(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Reject if a user with this name (case-insensitive) already exists
|
||||
if User::name_taken(&state.pool, event.id, display_name).await? {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Der Name \"{}\" ist bereits vergeben.",
|
||||
display_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Generate a 4-digit PIN
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
@@ -95,7 +62,7 @@ pub async fn join(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
||||
|
||||
Ok((
|
||||
@@ -123,33 +90,10 @@ 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()))?;
|
||||
@@ -164,20 +108,13 @@ pub async fn recover(
|
||||
}
|
||||
|
||||
for user in &users {
|
||||
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt
|
||||
// counter so the user gets a fresh 3-strike window — otherwise the counter
|
||||
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
|
||||
// after waiting out the cooldown. Without this reset, a once-locked account
|
||||
// is effectively permanently fragile.
|
||||
// Check PIN lockout
|
||||
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(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
// Lockout window expired — wipe the counter and the timestamp.
|
||||
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||
}
|
||||
|
||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
||||
@@ -197,7 +134,7 @@ pub async fn recover(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
||||
|
||||
return Ok(Json(RecoverResponse {
|
||||
@@ -208,22 +145,9 @@ 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);
|
||||
let lockout = Utc::now() + Duration::minutes(15);
|
||||
User::lock_pin(&state.pool, user.id, lockout).await?;
|
||||
tracing::warn!(
|
||||
user_id = %user.id,
|
||||
event_id = %event.id,
|
||||
ip = %ip,
|
||||
"recover: account locked for 15 minutes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +166,6 @@ 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() {
|
||||
@@ -251,31 +174,10 @@ pub async fn admin_login(
|
||||
));
|
||||
}
|
||||
|
||||
// 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");
|
||||
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 = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
||||
}
|
||||
|
||||
@@ -292,13 +194,8 @@ pub async fn admin_login(
|
||||
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
|
||||
u
|
||||
} else {
|
||||
// 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 = bcrypt::hash(&dummy_pin, 4)
|
||||
// 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)))?;
|
||||
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||
@@ -310,8 +207,6 @@ 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,
|
||||
@@ -322,7 +217,7 @@ pub async fn admin_login(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(1);
|
||||
let expires_at = Utc::now() + Duration::days(1);
|
||||
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
|
||||
|
||||
Ok(Json(AdminLoginResponse { jwt: token }))
|
||||
|
||||
@@ -13,13 +13,6 @@ 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(
|
||||
@@ -36,7 +29,6 @@ pub fn create_token(
|
||||
role,
|
||||
iat: now.timestamp(),
|
||||
exp: (now + Duration::days(expiry_days)).timestamp(),
|
||||
jti: Uuid::new_v4(),
|
||||
};
|
||||
jsonwebtoken::encode(
|
||||
&Header::default(),
|
||||
|
||||
@@ -43,15 +43,11 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
.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). Failures are
|
||||
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
||||
// pressure that would otherwise be the first symptom of a real problem.
|
||||
// Update last_seen_at in the background (fire-and-forget)
|
||||
let pool = state.pool.clone();
|
||||
let session_id = session.id;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Session::touch(&pool, session_id).await {
|
||||
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
|
||||
}
|
||||
let _ = Session::touch(&pool, session_id).await;
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
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";
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
@@ -20,29 +16,11 @@ 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")?;
|
||||
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
||||
if is_prod {
|
||||
return Err(anyhow!(
|
||||
"Refusing to start in production with the well-known dev JWT_SECRET — \
|
||||
rotate it (openssl rand -hex 64)."
|
||||
));
|
||||
}
|
||||
tracing::warn!(
|
||||
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
|
||||
);
|
||||
} else if jwt_secret.len() < 32 {
|
||||
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.context("DATABASE_URL must be set")?,
|
||||
jwt_secret,
|
||||
jwt_secret: std::env::var("JWT_SECRET")
|
||||
.context("JWT_SECRET must be set")?,
|
||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
|
||||
@@ -2,16 +2,9 @@ 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(max_connections)
|
||||
.max_connections(10)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.context("failed to connect to database")?;
|
||||
@@ -21,6 +14,6 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
.await
|
||||
.context("failed to run database migrations")?;
|
||||
|
||||
tracing::info!(max_connections, "database connected and migrations applied");
|
||||
tracing::info!("database connected and migrations applied");
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
@@ -7,9 +8,7 @@ pub enum AppError {
|
||||
Unauthorized(String),
|
||||
Forbidden(String),
|
||||
NotFound(String),
|
||||
Conflict(String),
|
||||
/// Second field: optional retry-after seconds to include in the response.
|
||||
TooManyRequests(String, Option<u64>),
|
||||
TooManyRequests(String),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -20,8 +19,7 @@ impl AppError {
|
||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||
}
|
||||
}
|
||||
@@ -32,8 +30,7 @@ impl AppError {
|
||||
| Self::Unauthorized(msg)
|
||||
| Self::Forbidden(msg)
|
||||
| Self::NotFound(msg)
|
||||
| Self::Conflict(msg) => msg.clone(),
|
||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||
| Self::TooManyRequests(msg) => msg.clone(),
|
||||
Self::Internal(err) => {
|
||||
tracing::error!("internal error: {err:#}");
|
||||
"Ein interner Fehler ist aufgetreten.".to_string()
|
||||
@@ -45,29 +42,13 @@ impl AppError {
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = self.status_and_code();
|
||||
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
|
||||
Some(*secs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let message = self.message();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
let body = json!({
|
||||
"error": code,
|
||||
"message": message,
|
||||
"status": status.as_u16(),
|
||||
});
|
||||
if let Some(secs) = retry_after_secs {
|
||||
body["retry_after_secs"] = secs.into();
|
||||
}
|
||||
|
||||
let mut resp = (status, axum::Json(body)).into_response();
|
||||
if let Some(secs) = retry_after_secs {
|
||||
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
|
||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
|
||||
}
|
||||
}
|
||||
resp
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StatsDto {
|
||||
pub user_count: i64,
|
||||
pub upload_count: i64,
|
||||
pub comment_count: i64,
|
||||
pub disk_total_bytes: u64,
|
||||
pub disk_used_bytes: u64,
|
||||
pub disk_free_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct ExportJobDto {
|
||||
pub id: uuid::Uuid,
|
||||
pub r#type: String,
|
||||
pub status: String,
|
||||
pub progress_pct: i16,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
) -> Result<Json<StatsDto>, AppError> {
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
let (user_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
|
||||
.bind(event.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (upload_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (comment_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM comment c
|
||||
JOIN upload u ON u.id = c.upload_id
|
||||
WHERE u.event_id = $1 AND c.deleted_at IS NULL",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Disk usage via sysinfo
|
||||
let mut sys = System::new();
|
||||
sys.refresh_all();
|
||||
|
||||
let media_path = state.config.media_path.to_string_lossy().to_string();
|
||||
let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list()
|
||||
.iter()
|
||||
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
|
||||
.map(|d| (d.total_space(), d.available_space()))
|
||||
.unwrap_or_else(|| {
|
||||
// Fall back to the root disk
|
||||
sysinfo::Disks::new_with_refreshed_list()
|
||||
.iter()
|
||||
.find(|d| d.mount_point().to_string_lossy() == "/")
|
||||
.map(|d| (d.total_space(), d.available_space()))
|
||||
.unwrap_or((0, 0))
|
||||
});
|
||||
|
||||
let disk_used = disk_total.saturating_sub(disk_free);
|
||||
|
||||
Ok(Json(StatsDto {
|
||||
user_count,
|
||||
upload_count,
|
||||
comment_count,
|
||||
disk_total_bytes: disk_total,
|
||||
disk_used_bytes: disk_used,
|
||||
disk_free_bytes: disk_free,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_config(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
) -> Result<Json<HashMap<String, String>>, AppError> {
|
||||
let rows: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(rows.into_iter().collect()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PatchConfigRequest(pub HashMap<String, String>);
|
||||
|
||||
pub async fn patch_config(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
Json(body): Json<HashMap<String, String>>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// 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",
|
||||
"feed_rate_per_min",
|
||||
"export_rate_per_day",
|
||||
"quota_tolerance",
|
||||
"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 {
|
||||
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()",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(&state.pool)
|
||||
.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)
|
||||
}
|
||||
|
||||
pub async fn get_export_jobs(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
) -> Result<Json<Vec<ExportJobDto>>, AppError> {
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
let jobs = sqlx::query_as::<_, ExportJobDto>(
|
||||
"SELECT id, type::text, status::text, progress_pct, error_message, created_at, completed_at
|
||||
FROM export_job
|
||||
WHERE event_id = $1
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(jobs))
|
||||
}
|
||||
|
||||
// ── Export download endpoints (authenticated guests) ─────────────────────────
|
||||
|
||||
pub async fn download_zip(
|
||||
State(state): State<AppState>,
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
if !event.export_zip_ready {
|
||||
return Err(AppError::NotFound(
|
||||
"Der ZIP-Export ist noch nicht verfügbar.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = state.config.media_path.join("exports").join("Gallery.zip");
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
serve_file(path, "Gallery.zip", "application/zip").await
|
||||
}
|
||||
|
||||
pub async fn download_html(
|
||||
State(state): State<AppState>,
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
if !event.export_html_ready {
|
||||
return Err(AppError::NotFound(
|
||||
"Der HTML-Export ist noch nicht verfügbar.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = state.config.media_path.join("exports").join("Memories.zip");
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
serve_file(path, "Memories.zip", "application/zip").await
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
path: std::path::PathBuf,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Response, StatusCode};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.header(header::CONTENT_LENGTH, metadata.len())
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Also expose export status to all authenticated users (guests need it for the export page)
|
||||
pub async fn export_status(
|
||||
State(state): State<AppState>,
|
||||
_auth: crate::auth::middleware::AuthUser,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
let released = event.export_released_at.is_some();
|
||||
|
||||
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
|
||||
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
|
||||
)
|
||||
.bind(event.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let job_status = |type_name: &str| {
|
||||
jobs.iter()
|
||||
.find(|(t, _, _)| t == type_name)
|
||||
.map(|(_, status, pct)| {
|
||||
serde_json::json!({ "status": status, "progress_pct": pct })
|
||||
})
|
||||
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"released": released,
|
||||
"zip": job_status("zip"),
|
||||
"html": job_status("html"),
|
||||
})))
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -9,8 +6,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::services::config;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -58,25 +53,8 @@ struct FeedRow {
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<FeedQuery>,
|
||||
) -> Result<Json<FeedResponse>, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
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);
|
||||
|
||||
let rows = if let Some(hashtag) = &q.hashtag {
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::RequireHost;
|
||||
use crate::error::AppError;
|
||||
use crate::models::comment::Comment;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::models::user::UserRole;
|
||||
use crate::state::{AppState, SseEvent};
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct UserSummary {
|
||||
pub id: Uuid,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub is_banned: bool,
|
||||
pub uploads_hidden: bool,
|
||||
pub upload_count: i64,
|
||||
pub total_upload_bytes: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EventStatus {
|
||||
pub name: String,
|
||||
pub is_active: bool,
|
||||
pub uploads_locked: bool,
|
||||
pub export_released: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanRequest {
|
||||
pub hide_uploads: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetRoleRequest {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_event_status(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<Json<EventStatus>, AppError> {
|
||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
Ok(Json(EventStatus {
|
||||
name: event.name,
|
||||
is_active: event.is_active,
|
||||
uploads_locked: event.uploads_locked_at.is_some(),
|
||||
export_released: event.export_released_at.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_users(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
) -> Result<Json<Vec<UserSummary>>, AppError> {
|
||||
let rows = sqlx::query_as::<_, UserSummary>(
|
||||
"SELECT u.id,
|
||||
u.display_name,
|
||||
u.role::text AS role,
|
||||
u.is_banned,
|
||||
u.uploads_hidden,
|
||||
COALESCE(COUNT(up.id), 0) AS upload_count,
|
||||
u.total_upload_bytes,
|
||||
u.created_at
|
||||
FROM \"user\" u
|
||||
LEFT JOIN upload up ON up.user_id = u.id AND up.deleted_at IS NULL
|
||||
WHERE u.event_id = $1
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at ASC",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
pub async fn ban_user(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Json(body): Json<BanRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Cannot ban yourself or another host/admin
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".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()))?;
|
||||
|
||||
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) {
|
||||
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(body.hide_uploads)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
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,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn set_role(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
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(),
|
||||
));
|
||||
}
|
||||
let new_role = match body.role.as_str() {
|
||||
"guest" => "guest",
|
||||
"host" => "host",
|
||||
_ => {
|
||||
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?;
|
||||
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!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET recovery_pin_hash = $1,
|
||||
pin_failed_attempts = 0,
|
||||
pin_locked_until = NULL
|
||||
WHERE id = $2",
|
||||
)
|
||||
.bind(&pin_hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.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,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
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,
|
||||
Path(comment_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn close_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn open_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn release_gallery(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.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()));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Enqueue export jobs
|
||||
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",
|
||||
)
|
||||
.bind(event.id)
|
||||
.bind(export_type)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Spawn export workers
|
||||
crate::services::export::spawn_export_jobs(
|
||||
event.id,
|
||||
event.name,
|
||||
state.pool.clone(),
|
||||
state.config.media_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod host;
|
||||
pub mod me;
|
||||
pub mod social;
|
||||
pub mod sse;
|
||||
pub mod test_admin;
|
||||
pub mod upload;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -52,24 +51,12 @@ 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,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Query(q): Query<ListCommentsQuery>,
|
||||
) -> Result<Json<Vec<CommentDto>>, AppError> {
|
||||
let comments =
|
||||
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
|
||||
let comments = Comment::list_for_upload(&state.pool, upload_id).await?;
|
||||
Ok(Json(comments))
|
||||
}
|
||||
|
||||
@@ -92,8 +79,7 @@ pub async fn add_comment(
|
||||
}
|
||||
|
||||
let text = body.body.trim();
|
||||
let text_chars = text.chars().count();
|
||||
if text_chars == 0 || text_chars > 500 {
|
||||
if text.is_empty() || text.len() > 500 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
|
||||
));
|
||||
|
||||
@@ -3,51 +3,32 @@ 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, Serialize};
|
||||
use serde::Deserialize;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::auth::jwt;
|
||||
use crate::error::AppError;
|
||||
use crate::models::session::Session;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SseQuery {
|
||||
pub ticket: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[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.
|
||||
/// SSE stream endpoint. Accepts JWT via query param since EventSource
|
||||
/// doesn't support custom headers.
|
||||
pub async fn stream(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SseQuery>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
|
||||
let token_hash = state
|
||||
.sse_tickets
|
||||
.consume(&q.ticket)
|
||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||
// Verify token
|
||||
let _claims = jwt::verify_token(&q.token, &state.config.jwt_secret)
|
||||
.map_err(|_| AppError::Unauthorized("Token ungültig.".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()))?
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
//! 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")
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
@@ -11,40 +9,18 @@ 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;
|
||||
|
||||
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. 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
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -53,13 +29,12 @@ pub async fn upload(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
if event.uploads_locked_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
|
||||
// Read config limits from DB
|
||||
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 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 mut file_data: Option<Vec<u8>> = None;
|
||||
let mut file_name: Option<String> = None;
|
||||
@@ -99,35 +74,6 @@ pub async fn upload(
|
||||
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let size = data.len() as i64;
|
||||
|
||||
// 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file MIME type using magic bytes
|
||||
let detected_mime = infer::get(&data);
|
||||
if let Some(detected) = detected_mime {
|
||||
let detected_type = detected.mime_type();
|
||||
// Ensure detected type is compatible with declared MIME type
|
||||
let declared_category = mime.split('/').next().unwrap_or("");
|
||||
let detected_category = detected_type.split('/').next().unwrap_or("");
|
||||
|
||||
// Only reject if categories don't match (e.g., image vs video)
|
||||
if declared_category != "application" && declared_category != detected_category {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}",
|
||||
mime, detected_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
let max_bytes = if mime.starts_with("video/") {
|
||||
max_video_mb * 1024 * 1024
|
||||
@@ -141,24 +87,6 @@ pub async fn upload(
|
||||
)));
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
if quota_on && storage_quota_on {
|
||||
let estimate = compute_storage_quota(&state).await;
|
||||
if let Some(limit) = estimate.limit_bytes {
|
||||
let prospective_total = user.total_upload_bytes.saturating_add(size);
|
||||
if prospective_total > limit {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine file extension
|
||||
let ext = file_name
|
||||
.as_deref()
|
||||
@@ -237,10 +165,10 @@ pub async fn upload(
|
||||
created_at: upload.created_at,
|
||||
};
|
||||
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"new-upload",
|
||||
serde_json::to_string(&dto).unwrap_or_default(),
|
||||
));
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "new-upload".to_string(),
|
||||
data: serde_json::to_string(&dto).unwrap_or_default(),
|
||||
});
|
||||
|
||||
Ok((StatusCode::CREATED, Json(dto)))
|
||||
}
|
||||
@@ -298,116 +226,12 @@ pub async fn delete_upload(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
|
||||
/// Without draining, the client may still be sending the body after we've sent our response,
|
||||
/// which can corrupt the keep-alive connection for subsequent requests.
|
||||
async fn drain_multipart(mut mp: Multipart) {
|
||||
while let Ok(Some(mut field)) = mp.next_field().await {
|
||||
while field.chunk().await.ok().flatten().is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
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((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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming download of the original file behind an upload. Used by:
|
||||
/// - the per-post "Original anzeigen" context action (`window.open`)
|
||||
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
||||
/// Data Mode = Original
|
||||
///
|
||||
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
|
||||
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
|
||||
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
|
||||
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
|
||||
/// and `window.open`, defeating the purpose of having the alias.
|
||||
pub async fn get_original(
|
||||
State(state): State<AppState>,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
let absolute = state.config.media_path.join(&upload.original_path);
|
||||
if !absolute.exists() {
|
||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Response, StatusCode};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(&absolute)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
let filename = absolute
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("original");
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, upload.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.header(header::CONTENT_LENGTH, metadata.len())
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| AppError::Internal(e.into()))
|
||||
.unwrap_or(None);
|
||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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};
|
||||
|
||||
mod auth;
|
||||
@@ -31,22 +29,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let pool = db::create_pool(&config.database_url).await?;
|
||||
|
||||
// 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(),
|
||||
);
|
||||
let state = AppState::new(pool, config.clone());
|
||||
|
||||
// Ensure media directories exist
|
||||
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
||||
@@ -57,20 +40,12 @@ 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
|
||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
||||
.route_layer(DefaultBodyLimit::disable()))
|
||||
// Upload
|
||||
.route("/api/v1/upload", post(handlers::upload::upload))
|
||||
.route(
|
||||
"/api/v1/upload/{id}",
|
||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/upload/{id}/original",
|
||||
get(handlers::upload::get_original),
|
||||
)
|
||||
// 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))
|
||||
@@ -83,51 +58,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))
|
||||
.route("/api/v1/host/event/open", post(handlers::host::open_event))
|
||||
.route("/api/v1/host/gallery/release", post(handlers::host::release_gallery))
|
||||
.route("/api/v1/host/users", get(handlers::host::list_users))
|
||||
.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)
|
||||
.route("/api/v1/export/status", get(handlers::admin::export_status))
|
||||
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
|
||||
.route("/api/v1/export/html", get(handlers::admin::download_html))
|
||||
// Admin Dashboard
|
||||
.route("/api/v1/admin/stats", get(handlers::admin::get_stats))
|
||||
.route(
|
||||
"/api/v1/admin/config",
|
||||
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
|
||||
)
|
||||
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
|
||||
|
||||
// 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
|
||||
};
|
||||
.route("/api/v1/stream", get(handlers::sse::stream));
|
||||
|
||||
// Serve media files from disk
|
||||
let media_service = ServeDir::new(&config.media_path);
|
||||
@@ -136,7 +67,6 @@ async fn main() -> Result<()> {
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
.nest_service("/media", media_service)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
||||
|
||||
@@ -40,35 +40,15 @@ impl Comment {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub async fn list_for_upload(pool: &PgPool, upload_id: Uuid) -> Result<Vec<CommentDto>, sqlx::Error> {
|
||||
sqlx::query_as::<_, CommentDto>(
|
||||
"SELECT * FROM (
|
||||
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
|
||||
c.body, c.created_at
|
||||
"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",
|
||||
ORDER BY c.created_at ASC",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.bind(before)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
@@ -89,25 +69,4 @@ 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,46 +67,11 @@ impl Hashtag {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Extract #hashtags from text (caption or body).
|
||||
pub fn extract_hashtags(text: &str) -> Vec<String> {
|
||||
const MAX_TAG_LEN: usize = 40;
|
||||
text.split_whitespace()
|
||||
.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)
|
||||
.filter(|w| w.starts_with('#') && w.len() > 1)
|
||||
.map(|w| w.trim_start_matches('#').to_lowercase())
|
||||
.filter(|t| !t.is_empty())
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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>>,
|
||||
}
|
||||
@@ -69,23 +68,6 @@ 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,
|
||||
@@ -112,76 +94,14 @@ impl Upload {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes",
|
||||
)
|
||||
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if let Some((user_id, bytes)) = 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?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||
/// can return a clean 404 instead of silently no-op'ing.
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64)> = 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",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let deleted = if let Some((user_id, bytes)) = 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?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub async fn update_caption(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
@@ -194,17 +114,4 @@ 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
|
||||
pub enum UserRole {
|
||||
Guest,
|
||||
@@ -12,16 +11,6 @@ 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,
|
||||
@@ -69,7 +58,7 @@ impl User {
|
||||
display_name: &str,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)",
|
||||
"SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(display_name)
|
||||
@@ -77,21 +66,6 @@ impl User {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn name_taken(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
display_name: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row: (bool,) = sqlx::query_as(
|
||||
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(display_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
|
||||
let row: (i16,) = sqlx::query_as(
|
||||
"UPDATE \"user\"
|
||||
|
||||
@@ -3,27 +3,24 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::{broadcast, Semaphore};
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::SseEvent;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompressionWorker {
|
||||
semaphore: Arc<Semaphore>,
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
}
|
||||
|
||||
impl CompressionWorker {
|
||||
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
|
||||
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize) -> Self {
|
||||
Self {
|
||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||
pool,
|
||||
media_path,
|
||||
sse_tx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,22 +29,8 @@ impl CompressionWorker {
|
||||
let worker = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = worker.semaphore.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) => {
|
||||
if let Err(e) = worker.do_process(upload_id, &original_path, &mime_type).await {
|
||||
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": e.to_string() }).to_string(),
|
||||
});
|
||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -58,8 +41,6 @@ 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/") {
|
||||
@@ -72,7 +53,6 @@ impl CompressionWorker {
|
||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
||||
}
|
||||
|
||||
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -132,11 +112,7 @@ impl CompressionWorker {
|
||||
let thumb_filename = format!("{upload_id}.jpg");
|
||||
let thumb_path = thumbs_dir.join(&thumb_filename);
|
||||
|
||||
// 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")
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
original.to_str().unwrap_or_default(),
|
||||
@@ -149,36 +125,13 @@ impl CompressionWorker {
|
||||
"-y",
|
||||
thumb_path.to_str().unwrap_or_default(),
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("failed to spawn ffmpeg")?;
|
||||
|
||||
let status = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
child.wait(),
|
||||
)
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
anyhow::bail!("ffmpeg timeout after 120s");
|
||||
}
|
||||
};
|
||||
.context("failed to run ffmpeg")?;
|
||||
|
||||
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)
|
||||
);
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("ffmpeg failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(format!("thumbnails/{thumb_filename}"))
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::io::{copy as fcopy, AllowStdIo};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::compat::TokioAsyncReadCompatExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::SseEvent;
|
||||
|
||||
// ── Embedded viewer assets (pre-built SvelteKit static output) ──────────────
|
||||
|
||||
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
|
||||
|
||||
// ── DB query rows ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExportUploadRow {
|
||||
id: Uuid,
|
||||
original_path: String,
|
||||
mime_type: String,
|
||||
caption: Option<String>,
|
||||
uploader_name: String,
|
||||
like_count: i64,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExportCommentRow {
|
||||
upload_id: Uuid,
|
||||
uploader_name: String,
|
||||
body: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ── Viewer JSON structs (serialised to data.json) ───────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerData {
|
||||
event: ViewerEvent,
|
||||
posts: Vec<ViewerPost>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerEvent {
|
||||
name: String,
|
||||
exported_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerPost {
|
||||
id: String,
|
||||
uploader: String,
|
||||
caption: String,
|
||||
tags: Vec<String>,
|
||||
timestamp: String,
|
||||
likes: i64,
|
||||
comments: Vec<ViewerComment>,
|
||||
media: ViewerMedia,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerComment {
|
||||
author: String,
|
||||
text: String,
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerMedia {
|
||||
#[serde(rename = "type")]
|
||||
media_type: String,
|
||||
thumb: String,
|
||||
full: String,
|
||||
}
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn spawn_export_jobs(
|
||||
event_id: Uuid,
|
||||
event_name: String,
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) {
|
||||
let pool2 = pool.clone();
|
||||
let media_path2 = media_path.clone();
|
||||
let sse_tx2 = sse_tx.clone();
|
||||
let event_name2 = event_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_zip_export(event_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;
|
||||
}
|
||||
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
run_html_export(event_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;
|
||||
}
|
||||
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ── ZIP export ───────────────────────────────────────────────────────────────
|
||||
|
||||
async fn run_zip_export(
|
||||
event_id: Uuid,
|
||||
pool: &PgPool,
|
||||
media_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
mark_running(pool, event_id, "zip").await;
|
||||
|
||||
let uploads = query_uploads(pool, event_id).await?;
|
||||
let total = uploads.len().max(1) as f32;
|
||||
|
||||
let exports_dir = media_path.join("exports");
|
||||
tokio::fs::create_dir_all(&exports_dir).await?;
|
||||
|
||||
let tmp_path = exports_dir.join("Gallery.zip.tmp");
|
||||
let out_path = exports_dir.join("Gallery.zip");
|
||||
|
||||
{
|
||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||
let mut zip = ZipFileWriter::with_tokio(file);
|
||||
|
||||
for (i, row) in uploads.iter().enumerate() {
|
||||
let src = media_path.join(&row.original_path);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
let ext = ext_from_path(&row.original_path);
|
||||
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
|
||||
let name_safe = sanitize_name(&row.uploader_name);
|
||||
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
|
||||
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
|
||||
|
||||
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
|
||||
let mut f = tokio::fs::File::open(&src).await?.compat();
|
||||
fcopy(&mut f, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
|
||||
let pct = ((i + 1) as f32 / total * 100.0) as i16;
|
||||
update_progress(pool, event_id, "zip", pct.min(99)).await;
|
||||
}
|
||||
|
||||
zip.close().await?;
|
||||
}
|
||||
|
||||
tokio::fs::rename(&tmp_path, &out_path).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = 'zip'::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind("exports/Gallery.zip")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let _ = sse_tx.send(SseEvent {
|
||||
event_type: "export-progress".to_string(),
|
||||
data: serde_json::json!({ "type": "zip", "progress_pct": 100 }).to_string(),
|
||||
});
|
||||
|
||||
tracing::info!("ZIP export complete for event {event_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── HTML viewer export ──────────────────────────────────────────────────────
|
||||
|
||||
async fn run_html_export(
|
||||
event_id: Uuid,
|
||||
event_name: &str,
|
||||
pool: &PgPool,
|
||||
media_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
mark_running(pool, event_id, "html").await;
|
||||
|
||||
// 1. Query data
|
||||
let uploads = query_uploads(pool, event_id).await?;
|
||||
let comments = query_comments(pool, event_id).await?;
|
||||
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
|
||||
let total = uploads.len().max(1) as f32;
|
||||
|
||||
update_progress(pool, event_id, "html", 5).await;
|
||||
|
||||
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}"));
|
||||
let media_tmp = tmp_dir.join("media");
|
||||
tokio::fs::create_dir_all(&media_tmp).await?;
|
||||
|
||||
// 3. Process media and build post data
|
||||
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
|
||||
|
||||
for (i, row) in uploads.iter().enumerate() {
|
||||
let src = media_path.join(&row.original_path);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_video = row.mime_type.starts_with("video/");
|
||||
let id_str = row.id.to_string();
|
||||
|
||||
// Generate thumbnail and full variant
|
||||
let (thumb_name, full_name) = if is_video {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let full_ext = ext_from_path(&row.original_path);
|
||||
let full = format!("{id_str}.{full_ext}");
|
||||
|
||||
// Video thumbnail via ffmpeg
|
||||
let thumb_path = media_tmp.join(&thumb);
|
||||
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
src.to_str().unwrap_or_default(),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vf",
|
||||
"scale=400:-1",
|
||||
"-y",
|
||||
thumb_path.to_str().unwrap_or_default(),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match ffmpeg_result {
|
||||
Ok(output) if output.status.success() => {}
|
||||
_ => {
|
||||
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
|
||||
// Create empty thumb entry — viewer handles missing thumbs gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Copy video as-is
|
||||
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
|
||||
|
||||
(thumb, full)
|
||||
} else {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let ext = ext_from_path(&row.original_path);
|
||||
let full = format!("{id_str}_full.{ext}");
|
||||
|
||||
// Image thumbnail: resize to 400px wide
|
||||
let src_clone = src.clone();
|
||||
let thumb_path = media_tmp.join(&thumb);
|
||||
let thumb_path_clone = thumb_path.clone();
|
||||
|
||||
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
|
||||
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
|
||||
resized
|
||||
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
|
||||
.context("failed to save thumbnail")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Err(e) = thumb_result {
|
||||
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
|
||||
}
|
||||
|
||||
// Full variant: compress if >5MB, otherwise copy original
|
||||
let src_meta = tokio::fs::metadata(&src).await?;
|
||||
let full_path = media_tmp.join(&full);
|
||||
|
||||
if src_meta.len() > 5_000_000 {
|
||||
// Resize to max 2000px
|
||||
let src_clone = src.clone();
|
||||
let full_path_clone = full_path.clone();
|
||||
|
||||
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let img =
|
||||
image::open(&src_clone).context("failed to open image for compression")?;
|
||||
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
|
||||
resized
|
||||
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
|
||||
.context("failed to save compressed full image")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Err(e) = compress_result {
|
||||
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
|
||||
tokio::fs::copy(&src, &full_path).await?;
|
||||
}
|
||||
} else {
|
||||
tokio::fs::copy(&src, &full_path).await?;
|
||||
}
|
||||
|
||||
(thumb, full)
|
||||
};
|
||||
|
||||
// Build comments for this upload
|
||||
let post_comments: Vec<ViewerComment> = comments
|
||||
.iter()
|
||||
.filter(|c| c.upload_id == row.id)
|
||||
.map(|c| ViewerComment {
|
||||
author: c.uploader_name.clone(),
|
||||
text: c.body.clone(),
|
||||
timestamp: c.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build tags for this upload
|
||||
let tags: Vec<String> = hashtags_per_upload
|
||||
.iter()
|
||||
.filter(|(uid, _)| *uid == row.id)
|
||||
.map(|(_, tag)| tag.clone())
|
||||
.collect();
|
||||
|
||||
viewer_posts.push(ViewerPost {
|
||||
id: id_str,
|
||||
uploader: row.uploader_name.clone(),
|
||||
caption: row.caption.clone().unwrap_or_default(),
|
||||
tags,
|
||||
timestamp: row.created_at.to_rfc3339(),
|
||||
likes: row.like_count,
|
||||
comments: post_comments,
|
||||
media: ViewerMedia {
|
||||
media_type: if is_video {
|
||||
"video".to_string()
|
||||
} else {
|
||||
"image".to_string()
|
||||
},
|
||||
thumb: format!("media/{thumb_name}"),
|
||||
full: format!("media/{full_name}"),
|
||||
},
|
||||
});
|
||||
|
||||
let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
|
||||
update_progress(pool, event_id, "html", pct.min(69)).await;
|
||||
}
|
||||
|
||||
// 4. Build data.json
|
||||
let viewer_data = ViewerData {
|
||||
event: ViewerEvent {
|
||||
name: event_name.to_string(),
|
||||
exported_at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
posts: viewer_posts,
|
||||
};
|
||||
let data_json =
|
||||
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
||||
|
||||
update_progress(pool, event_id, "html", 72).await;
|
||||
|
||||
// 5. Create ZIP
|
||||
let tmp_path = exports_dir.join("Memories.zip.tmp");
|
||||
let out_path = exports_dir.join("Memories.zip");
|
||||
|
||||
{
|
||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||
let mut zip = ZipFileWriter::with_tokio(file);
|
||||
|
||||
// Write embedded viewer assets (index.html, _app/*, etc.)
|
||||
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
|
||||
|
||||
update_progress(pool, event_id, "html", 75).await;
|
||||
|
||||
// Write data.json
|
||||
{
|
||||
let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
}
|
||||
|
||||
// Write README.txt
|
||||
{
|
||||
let builder = ZipEntryBuilder::new("README.txt".into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
}
|
||||
|
||||
update_progress(pool, event_id, "html", 78).await;
|
||||
|
||||
// Write media files from temp directory
|
||||
let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
|
||||
let mut file_count = 0u32;
|
||||
let mut files_written = 0u32;
|
||||
|
||||
// Count files first
|
||||
{
|
||||
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
|
||||
while counter.next_entry().await?.is_some() {
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
let file_total = file_count.max(1) as f32;
|
||||
|
||||
while let Some(dir_entry) = media_entries.next_entry().await? {
|
||||
let filename = dir_entry.file_name();
|
||||
let entry_name = format!("media/{}", filename.to_string_lossy());
|
||||
|
||||
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
|
||||
let mut zip_entry = zip.write_entry_stream(builder).await?;
|
||||
let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
|
||||
fcopy(&mut f, &mut zip_entry).await?;
|
||||
zip_entry.close().await?;
|
||||
|
||||
files_written += 1;
|
||||
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
|
||||
update_progress(pool, event_id, "html", pct.min(98)).await;
|
||||
}
|
||||
|
||||
zip.close().await?;
|
||||
}
|
||||
|
||||
// 6. Finalise
|
||||
tokio::fs::rename(&tmp_path, &out_path).await?;
|
||||
|
||||
// Clean up temp directory
|
||||
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = 'html'::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind("exports/Memories.zip")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let _ = sse_tx.send(SseEvent {
|
||||
event_type: "export-progress".to_string(),
|
||||
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
|
||||
});
|
||||
|
||||
tracing::info!("HTML viewer export complete for event {event_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── DB helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
|
||||
Ok(sqlx::query_as::<_, ExportUploadRow>(
|
||||
"SELECT u.id, u.original_path, u.mime_type, u.caption,
|
||||
usr.display_name AS uploader_name,
|
||||
COUNT(DISTINCT l.user_id) AS like_count,
|
||||
u.created_at
|
||||
FROM upload u
|
||||
JOIN \"user\" usr ON usr.id = u.user_id
|
||||
LEFT JOIN \"like\" l ON l.upload_id = u.id
|
||||
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
|
||||
GROUP BY u.id, usr.display_name
|
||||
ORDER BY u.created_at ASC",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_all(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn query_comments(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportCommentRow>> {
|
||||
Ok(sqlx::query_as::<_, ExportCommentRow>(
|
||||
"SELECT c.upload_id, usr.display_name AS uploader_name, c.body, c.created_at
|
||||
FROM comment c
|
||||
JOIN \"user\" usr ON usr.id = c.user_id
|
||||
JOIN upload u ON u.id = c.upload_id
|
||||
WHERE u.event_id = $1 AND c.deleted_at IS NULL AND u.deleted_at IS NULL
|
||||
ORDER BY c.created_at ASC",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_all(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, String)>> {
|
||||
let rows: Vec<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT uh.upload_id, h.tag
|
||||
FROM upload_hashtag uh
|
||||
JOIN hashtag h ON h.id = uh.hashtag_id
|
||||
JOIN upload u ON u.id = uh.upload_id
|
||||
WHERE h.event_id = $1 AND u.deleted_at IS NULL",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE export_job SET status = 'failed', error_message = $3
|
||||
WHERE event_id = $1 AND type = $2::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(msg)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(pct)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn maybe_broadcast_complete(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) {
|
||||
let row: Option<(bool, bool)> = sqlx::query_as(
|
||||
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some((zip_ready, html_ready)) = row {
|
||||
if zip_ready && html_ready {
|
||||
let _ = sse_tx.send(SseEvent {
|
||||
event_type: "export-available".to_string(),
|
||||
data: serde_json::json!({ "types": ["zip", "html"] }).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
|
||||
async fn write_dir_to_zip(
|
||||
dir: &include_dir::Dir<'_>,
|
||||
zip: &mut ZipFileWriter<tokio::fs::File>,
|
||||
) -> Result<()> {
|
||||
for file in dir.files() {
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
let contents = file.contents();
|
||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
}
|
||||
for sub_dir in dir.dirs() {
|
||||
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ext_from_path(path: &str) -> &str {
|
||||
path.rsplit('.').next().unwrap_or("bin")
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Static content ───────────────────────────────────────────────────────────
|
||||
|
||||
const README_TEXT: &str = "EventSnap Offline-Galerie\n\
|
||||
\n\
|
||||
So geht's:\n\
|
||||
1. Entpacke diese ZIP-Datei\n\
|
||||
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
|
||||
Handy: Dateimanager-App verwenden).\n\
|
||||
2. Öffne \"index.html\" im Browser\n\
|
||||
(z. B. Chrome, Safari oder Firefox).\n\
|
||||
3. Stöbere durch alle Fotos und Videos.\n\
|
||||
Du kannst zwischen Listen- und Rasteransicht wechseln,\n\
|
||||
nach Hashtags filtern und nach Nutzern suchen.\n\
|
||||
4. Eine Internetverbindung ist nicht nötig.\n\
|
||||
Alles ist lokal auf deinem Gerät gespeichert.\n\
|
||||
\n\
|
||||
Viel Freude mit den Erinnerungen!\n";
|
||||
@@ -1,73 +0,0 @@
|
||||
//! 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<()>;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//! 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::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,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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:#}"),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1 @@
|
||||
pub mod compression;
|
||||
pub mod config;
|
||||
pub mod export;
|
||||
pub mod jobs;
|
||||
pub mod maintenance;
|
||||
pub mod rate_limiter;
|
||||
pub mod sse_tickets;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Thread-safe sliding-window rate limiter backed by an in-memory HashMap.
|
||||
/// Each key (e.g. `"join:{ip}"` or `"upload:{user_id}"`) tracks timestamps
|
||||
/// of recent requests and rejects new ones once the window is full.
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
windows: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the request is allowed, `false` if rate-limited.
|
||||
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
|
||||
self.check_with_retry(key, max, window).is_ok()
|
||||
}
|
||||
|
||||
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
|
||||
/// `retry_after_secs` is how long until the oldest slot in the window expires.
|
||||
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> {
|
||||
let now = Instant::now();
|
||||
let key = key.into();
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let timestamps = map.entry(key).or_default();
|
||||
timestamps.retain(|&t| now.duration_since(t) < window);
|
||||
if timestamps.len() < max {
|
||||
timestamps.push(now);
|
||||
Ok(())
|
||||
} else {
|
||||
// The oldest timestamp expires at oldest + window; compute remaining seconds
|
||||
let oldest = timestamps[0];
|
||||
let elapsed = now.duration_since(oldest);
|
||||
let remaining = window.saturating_sub(elapsed);
|
||||
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.
|
||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -3,8 +3,6 @@ use tokio::sync::broadcast;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::services::compression::CompressionWorker;
|
||||
use crate::services::rate_limiter::RateLimiter;
|
||||
use crate::services::sse_tickets::SseTicketStore;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SseEvent {
|
||||
@@ -12,39 +10,24 @@ 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,
|
||||
pub config: AppConfig,
|
||||
pub sse_tx: broadcast::Sender<SseEvent>,
|
||||
pub compression: CompressionWorker,
|
||||
pub rate_limiter: RateLimiter,
|
||||
pub sse_tickets: SseTicketStore,
|
||||
}
|
||||
|
||||
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, sse_tx.clone());
|
||||
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2);
|
||||
Self {
|
||||
pool,
|
||||
config,
|
||||
sse_tx,
|
||||
compression,
|
||||
rate_limiter: RateLimiter::new(),
|
||||
sse_tickets: SseTicketStore::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const env={}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};
|
||||
@@ -1 +0,0 @@
|
||||
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};
|
||||
@@ -1 +0,0 @@
|
||||
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};
|
||||
@@ -1 +0,0 @@
|
||||
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};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"version":"1778876725548"}
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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.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">
|
||||
<link href="/_app/immutable/chunks/BRDva_z9.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_19z1hjw = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.YjNZv4co.js"),
|
||||
import("/_app/immutable/entry/app.BTH3knpg.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,196 +0,0 @@
|
||||
# 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,288 +0,0 @@
|
||||
# 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
|
||||
of the live EventSnap feed. Opening `index.html` in any modern browser shows the full event
|
||||
gallery — list view, grid view, search, filter, lightbox — with no internet connection or
|
||||
server required.
|
||||
|
||||
It **replaces the current HTML export job type**. The old HTML export produced a raw
|
||||
minijinja-rendered template; the new viewer supersedes it entirely. The existing `html`
|
||||
job variant in the backend is repurposed to run this flow instead of being kept alongside
|
||||
it.
|
||||
|
||||
It is distinct from the ZIP archive export (which is raw media files). The viewer is a
|
||||
polished, navigable web app bundled with the event's content.
|
||||
|
||||
---
|
||||
|
||||
## User-Facing Behavior
|
||||
|
||||
- Download `event_name_viewer.zip`, unzip, open `index.html`
|
||||
- Full list view (chronological, newest first) and grid view with search/filter
|
||||
- Likes, comments, and reaction counts shown (static snapshot from export time)
|
||||
- Read-only: no uploads, no auth, no dashboards
|
||||
- Works offline, no CDN or external resources
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Separate Static SvelteKit App
|
||||
|
||||
A new mini SvelteKit project lives at `frontend/export-viewer/` within the same monorepo.
|
||||
It uses `adapter-static` and is kept completely independent of the main app.
|
||||
|
||||
**Why separate rather than a shared route:**
|
||||
- The viewer must be distributable as a standalone static bundle; the main app uses
|
||||
`adapter-node` and cannot be mixed
|
||||
- Keeping it separate avoids auth, store, and routing dependencies leaking in
|
||||
- Simpler to reason about: the viewer has exactly two concerns (list view, grid view)
|
||||
|
||||
**Why same repo:**
|
||||
- Shares Tailwind config and design tokens → visual parity with the main app
|
||||
- Single `pnpm` workspace, no separate CI needed
|
||||
- Backend can reference the pre-built output by relative path
|
||||
|
||||
### Pre-Built Output Committed to Repo
|
||||
|
||||
The viewer is built once and its output committed to `backend/static/export-viewer/`.
|
||||
The backend export job **does not run a Node build** at runtime — it just copies the
|
||||
pre-built assets and injects event data alongside them.
|
||||
|
||||
When the viewer source changes, a developer rebuilds it locally (`pnpm build` in
|
||||
`frontend/export-viewer/`) and commits the updated `backend/static/export-viewer/` output.
|
||||
|
||||
---
|
||||
|
||||
## ZIP Structure
|
||||
|
||||
```
|
||||
event_name_viewer.zip/
|
||||
├── index.html ← entry point; open this in any browser
|
||||
├── _app/
|
||||
│ └── immutable/
|
||||
│ ├── viewer.[hash].js ← all Svelte/app logic, single bundle
|
||||
│ └── viewer.[hash].css ← all styles including Tailwind output
|
||||
├── data.json ← injected by backend at export time
|
||||
└── media/
|
||||
├── abc123_thumb.jpg ← ~400 px wide, used in grid cells
|
||||
├── abc123_full.jpg ← original or capped (see Media Strategy)
|
||||
├── def456_thumb.jpg
|
||||
└── def456.mp4 ← videos included as-is
|
||||
```
|
||||
|
||||
No external font CDN, no Google Fonts, no remote scripts. All assets are local.
|
||||
|
||||
---
|
||||
|
||||
## data.json Schema
|
||||
|
||||
Generated by the backend export job. The viewer fetches this file on startup via
|
||||
`fetch('./data.json')` (relative path, works from filesystem).
|
||||
|
||||
```json
|
||||
{
|
||||
"event": {
|
||||
"name": "Sommerfest 2025",
|
||||
"exported_at": "2025-07-15T20:00:00Z"
|
||||
},
|
||||
"posts": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"uploader": "MaxMustermann",
|
||||
"caption": "Tolle Stimmung! #party #spaß",
|
||||
"tags": ["party", "spaß"],
|
||||
"timestamp": "2025-07-15T18:30:00Z",
|
||||
"likes": 12,
|
||||
"comments": [
|
||||
{
|
||||
"author": "AnnaSchulz",
|
||||
"text": "So schön!",
|
||||
"timestamp": "2025-07-15T18:35:00Z"
|
||||
}
|
||||
],
|
||||
"media": [
|
||||
{
|
||||
"type": "image",
|
||||
"thumb": "media/abc123_thumb.jpg",
|
||||
"full": "media/abc123_full.jpg",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All post data (likes, comments, tags) reflects the state at export time. No live updates.
|
||||
|
||||
---
|
||||
|
||||
## Media Strategy
|
||||
|
||||
### Images
|
||||
|
||||
| Variant | Purpose | Max dimension | Format |
|
||||
|---------|---------|---------------|--------|
|
||||
| `_thumb` | Grid cells, list post thumbnail | 400 px wide | JPEG q75 |
|
||||
| `_full` | Lightbox / full-screen view | Original, or 2000 px cap if >5 MB | JPEG q85 |
|
||||
|
||||
The backend applies compression only when the original exceeds a threshold (e.g. >5 MB for
|
||||
images). Below that threshold the original is used as `_full` unchanged.
|
||||
|
||||
The full-resolution original is always available via the separate ZIP archive export.
|
||||
|
||||
### Videos
|
||||
|
||||
Included as-is (no server-side transcoding). The viewer uses a standard `<video>` element.
|
||||
The `_thumb` variant for videos is a JPEG frame extracted at the 1-second mark.
|
||||
|
||||
---
|
||||
|
||||
## SvelteKit SSG Configuration
|
||||
|
||||
```
|
||||
// frontend/export-viewer/src/routes/+layout.ts
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
```
|
||||
|
||||
`ssr = false` produces a pure client-side SPA: SvelteKit emits a minimal `index.html` shell
|
||||
and the JavaScript bundle hydrates it entirely in the browser. This is correct for a ZIP
|
||||
distribution where no server exists to handle SSR.
|
||||
|
||||
```
|
||||
// frontend/export-viewer/svelte.config.js
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
export default {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
pages: '../../backend/static/export-viewer',
|
||||
assets: '../../backend/static/export-viewer',
|
||||
})
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The build output is written directly into `backend/static/export-viewer/` so the backend
|
||||
can reference it without a copy step.
|
||||
|
||||
### Shared Tailwind Config
|
||||
|
||||
```
|
||||
// frontend/export-viewer/tailwind.config.js
|
||||
import baseConfig from '../tailwind.config.js';
|
||||
export default { ...baseConfig, content: ['./src/**/*.{svelte,ts}'] };
|
||||
```
|
||||
|
||||
Imports the main app's Tailwind config to guarantee visual parity. Only the `content` glob
|
||||
is overridden.
|
||||
|
||||
---
|
||||
|
||||
## Viewer Feature Set
|
||||
|
||||
| Feature | Included | Notes |
|
||||
|---------|----------|-------|
|
||||
| List view (chronological, newest first) | ✓ | Full-width cards, same layout as live app |
|
||||
| Grid view (3-column) | ✓ | Square cells, video duration badge |
|
||||
| List/grid toggle | ✓ | Same toggle icons as live app |
|
||||
| Search bar (grid view only) | ✓ | Appears only in grid view |
|
||||
| Tag filter chips | ✓ | Built from tags in data.json |
|
||||
| Uploader filter | ✓ | Dropdown from uploaders in data.json |
|
||||
| Autocomplete suggestions | ✓ | From data.json — no network requests |
|
||||
| Lightbox (tap to expand) | ✓ | Swipe left/right navigates filtered set |
|
||||
| Like counts (static) | ✓ | Snapshot from export time |
|
||||
| Comment list (static) | ✓ | Expandable under each post |
|
||||
| Like/comment actions | ✗ | Read-only export |
|
||||
| Upload button / FAB | ✗ | |
|
||||
| Account / Host / Admin | ✗ | |
|
||||
| Authentication | ✗ | No JWT, no PIN |
|
||||
| Service Worker (offline cache) | Future | Could be added later for PWA behavior |
|
||||
|
||||
---
|
||||
|
||||
## Backend Export Job Flow
|
||||
|
||||
The `html` job variant is repurposed. The old minijinja template rendering path is removed
|
||||
and replaced entirely by the steps below.
|
||||
|
||||
```
|
||||
1. Query all posts, media, reactions, and comments for the event from the DB
|
||||
2. Copy pre-built viewer assets:
|
||||
backend/static/export-viewer/ → tmp/{job_id}/
|
||||
3. Generate data.json:
|
||||
- Build the JSON structure from queried data
|
||||
- Write to tmp/{job_id}/data.json
|
||||
4. Process and copy media:
|
||||
For each media file:
|
||||
a. Copy original; if image >5 MB, also produce compressed _full variant
|
||||
b. Generate _thumb (resize to 400 px wide via image library)
|
||||
c. For video, extract JPEG frame for _thumb
|
||||
d. Write to tmp/{job_id}/media/
|
||||
5. Create ZIP:
|
||||
zip -r event_name_viewer.zip tmp/{job_id}/
|
||||
6. Store ZIP path, mark job as complete
|
||||
7. Clean up tmp/{job_id}/
|
||||
```
|
||||
|
||||
The backend needs an image processing dependency (e.g. `image` crate in Rust) for thumbnail
|
||||
generation and compression. Video frame extraction requires `ffmpeg` available in the
|
||||
deployment environment (already used for video handling if applicable, otherwise add to
|
||||
docker-compose).
|
||||
|
||||
---
|
||||
|
||||
## Monorepo Structure After Implementation
|
||||
|
||||
```
|
||||
EventSnap/
|
||||
├── backend/
|
||||
│ ├── static/
|
||||
│ │ └── export-viewer/ ← pre-built viewer output (committed)
|
||||
│ │ ├── index.html
|
||||
│ │ └── _app/...
|
||||
│ └── src/
|
||||
│ └── handlers/
|
||||
│ └── export.rs ← export job assembles ZIP
|
||||
├── frontend/
|
||||
│ ├── export-viewer/ ← new mini SvelteKit project
|
||||
│ │ ├── package.json
|
||||
│ │ ├── svelte.config.js ← adapter-static, output → backend/static/export-viewer
|
||||
│ │ ├── tailwind.config.js ← extends ../tailwind.config.js
|
||||
│ │ └── src/
|
||||
│ │ └── routes/
|
||||
│ │ ├── +layout.ts ← prerender=true, ssr=false
|
||||
│ │ └── +page.svelte ← list/grid feed, lightbox, search
|
||||
│ └── src/ ← existing main app (unchanged)
|
||||
└── docs/
|
||||
├── CONCEPT_MOBILE_UI.md
|
||||
└── CONCEPT_HTML_VIEWER.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for Implementation
|
||||
|
||||
1. **Image processing library**: The `image` crate handles JPEG resize/compress; is it
|
||||
already a backend dependency, or does it need to be added?
|
||||
2. **Video thumbnail extraction**: Is `ffmpeg` available in the Docker environment?
|
||||
If not, a fallback (no video thumb, use a placeholder) is needed.
|
||||
3. **Viewer rebuild workflow**: Add a `make build-viewer` or `pnpm --filter export-viewer build`
|
||||
step to the developer workflow docs and CI so the committed output stays in sync.
|
||||
4. **ZIP file naming**: `{event_slug}_viewer_{date}.zip` or a fixed name?
|
||||
@@ -1,471 +0,0 @@
|
||||
# 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. This document describes the full
|
||||
mobile-first design covering navigation, the feed/gallery, account page, host dashboard,
|
||||
and admin dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 1. Navigation: Bottom Tab Bar
|
||||
|
||||
Replace all per-page top-right icon links with a single **persistent bottom tab bar** present
|
||||
on every page. The bar sits at the very bottom with proper `padding-bottom` for iPhone home
|
||||
indicator (safe-area-inset-bottom).
|
||||
|
||||
### Tab Composition by Role
|
||||
|
||||
| Role | Tabs |
|
||||
|-------|------|
|
||||
| Guest | 🏠 Feed · [📷+] · 👤 Account |
|
||||
| Host | 🏠 Feed · [📷+] · 👤 Account |
|
||||
| Admin | 🏠 Feed · [📷+] · 👤 Account |
|
||||
|
||||
All roles see the same three tabs. Role-specific dashboard links (Host, Admin) live inside
|
||||
the Account page — not as separate tabs. This keeps the bar simple and avoids conditional
|
||||
tab rendering.
|
||||
|
||||
### Visual Style
|
||||
|
||||
- Frosted glass background: `bg-white/85 backdrop-blur-md`
|
||||
- Thin top border: `border-t border-gray-200`
|
||||
- Subtle shadow upward
|
||||
- Active tab: colored icon + small label below
|
||||
- Inactive tab: gray icon, small gray label
|
||||
|
||||
### Upload FAB (Floating Action Button)
|
||||
|
||||
The center tab is an elevated circular button, not a flat tab icon:
|
||||
|
||||
- Circle ~56 px diameter, `bg-blue-600`
|
||||
- Icon: camera outline with a small `+` badge overlaid at bottom-right
|
||||
- Raised above the bar with a drop shadow
|
||||
- Press: slight scale-down (`scale-95`) + haptic feedback where available
|
||||
- Communicates "capture new or upload existing"
|
||||
|
||||
---
|
||||
|
||||
## 2. Feed / Gallery Page
|
||||
|
||||
### Header
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Sommerfest 2025 ≡ ⊞ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Event name left-aligned
|
||||
- List/grid view toggle icons right-aligned (≡ list, ⊞ grid)
|
||||
- Header collapses on downward scroll (only toggle remains visible), expands on upward scroll
|
||||
|
||||
---
|
||||
|
||||
### View A — Chronological List (default)
|
||||
|
||||
Full-width post cards, newest at top, infinite scroll.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 👤 MaxMustermann · vor 2 Min │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [photo / video] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ Tolle Stimmung! #party #spaß │
|
||||
│ ❤️ 12 💬 3 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Media: full-width, native aspect ratio, capped at 80 vh
|
||||
- Avatar: colored initial circle, no photo
|
||||
- Timestamp: relative ("vor 2 Min", "vor 1 Std")
|
||||
- Tap media → fullscreen lightbox, swipe left/right navigates feed
|
||||
- No search bar in list view
|
||||
|
||||
---
|
||||
|
||||
### View B — Grid View
|
||||
|
||||
Transition animation when toggling: list collapses, grid fades/scales in (~200 ms).
|
||||
|
||||
#### Search Bar (grid view only)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🔍 Nutzer oder #Tag suchen… × │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Appears below the header only in grid view
|
||||
- Slides in as part of the view transition
|
||||
- `×` clears current input
|
||||
- Auto-focuses when grid view is activated
|
||||
|
||||
#### Autocomplete Dropdown
|
||||
|
||||
Appears immediately on focus and updates on every keystroke. Data source: the already-loaded
|
||||
posts in memory — **no extra API calls**.
|
||||
|
||||
Two suggestion lists are derived at load time:
|
||||
- `allTags`: unique hashtags from all post captions, sorted by frequency descending
|
||||
- `allUploaders`: unique display names, sorted alphabetically
|
||||
|
||||
| User input | Suggestions shown |
|
||||
|------------|-------------------|
|
||||
| (focus, empty) | Top 3 tags by frequency + top 3 uploaders |
|
||||
| `#` | All tags, frequency-sorted |
|
||||
| `#par` | Tags with prefix "par": `#party`, `#parade` |
|
||||
| `Max` | Uploaders matching "max" (case-insensitive) |
|
||||
| `a` | Uploaders containing "a" + tags containing "a" |
|
||||
|
||||
Dropdown layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 👤 Nutzer │
|
||||
│ MaxMustermann │
|
||||
│ AnnaSchulz │
|
||||
│ # Tags │
|
||||
│ #party #tanz #spaß │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Max ~5 total suggestions. Tapping a suggestion adds it as an active filter chip and clears
|
||||
the search bar for another entry.
|
||||
|
||||
#### Active Filter Chips
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 👤 MaxMustermann × # party × │
|
||||
│ Alle Filter löschen │ ← shown when 2+ chips active
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Filter combination logic:
|
||||
|
||||
| Combination | Logic |
|
||||
|-------------|-------|
|
||||
| Two tags: `#party` + `#tanz` | OR — posts with either tag |
|
||||
| Two uploaders: Max + Anna | OR — posts from either |
|
||||
| Uploader + tag: Max + `#party` | AND — posts by Max that also have `#party` |
|
||||
|
||||
#### Grid Layout
|
||||
|
||||
```
|
||||
┌───────┬───────┬───────┐
|
||||
│ │ │ │
|
||||
│ │ │ │ 3-column, equal square cells
|
||||
├───────┼───────┼───────┤ small gap (2 px)
|
||||
│ │ ▶ │ │ ← video: small ▶ badge + duration
|
||||
│ │ 0:42 │ │
|
||||
└───────┴───────┴───────┘
|
||||
```
|
||||
|
||||
- Tap cell → fullscreen lightbox, swipe navigates filtered set only
|
||||
- Virtualized grid for performance on large events
|
||||
|
||||
---
|
||||
|
||||
## 3. Upload Flow
|
||||
|
||||
### Step 1 — Source Selection (Bottom Sheet)
|
||||
|
||||
Tapping the FAB slides up a bottom sheet (~300 ms spring animation).
|
||||
Frosted glass, rounded top corners, drag handle at top. Tap outside or swipe down to dismiss.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ▬ (drag handle) │
|
||||
│ │
|
||||
│ 📸 Kamera │
|
||||
│ Jetzt aufnehmen │
|
||||
│ │
|
||||
│ 🖼 Galerie │
|
||||
│ Foto oder Video wählen │
|
||||
│ │
|
||||
│ [ Abbrechen ] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 2a — Camera
|
||||
|
||||
Triggers `<input type="file" accept="image/*,video/*" capture="environment">`.
|
||||
Native camera opens. After capture → Step 3.
|
||||
|
||||
### Step 2b — Gallery
|
||||
|
||||
Triggers `<input type="file" accept="image/*,video/*" multiple>`.
|
||||
Native gallery picker with multi-select (up to ~10 items). After selection → Step 3.
|
||||
|
||||
### Step 3 — Preview & Metadata Screen
|
||||
|
||||
Full-screen, pushes in from right. Bottom nav hidden (immersive).
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ × Abbrechen Hochladen → │
|
||||
├──────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ → │ ← horizontal scroll, tap to preview
|
||||
│ │img │ │img │ │ × │ │ × on each thumbnail to remove
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
│ │
|
||||
│ Beschreibung (optional) │
|
||||
│ ┌────────────────────────────┐ │
|
||||
│ │ │ │ ← auto-focused
|
||||
│ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ # Schnell-Tags │
|
||||
│ [#Feier] [#Spaß] [#Party] … │ ← tap to append to caption
|
||||
│ │
|
||||
├──────────────────────────────────┤
|
||||
│ ┌────────────────────────────┐ │
|
||||
│ │ 📤 Hochladen │ │ ← sticky, disabled until ≥1 file
|
||||
│ └────────────────────────────┘ │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 4 — Background Upload + Feedback
|
||||
|
||||
- Tapping "Hochladen" immediately returns to the feed (optimistic UX)
|
||||
- Slim progress bar above the bottom tab bar while queue is active
|
||||
- FAB gets a small spinning ring badge while uploads are in progress
|
||||
- On completion: brief toast near the bottom ("✓ Hochgeladen")
|
||||
- Rate-limit countdown banner anchored above the bottom bar (existing behavior)
|
||||
|
||||
---
|
||||
|
||||
## 4. Account Page
|
||||
|
||||
Single entry point for profile info and role-based dashboard navigation.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Mein Account │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────┐ │
|
||||
│ │ M │ MaxMustermann │
|
||||
│ └───────┘ 🏷 Gast │
|
||||
│ Sommerfest 2025 │
|
||||
│ 7 Uploads │
|
||||
│ │
|
||||
├── Dashboards ───────────────────────────┤ (entire section absent for guests)
|
||||
│ │
|
||||
│ ⭐ Host-Dashboard → │ (host + admin only)
|
||||
│ 🛡 Admin-Dashboard → │ (admin only)
|
||||
│ │
|
||||
├── Konto ────────────────────────────────┤
|
||||
│ │
|
||||
│ ✏️ Anzeigename ändern → │
|
||||
│ 🔑 PIN ändern → │
|
||||
│ 🚪 Event verlassen → │ (red text, confirm sheet)
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 🏠 Feed · [📷+] · 👤 Account │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- "Dashboards" section is entirely absent in the DOM for plain guests — not just hidden
|
||||
- "Event verlassen" triggers a bottom-sheet confirmation before action
|
||||
- Avatar: colored circle with initial letter
|
||||
|
||||
---
|
||||
|
||||
## 5. Host Dashboard
|
||||
|
||||
Accessed via Account → ⭐ Host-Dashboard. Full-screen page, bottom nav visible.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← 🎉 Host-Dashboard │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ── Statistiken ────────────────────── │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 24 │ │ 156 │ │
|
||||
│ │ Nutzer │ │ Uploads │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ── Event-Einstellungen ────────────── │ ← collapsible section
|
||||
│ │
|
||||
│ Neue Uploads sperren │
|
||||
│ ○────────────● Gesperrt │ ← toggle
|
||||
│ Keine neuen Uploads möglich │
|
||||
│ │
|
||||
│ ── Nutzerverwaltung ───────────────── │ ← collapsible section
|
||||
│ │
|
||||
│ 🔍 Nutzer suchen… │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 👤 MaxMustermann Gast [🚫] │ │
|
||||
│ │ 👤 AnnaSchulz Gast [🚫] │ │
|
||||
│ │ 👤 GesperrterNutzer [↩] │ │ ← banned: undo icon
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 🏠 Feed · [📷+] · 👤 Account │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Sections have a chevron toggle to collapse/expand (helps on small phones)
|
||||
- Ban/unban: icon tap + bottom sheet confirmation ("Nutzer wirklich sperren?")
|
||||
- User list virtualized for large events
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin Dashboard
|
||||
|
||||
Most complex page. Uses an **inner tab bar** directly below the header to divide the four
|
||||
functional areas. The inner tabs are independent of the bottom nav.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← 🛡 Admin-Dashboard │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Stats] [Config] [Export] [Nutzer] │ ← inner tab bar (scrollable if needed)
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Tab content] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 🏠 Feed · [📷+] · 👤 Account │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Stats Tab
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 156 │ │ 24 │
|
||||
│ Uploads │ │ Nutzer │
|
||||
└──────────┘ └──────────┘
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 2.1 GB │ │ 3 │
|
||||
│ Speicher │ │ Gesperrt │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
2×2 metric card grid. Values large and prominent. Optionally expandable to show time-series
|
||||
charts on tap.
|
||||
|
||||
### Config Tab
|
||||
|
||||
```
|
||||
Upload-Limit / Nutzer
|
||||
┌────────────────────────────────┐
|
||||
│ 10 │
|
||||
└────────────────────────────────┘
|
||||
|
||||
Zeitfenster (Sek.)
|
||||
┌────────────────────────────────┐
|
||||
│ 60 │
|
||||
└────────────────────────────────┘
|
||||
|
||||
Max. Dateigröße (MB)
|
||||
┌────────────────────────────────┐
|
||||
│ 50 │
|
||||
└────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────┐
|
||||
│ 💾 Speichern │ ← sticky at bottom of tab scroll area
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each setting: full-width label + input. Save button always reachable without scrolling.
|
||||
|
||||
### Export Tab
|
||||
|
||||
```
|
||||
── Galerie ──────────────────────────
|
||||
[ 🔓 Galerie freigeben ]
|
||||
|
||||
── Export-Jobs ──────────────────────
|
||||
[ 🔄 Aktualisieren ]
|
||||
|
||||
┌───────────────────────────────────┐
|
||||
│ HTML-Viewer ● Fertig [↓ ZIP] │
|
||||
│ JSON-Export ⏳ Läuft… │
|
||||
│ ZIP-Archiv ✗ Fehler [↺] │
|
||||
└───────────────────────────────────┘
|
||||
|
||||
[ + Neuer Export-Job ]
|
||||
```
|
||||
|
||||
- Status chips: green (Fertig), amber (Läuft), red (Fehler)
|
||||
- Download button inline per completed job
|
||||
- Only the jobs list refreshes on "Aktualisieren" — no full page re-render
|
||||
|
||||
### Nutzer Tab
|
||||
|
||||
Same structure as Host Nutzerverwaltung, with any additional admin-only actions
|
||||
(e.g. role assignment) added as extra controls per row.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|-----------|-------------|
|
||||
| Thumb zone | All primary actions in bottom ~20% of screen |
|
||||
| One-hand operation | FAB centered, bottom sheets dismissable with swipe |
|
||||
| Minimal taps to upload | Source → picker → preview → upload: 4 taps |
|
||||
| Immediate feedback | Optimistic return to feed, background upload |
|
||||
| Progressive disclosure | Caption/tags optional; CTA always reachable |
|
||||
| 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
313
docs/FEATURES.md
@@ -1,313 +0,0 @@
|
||||
# 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
199
docs/IDEAS.md
@@ -1,199 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,392 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,292 +0,0 @@
|
||||
# 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
6
e2e/.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.cache/
|
||||
.env.test
|
||||
*.log
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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
287
e2e/README.md
@@ -1,287 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,77 +0,0 @@
|
||||
# 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:
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 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]
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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') ?? '');
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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
286
e2e/package-lock.json
generated
@@ -1,286 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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';
|
||||
@@ -1,61 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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/,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* USER_JOURNEYS.md §11 — admin login. Tests the /admin/login route and
|
||||
* the redirect-while-already-logged-in shortcut.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { AdminLoginPage } from '../../page-objects';
|
||||
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||
|
||||
test.describe('Auth — admin login', () => {
|
||||
test('correct password → /admin dashboard', async ({ page }) => {
|
||||
const login = new AdminLoginPage(page);
|
||||
await login.goto();
|
||||
await login.login(ADMIN_PASSWORD);
|
||||
await page.waitForURL('**/admin', { timeout: 10_000 });
|
||||
|
||||
// Admin JWT should now be in localStorage (admin uses the same key, just with role=admin in the payload)
|
||||
const role = await page.evaluate(() => {
|
||||
const token = localStorage.getItem('eventsnap_jwt');
|
||||
if (!token) return null;
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1])).role;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
expect(role).toBe('admin');
|
||||
});
|
||||
|
||||
test('wrong password → error, no token written', async ({ page }) => {
|
||||
const login = new AdminLoginPage(page);
|
||||
await login.goto();
|
||||
await login.login('definitely-not-the-password');
|
||||
await expect(login.errorMessage).toContainText(/falsch|forbidden|password/i);
|
||||
const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt'));
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
test('already logged in as admin → auto-redirect to /admin', async ({ page, api }) => {
|
||||
const adminJwt = await api.adminLogin();
|
||||
await page.goto('/');
|
||||
await page.evaluate((jwt) => localStorage.setItem('eventsnap_jwt', jwt), adminJwt);
|
||||
await page.goto('/admin/login');
|
||||
await page.waitForURL('**/admin', { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* UX polish — back chevrons on /recover and /export. Both pages used to be
|
||||
* dead-ends for deep-linked users; the chevron mirrors the upload-composer
|
||||
* header pattern and routes back to /feed.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
test.describe('Navigation — back chevrons', () => {
|
||||
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ page }) => {
|
||||
await page.goto('/recover');
|
||||
const back = page.getByTestId('recover-back');
|
||||
await expect(back).toBeVisible();
|
||||
await back.click();
|
||||
// Unauthenticated → /feed mounts and redirects to /join.
|
||||
await page.waitForURL(/\/(join|feed)$/);
|
||||
});
|
||||
|
||||
test('/export back chevron returns the authenticated guest to /feed', async ({ page, guest, signIn }) => {
|
||||
const g = await guest('ExportBack');
|
||||
await signIn(page, g);
|
||||
await page.goto('/export');
|
||||
|
||||
const back = page.getByTestId('export-back');
|
||||
await expect(back).toBeVisible();
|
||||
await back.click();
|
||||
await page.waitForURL('**/feed');
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* USER_JOURNEYS.md §1 (First-time guest), §2 (Returning guest, same device),
|
||||
* §3 (Returning guest, new device). Covers the happy path through
|
||||
* /join, the PIN modal, the onboarding overlay landing, and the
|
||||
* name-already-taken recovery transformation.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { JoinPage } from '../../page-objects';
|
||||
import { readStorage, STORAGE_KEYS, clearAllStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Auth — join flow', () => {
|
||||
test('happy path: name → PIN modal → feed @smoke', async ({ page }) => {
|
||||
const join = new JoinPage(page);
|
||||
await join.goto();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Willkommen!' })).toBeVisible();
|
||||
|
||||
const { pin } = await join.joinAs('Alice');
|
||||
expect(pin).toMatch(/^\d{4}$/);
|
||||
|
||||
// PIN copy button toggles to "Kopiert!" on click
|
||||
await join.pinCopyButton.click();
|
||||
await expect(join.pinCopyButton).toHaveText(/Kopiert/i);
|
||||
|
||||
await join.continueToFeed();
|
||||
await expect(page).toHaveURL(/\/feed$/);
|
||||
|
||||
const storage = await readStorage(page);
|
||||
expect(storage.jwt, 'JWT in localStorage').toMatch(/^eyJ/);
|
||||
expect(storage.pin).toBe(pin);
|
||||
expect(storage.userId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(storage.displayName).toBe('Alice');
|
||||
});
|
||||
|
||||
test('returning guest with valid JWT is redirected to /feed', async ({ page, guest, signIn }) => {
|
||||
const alice = await guest('Bob');
|
||||
await signIn(page, alice);
|
||||
|
||||
// Now visit the root — should auto-redirect to /feed.
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => {
|
||||
const original = await guest('Charlie');
|
||||
|
||||
// Brand-new browser context (cleared storage) — landing on /join with same name
|
||||
await clearAllStorage(page);
|
||||
const join = new JoinPage(page);
|
||||
await join.goto();
|
||||
await join.fillName('Charlie');
|
||||
await join.submit();
|
||||
|
||||
await expect(join.recoveryPinInput).toBeVisible();
|
||||
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
|
||||
|
||||
// Type correct PIN → land on /feed with a new JWT
|
||||
await join.recoveryPinInput.fill(original.pin);
|
||||
await join.recoverySubmit.click();
|
||||
await page.waitForURL('**/feed');
|
||||
|
||||
const storage = await readStorage(page);
|
||||
expect(storage.userId).toBe(original.userId);
|
||||
expect(storage.pin).toBe(original.pin);
|
||||
});
|
||||
|
||||
test('wrong PIN three times locks the account for 15 minutes', async ({ page, guest, db }) => {
|
||||
const dave = await guest('Dave');
|
||||
await clearAllStorage(page);
|
||||
|
||||
const join = new JoinPage(page);
|
||||
await join.goto();
|
||||
await join.fillName('Dave');
|
||||
await join.submit();
|
||||
await expect(join.recoveryPinInput).toBeVisible();
|
||||
|
||||
// Wrong PIN (real one is dave.pin)
|
||||
const wrong = dave.pin === '0000' ? '1111' : '0000';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await join.recoveryPinInput.fill(wrong);
|
||||
await join.recoverySubmit.click();
|
||||
await expect(join.recoveryError).toBeVisible();
|
||||
}
|
||||
|
||||
// Fourth attempt should hit the 429 lockout (even with the correct PIN now)
|
||||
await join.recoveryPinInput.fill(dave.pin);
|
||||
await join.recoverySubmit.click();
|
||||
await expect(join.recoveryError).toContainText(/15 Minuten/);
|
||||
|
||||
// Sanity: DB row reflects the lock
|
||||
// (The handler sets pin_locked_until directly — verify via API "recover" returning 429)
|
||||
void db; // unused for now, documenting that db.lockUserPin exists if we want shortcut path
|
||||
});
|
||||
|
||||
test('"Anderen Namen wählen" returns to the normal join form', async ({ page, guest }) => {
|
||||
await guest('Eve');
|
||||
await clearAllStorage(page);
|
||||
|
||||
const join = new JoinPage(page);
|
||||
await join.goto();
|
||||
await join.fillName('Eve');
|
||||
await join.submit();
|
||||
await expect(join.recoveryPinInput).toBeVisible();
|
||||
|
||||
await join.tryDifferentNameButton.click();
|
||||
await expect(join.nameInput).toBeVisible();
|
||||
await expect(join.recoveryPinInput).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('"Ich habe bereits einen Account" link routes to /recover', async ({ page }) => {
|
||||
const join = new JoinPage(page);
|
||||
await join.goto();
|
||||
await join.linkToRecover.click();
|
||||
await expect(page).toHaveURL(/\/recover$/);
|
||||
});
|
||||
|
||||
test('JWT and PIN keys are exactly the ones auth.ts expects', async ({ page, guest, signIn }) => {
|
||||
const handle = await guest('Frank');
|
||||
await signIn(page, handle);
|
||||
|
||||
// Read raw localStorage to make sure no test accidentally uses a different key.
|
||||
const raw = await page.evaluate(() => ({
|
||||
jwt: localStorage.getItem('eventsnap_jwt'),
|
||||
pin: localStorage.getItem('eventsnap_pin'),
|
||||
userId: localStorage.getItem('eventsnap_user_id'),
|
||||
displayName: localStorage.getItem('eventsnap_display_name'),
|
||||
}));
|
||||
expect(raw.jwt).toBe(handle.jwt);
|
||||
expect(raw.pin).toBe(handle.pin);
|
||||
expect(raw.userId).toBe(handle.userId);
|
||||
expect(raw.displayName).toBe('Frank');
|
||||
// Sanity: the keys we just checked match STORAGE_KEYS in storage-helpers.ts
|
||||
expect(STORAGE_KEYS.jwt).toBe('eventsnap_jwt');
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* USER_JOURNEYS.md §15 — leaving an event clears auth and routes to /join.
|
||||
* The journey doc spells out: clears JWT and PIN, calls DELETE /session.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { AccountPage } from '../../page-objects';
|
||||
import { readStorage } from '../../helpers/storage-helpers';
|
||||
|
||||
test.describe('Auth — leave event', () => {
|
||||
test('leave event clears localStorage and redirects to /join', async ({ page, guest, signIn }) => {
|
||||
const h = await guest('Jens');
|
||||
await signIn(page, h);
|
||||
|
||||
const account = new AccountPage(page);
|
||||
await account.goto();
|
||||
await account.leaveEvent();
|
||||
|
||||
await expect(page).toHaveURL(/\/join$/);
|
||||
|
||||
const storage = await readStorage(page);
|
||||
expect(storage.jwt, 'JWT should be cleared').toBeNull();
|
||||
// PIN is intentionally retained per auth.ts so the user can recover.
|
||||
expect(storage.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user