Compare commits
78 Commits
5546fb82e6
...
fix/keepsa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac30404e3 | ||
|
|
5d0c7cd949 | ||
|
|
a20b96d893 | ||
|
|
24ac862f81 | ||
|
|
a3d8ae72e3 | ||
|
|
e1653cc54e | ||
|
|
35390800c7 | ||
|
|
14ebe1e543 | ||
|
|
a4a4e46c53 | ||
|
|
e6e8a52d87 | ||
|
|
43c2a0d09c | ||
|
|
6818cabf91 | ||
|
|
f777764839 | ||
|
|
aeb958f6ba | ||
|
|
281eb3bec7 | ||
|
|
8c93cbb045 | ||
|
|
ceb68939a7 | ||
|
|
674ea87bbd | ||
|
|
fae12bd7ec | ||
|
|
2bd54d7f0b | ||
|
|
528960d201 | ||
|
|
6b7da8fb07 | ||
|
|
faf2e62a29 | ||
|
|
6d5c488e14 | ||
|
|
f0d69f1cda | ||
|
|
64eccb8672 | ||
|
|
eefa476765 | ||
|
|
0932e2a470 | ||
|
|
117c0c547f | ||
|
|
c49bf875d9 | ||
|
|
6e7c4565cd | ||
|
|
1a7a531c90 | ||
|
|
6920e5bf7a | ||
|
|
58f718bdce | ||
|
|
3c984e2932 | ||
|
|
d6fdc13da9 | ||
|
|
c14ccd2df1 | ||
|
|
9c8cc7c069 | ||
|
|
1485df5469 | ||
|
|
81e5017f27 | ||
|
|
813a9fa500 | ||
|
|
f03e392f8c | ||
|
|
3d94bbd6fb | ||
|
|
96a22cfe27 | ||
|
|
c6e9350f78 | ||
|
|
537a11b0a4 | ||
|
|
27e4004cc8 | ||
|
|
c4e9b89af0 | ||
|
|
05948d8268 | ||
|
|
d4237ad2ad | ||
|
|
be6d56f278 | ||
|
|
0d8e83d392 | ||
|
|
89057d605f | ||
|
|
688dc614d7 | ||
|
|
42416d76e2 | ||
|
|
cec69e804a | ||
|
|
137c4ee8a1 | ||
|
|
a77c2ddc00 | ||
|
|
40c6fd2ccb | ||
|
|
e69ec4d736 | ||
|
|
3fb1b5d80d | ||
|
|
e1ca9d192f | ||
|
|
5009590882 | ||
|
|
d9738a4cb9 | ||
|
|
669a191968 | ||
|
|
a1733b03d5 | ||
|
|
4026648f98 | ||
|
|
44641473ea | ||
|
|
57a907eca5 | ||
|
|
6e0a760271 | ||
|
|
0abf413693 | ||
|
|
002355ba40 | ||
|
|
6155b4123d | ||
|
|
7758270cac | ||
|
|
9b8698f86b | ||
|
|
3c3a7d0082 | ||
|
|
3654aca18b | ||
|
|
f243bfe89a |
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cargo check *)",
|
||||||
|
"Bash(cargo clippy *)",
|
||||||
|
"Bash(git --no-pager diff *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
63
.env.example
63
.env.example
@@ -16,6 +16,13 @@ DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/events
|
|||||||
POSTGRES_USER=eventsnap
|
POSTGRES_USER=eventsnap
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
||||||
POSTGRES_DB=eventsnap
|
POSTGRES_DB=eventsnap
|
||||||
|
# Connection pool size. Default 10. For a busy event (~100 guests polling the feed
|
||||||
|
# + SSE + uploads at once) raise to ~30 so requests don't queue on a pool permit.
|
||||||
|
# PAIRED WITH THE DB CONTAINER'S MEMORY LIMIT: 30 backends plus Postgres 16's default
|
||||||
|
# shared_buffers is already snug in the 1G that docker-compose.yml allots the `db`
|
||||||
|
# service. If you raise this, raise `db.deploy.resources.limits.memory` with it — an
|
||||||
|
# OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
||||||
|
DATABASE_MAX_CONNECTIONS=30
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
# Generate with: openssl rand -hex 64
|
# Generate with: openssl rand -hex 64
|
||||||
@@ -24,7 +31,11 @@ SESSION_EXPIRY_DAYS=30
|
|||||||
|
|
||||||
# Admin dashboard password (bcrypt hash).
|
# Admin dashboard password (bcrypt hash).
|
||||||
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
||||||
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
|
# IMPORTANT: keep the SINGLE QUOTES. A bcrypt hash is full of `$` (e.g. $2b$12$…$…),
|
||||||
|
# and both Docker Compose's env_file interpolation and dotenvy's variable substitution
|
||||||
|
# would otherwise eat the `$…` segments (reading them as unset vars) and corrupt the
|
||||||
|
# hash — every admin login then 401s. Single quotes make both read it literally.
|
||||||
|
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
|
||||||
|
|
||||||
# ── Event ─────────────────────────────────────────────────────────────────────
|
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||||
EVENT_NAME=Max & Maria's Wedding
|
EVENT_NAME=Max & Maria's Wedding
|
||||||
@@ -36,19 +47,43 @@ MEDIA_PATH=/media
|
|||||||
# /media is publicly served, so exports here would be downloadable without auth.
|
# /media is publicly served, so exports here would be downloadable without auth.
|
||||||
EXPORT_PATH=/exports
|
EXPORT_PATH=/exports
|
||||||
|
|
||||||
# ── Upload limits ─────────────────────────────────────────────────────────────
|
# ── Runtime settings (upload limits, rate limits, capacity) ───────────────────
|
||||||
DEFAULT_MAX_IMAGE_SIZE_MB=20
|
# NOTE: These are NOT environment variables. Upload size caps, rate limits, guest
|
||||||
DEFAULT_MAX_VIDEO_SIZE_MB=500
|
# count and quota tolerance are stored in the database `config` table (seeded once
|
||||||
|
# at first boot) and changed at runtime from the ADMIN DASHBOARD — the backend does
|
||||||
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
# not read them from .env. Setting them here has no effect. Current seeded defaults:
|
||||||
DEFAULT_UPLOAD_RATE_PER_HOUR=10
|
# upload rate 100 / hour / guest (raised from 10 by migration 015)
|
||||||
DEFAULT_FEED_RATE_PER_MIN=60
|
# feed rate 60 / minute
|
||||||
DEFAULT_EXPORT_RATE_PER_DAY=3
|
# export rate 3 / day
|
||||||
|
# max image size 20 MB
|
||||||
# ── Capacity ──────────────────────────────────────────────────────────────────
|
# max video size 500 MB
|
||||||
DEFAULT_ESTIMATED_GUEST_COUNT=100
|
# estimated guests 100
|
||||||
# Fraction of total storage that triggers the "low storage" warning (0.0–1.0)
|
# quota tolerance 0.75 (see below — NOT a warning threshold)
|
||||||
DEFAULT_QUOTA_TOLERANCE=0.75
|
# Adjust these in the admin UI before the event if needed.
|
||||||
|
#
|
||||||
|
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
|
||||||
|
# which anything warns you:
|
||||||
|
#
|
||||||
|
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
|
||||||
|
#
|
||||||
|
# It is recomputed against LIVE free space on every upload, so it self-throttles: guests
|
||||||
|
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
|
||||||
|
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
|
||||||
|
#
|
||||||
|
# Raising it therefore AUTHORISES GUESTS TO FILL MORE OF THE DISK. Setting 0.95 in the
|
||||||
|
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
|
||||||
|
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
|
||||||
|
# Memories.zip are each roughly a second copy of every original (both store media
|
||||||
|
# uncompressed). Budget for media + 2x media, or move exports to their own volume.
|
||||||
|
#
|
||||||
|
# 0.75 is the tested default. Lower it if the box is tight; raise it only if you have
|
||||||
|
# provisioned export headroom separately.
|
||||||
|
|
||||||
# ── Workers ───────────────────────────────────────────────────────────────────
|
# ── Workers ───────────────────────────────────────────────────────────────────
|
||||||
|
# Number of parallel image/video compression workers. Default 2. This is the main
|
||||||
|
# throughput bottleneck: with 2 workers a burst of uploads can take ~5s to appear.
|
||||||
|
# For a large event (100+ guests) 4 is a good target — but each worker can run an
|
||||||
|
# ffmpeg transcode, so if you raise this ALSO raise the app container's memory limit
|
||||||
|
# in docker-compose.yml (`app.deploy.resources.limits.memory`) from 1G to ~2G, or a
|
||||||
|
# burst of large videos can OOM the box and take Postgres down with it.
|
||||||
COMPRESSION_WORKER_CONCURRENCY=2
|
COMPRESSION_WORKER_CONCURRENCY=2
|
||||||
|
|||||||
20
.github/workflows/e2e.yml
vendored
20
.github/workflows/e2e.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
name: Playwright E2E (chromium-desktop)
|
name: Playwright E2E (chromium + webkit)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium webkit
|
||||||
|
|
||||||
- name: Bring up the test stack
|
- name: Bring up the test stack
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
@@ -54,6 +54,22 @@ jobs:
|
|||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
run: npm run test:e2e -- --project=chromium-mobile
|
run: npm run test:e2e -- --project=chromium-mobile
|
||||||
|
|
||||||
|
# iOS Safari is the app's stated primary user (a wedding guest opening a QR link), and
|
||||||
|
# WebKit is the ONLY engine here that reproduces two of its behaviours:
|
||||||
|
# - it enforces X-Frame-Options on the download iframe, so a site-wide `DENY` makes the
|
||||||
|
# keepsake download silently do nothing. Blink hands attachments to the download
|
||||||
|
# manager first and never notices. That shipped once already.
|
||||||
|
# - it abandons a <video> load without a 206 response to its Range probe.
|
||||||
|
# Both regressions are invisible to every Chromium project, so running WebKit is what
|
||||||
|
# actually gates them on a PR rather than on someone remembering to test locally.
|
||||||
|
#
|
||||||
|
# The project is scoped in playwright.config.ts to the journeys a guest walks
|
||||||
|
# (01-auth, 02-upload, 03-feed, 06-export); four IndexedDB-blob tests skip themselves
|
||||||
|
# there — see helpers/webkit.ts for why that is the harness and not the app.
|
||||||
|
- name: Run E2E tests (webkit / iOS)
|
||||||
|
working-directory: ./e2e
|
||||||
|
run: npm run test:e2e -- --project=webkit-iphone
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ e2e/.env.test
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code personal (per-user) settings — shared settings.json IS committed
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
43
Caddyfile
43
Caddyfile
@@ -9,34 +9,63 @@
|
|||||||
header {
|
header {
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# X-Frame-Options: DENY everywhere EXCEPT the keepsake download endpoints, which
|
||||||
|
# are navigated in a HIDDEN, SAME-ORIGIN iframe so a 404/429 can't unload the PWA
|
||||||
|
# (see frontend/src/routes/export/+page.svelte). WebKit enforces XFO *before*
|
||||||
|
# honouring Content-Disposition, so a blanket DENY makes the download silently do
|
||||||
|
# nothing on iOS Safari — the app's primary platform. SAMEORIGIN still blocks
|
||||||
|
# cross-origin framing.
|
||||||
|
#
|
||||||
|
# Split into two disjoint matchers rather than an override: Caddy applies the
|
||||||
|
# FIRST header directive outermost, so it wins on write — a later, more specific
|
||||||
|
# `header` would be silently ignored.
|
||||||
|
@framable path /api/v1/export/zip /api/v1/export/html
|
||||||
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
||||||
|
header @framable X-Frame-Options "SAMEORIGIN"
|
||||||
|
header @not_framable X-Frame-Options "DENY"
|
||||||
|
|
||||||
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
|
# SvelteKit frontend — static assets with long-lived cache (content-hashed filenames)
|
||||||
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
||||||
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
# Preview/thumbnail images. These are now served by the app through a
|
# Preview/thumbnail images. These are served by the app through a visibility-checked
|
||||||
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation
|
# alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation can revoke access;
|
||||||
# can revoke access; direct /media/previews|thumbnails is 404-blocked at the app.
|
# the app serves no /media route at all, so there is no direct path to the bytes.
|
||||||
# Privately cacheable for a short window (the app sets the same header; this is the
|
# Privately cacheable for a short window (the app sets the same header; this is the
|
||||||
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
||||||
# stops being served to a direct-URL holder promptly.
|
# stops being served to a direct-URL holder promptly.
|
||||||
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
||||||
header @media_api Cache-Control "private, max-age=300"
|
header @media_api Cache-Control "private, max-age=300"
|
||||||
|
|
||||||
# API — never cache, EXCEPT the gated image routes above.
|
# API and health — never cache, EXCEPT the gated image routes above. A cached health
|
||||||
|
# response would report the last known state rather than the current one.
|
||||||
@api {
|
@api {
|
||||||
path /api/*
|
path /api/* /health
|
||||||
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
||||||
}
|
}
|
||||||
header @api Cache-Control "no-store"
|
header @api Cache-Control "no-store"
|
||||||
|
|
||||||
# Route API and media requests to the Rust backend
|
# Route API and media requests to the Rust backend.
|
||||||
|
#
|
||||||
|
# The app serves no /media route at all (see the note in backend/src/main.rs) — media
|
||||||
|
# bytes are reachable only through the visibility-checked /api/v1/upload aliases, so
|
||||||
|
# /media/* forwards to a plain 404. The proxy line is kept deliberately: it means the
|
||||||
|
# edge faithfully hands /media to the app, so if a future change ever re-introduces a
|
||||||
|
# static media route the e2e gating specs see it here exactly as production would,
|
||||||
|
# instead of being masked by the SvelteKit 404 page.
|
||||||
reverse_proxy /api/* app:3000
|
reverse_proxy /api/* app:3000
|
||||||
reverse_proxy /media/* app:3000
|
reverse_proxy /media/* app:3000
|
||||||
|
|
||||||
|
# The backend registers /health on its ROOT router, not under /api/v1, so it needs its
|
||||||
|
# own line — without it the catch-all below hands /health to SvelteKit, which has no
|
||||||
|
# such route and returns its 404 page. That made the documented post-deploy check
|
||||||
|
# (`curl -fsS https://DOMAIN/health`) fail 100% of the time on a perfectly healthy
|
||||||
|
# stack. e2e/Caddyfile.test has always carried this line; production never did.
|
||||||
|
reverse_proxy /health app:3000
|
||||||
|
|
||||||
# Everything else goes to SvelteKit frontend
|
# Everything else goes to SvelteKit frontend
|
||||||
reverse_proxy frontend:3001
|
reverse_proxy frontend:3001
|
||||||
}
|
}
|
||||||
|
|||||||
21
PROJECT.md
21
PROJECT.md
@@ -709,7 +709,7 @@ CREATE TABLE config (
|
|||||||
INSERT INTO config (key, value) VALUES
|
INSERT INTO config (key, value) VALUES
|
||||||
('max_image_size_mb', '20'),
|
('max_image_size_mb', '20'),
|
||||||
('max_video_size_mb', '500'),
|
('max_video_size_mb', '500'),
|
||||||
('upload_rate_per_hour', '10'),
|
('upload_rate_per_hour', '100'), -- raised from 10 in migration 015 (guests upload bursts of 10-20)
|
||||||
('feed_rate_per_min', '60'),
|
('feed_rate_per_min', '60'),
|
||||||
('export_rate_per_day', '3'),
|
('export_rate_per_day', '3'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
@@ -1133,16 +1133,19 @@ eventsnap/
|
|||||||
|
|
||||||
### Backup Strategy
|
### Backup Strategy
|
||||||
|
|
||||||
```bash
|
Three artefacts in three places: the database, the `media_data` volume
|
||||||
# Daily (e.g. as a separate Compose service or cron on the VPS)
|
(originals + derivatives), and the **separate** `exports_data` volume. See
|
||||||
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
|
[README.md](README.md#backup) for the exact commands.
|
||||||
|
|
||||||
# Weekly: rsync /media volume to Hetzner Storage Box
|
Everything runs through `docker compose` / `docker run`, because `DATABASE_URL`
|
||||||
rsync -az /opt/eventsnap/media/ \
|
and the `/media` and `/exports` paths only exist inside the compose network —
|
||||||
user@u123456.your-storagebox.de:backup/eventsnap/
|
they are not host paths, and `DATABASE_URL` is never exported into an operator's
|
||||||
```
|
shell.
|
||||||
|
|
||||||
The `/media` volume contains originals, previews, thumbnails, generated exports, and DB backups — a single volume to back up.
|
Export archives are deliberately outside `MEDIA_PATH` (`EXPORT_PATH=/exports`): a
|
||||||
|
keepsake contains every photo in the event, and keeping it off the media tree is
|
||||||
|
what stops it being reachable except through the ticket-gated handler. A backup
|
||||||
|
of the media volume alone silently loses every generated keepsake.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
231
README.md
231
README.md
@@ -34,7 +34,6 @@ A guest scans the QR code on their way in, types their name, and is immediately
|
|||||||
### Planned (v1.x)
|
### Planned (v1.x)
|
||||||
|
|
||||||
- Individual file download button
|
- Individual file download button
|
||||||
- Low-disk alert (< 10 GB free)
|
|
||||||
- Event banner / cover image
|
- Event banner / cover image
|
||||||
- Chunked resumable upload for large videos
|
- Chunked resumable upload for large videos
|
||||||
- Host-curated story highlights
|
- Host-curated story highlights
|
||||||
@@ -115,6 +114,65 @@ Caddy automatically obtains a Let's Encrypt certificate on first start. The app
|
|||||||
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
|
### Updating an existing deployment
|
||||||
|
|
||||||
|
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
|
||||||
|
> `build:` services with no published image tag, and Compose has no source-change detection:
|
||||||
|
> if an image with that name already exists it is reused. After a `git pull` the command
|
||||||
|
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
|
||||||
|
> nothing looks exactly like a successful one. `--build` is what makes it real.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/eventsnap
|
||||||
|
|
||||||
|
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
|
||||||
|
# (See "Backup" below; the database dump is the one that matters here.)
|
||||||
|
|
||||||
|
# 2. Fetch the new code.
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 3. Rebuild and restart the application services. --build is NOT optional.
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4. Apply any Caddyfile change. Step 3 does NOT do this — see the warning below.
|
||||||
|
docker compose up -d --force-recreate caddy
|
||||||
|
|
||||||
|
# 5. Confirm the app came back up. Anything other than "ok" means check the logs.
|
||||||
|
curl -fsS https://DOMAIN/health && echo
|
||||||
|
|
||||||
|
# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
|
||||||
|
# compare — it must have changed. (Ignore the CREATED column; it reports the base
|
||||||
|
# layer's age, not this build's.) An unchanged ID means step 3 ran without --build
|
||||||
|
# and you are still serving the old code.
|
||||||
|
docker compose images app frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are applied by the backend on startup, so step 3 covers them. If `app` stays
|
||||||
|
unhealthy afterwards, `docker compose logs app` will name the failing migration — and note
|
||||||
|
that a migration applied by a *newer* build is not removed by checking out an older commit,
|
||||||
|
so rolling back code without restoring the database snapshot from step 1 leaves the schema
|
||||||
|
ahead of the binary and the app refusing to boot.
|
||||||
|
|
||||||
|
> **Why step 4 exists.** `--build` only rebuilds services that have a `build:` section, and
|
||||||
|
> `caddy` is a pinned upstream image. Compose decides whether to recreate a container from its
|
||||||
|
> *config hash*, which covers the mount **specification** (`./Caddyfile:/etc/caddy/Caddyfile:ro`)
|
||||||
|
> but **not the file's contents** — so a `git pull` that changes `./Caddyfile` produces no
|
||||||
|
> delta, Compose reports `Running`, and Caddy keeps serving its old config indefinitely. Exit
|
||||||
|
> code 0 throughout.
|
||||||
|
>
|
||||||
|
> That is not hypothetical: the fix that made the keepsake download work on iOS
|
||||||
|
> (`137c4ee`) touched the Caddyfile and four e2e files and nothing else, so **all** of its
|
||||||
|
> production effect lives in that one file. Without step 4 you deploy it, watch both image IDs
|
||||||
|
> change, and iOS downloads stay broken.
|
||||||
|
>
|
||||||
|
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
|
||||||
|
> **inode** when the container is created, and `git pull` replaces the file instead of editing
|
||||||
|
> it in place, so the container can still be bound to the old, now-unlinked inode. A restart
|
||||||
|
> then re-reads the stale content. Recreating the container re-resolves the path.
|
||||||
|
|
||||||
|
`db` is never touched, and recreating `caddy` does not disturb the `caddy_data` volume, so the
|
||||||
|
TLS certificate and all data volumes survive.
|
||||||
|
|
||||||
### Generate required secrets
|
### Generate required secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,23 +220,176 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
|||||||
└────────┘
|
└────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` and `/media/*` → Rust backend
|
- `/api/*` → Rust backend
|
||||||
- Everything else → SvelteKit frontend (`adapter-node`)
|
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
- Named volumes: `postgres_data`, `media_data`, `exports_data`, `caddy_data`
|
||||||
|
|
||||||
|
Media is **not** served as static files. Every image goes through a
|
||||||
|
visibility-checked alias (`/api/v1/upload/{id}/{preview,display,thumbnail,original}`)
|
||||||
|
so a host takedown or a ban actually revokes access to the bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sizing the disk
|
||||||
|
|
||||||
|
`postgres_data`, `media_data` and `exports_data` are all Docker named volumes under
|
||||||
|
`/var/lib/docker/volumes`, so **they share one filesystem**. Filling it does not
|
||||||
|
degrade one subsystem — Postgres stops being able to write and the whole event goes
|
||||||
|
down.
|
||||||
|
|
||||||
|
Uploads are self-limiting. `per_user_limit = free_disk × quota_tolerance ÷
|
||||||
|
active_uploaders` is recomputed against live free space on every upload, so guests
|
||||||
|
converge on a fixed point at `tolerance / (1 + tolerance)` of the free space you
|
||||||
|
started with — **43%** at the default 0.75. On an 80 GB box with ~70 GB free after
|
||||||
|
the OS and images, media settles at ~30 GB and stops.
|
||||||
|
|
||||||
|
**The keepsake is what the 80 GB baseline does not cover.** `Gallery.zip` and
|
||||||
|
`Memories.zip` are built concurrently and each is roughly a second copy of every
|
||||||
|
original: both write their media `Compression::Stored`, and `Memories.zip` streams the
|
||||||
|
untouched original for every video and for every image at or under 5 MB. So a release
|
||||||
|
wants room for **two more copies of the gallery** on top of the gallery itself.
|
||||||
|
|
||||||
|
| Stage | Used | Free (80 GB box) |
|
||||||
|
|---|---|---|
|
||||||
|
| Fresh box (OS + images) | ~10 GB | ~70 GB |
|
||||||
|
| Guests reach the quota fixed point | ~40 GB | ~40 GB |
|
||||||
|
| Host releases → both archives | ~100 GB | **ENOSPC** |
|
||||||
|
|
||||||
|
Two ways to size for it:
|
||||||
|
|
||||||
|
- **Provision ~3× your expected media** on one volume (media + two archives), or
|
||||||
|
- **give `exports_data` its own volume** so a full export cannot reach Postgres, and
|
||||||
|
size that one at ~2× expected media.
|
||||||
|
|
||||||
|
This is no longer silent. The export refuses up front with the two numbers rather than
|
||||||
|
hitting ENOSPC halfway through a multi-GB write, a rebuild reclaims the superseded
|
||||||
|
generation before it starts (so peak is one generation, not two), and the host
|
||||||
|
dashboard warns as soon as the keepsake would not fit — which is the only point at
|
||||||
|
which anyone can still do something about it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backup
|
## Backup
|
||||||
|
|
||||||
```bash
|
There are **three** things to back up, and they live in three different places.
|
||||||
# Database snapshot
|
`DATABASE_URL` and the container paths (`/media`, `/exports`) are meaningful only
|
||||||
pg_dump $DATABASE_URL | gzip > /media/backups/db_$(date +%Y-%m-%d).sql.gz
|
*inside* the compose network — they are not host paths, and `DATABASE_URL` is
|
||||||
|
never exported into an operator's shell — so every command below runs through
|
||||||
|
`docker compose` from the repo directory.
|
||||||
|
|
||||||
# Weekly offsite sync (Hetzner Storage Box or similar)
|
```bash
|
||||||
rsync -az /opt/eventsnap/media/ user@storagebox.example.com:backup/eventsnap/
|
# 1. Database snapshot. Runs pg_dump inside the db container (the app image has no
|
||||||
|
# postgres client), reading credentials from the compose environment.
|
||||||
|
# --clean --if-exists makes the dump SELF-CLEANING: without it the restore below
|
||||||
|
# aborts on the first "already exists" against a database that has ever booted,
|
||||||
|
# which is every database you would actually want to restore over.
|
||||||
|
mkdir -p ./backups
|
||||||
|
docker compose exec -T db \
|
||||||
|
sh -c 'pg_dump --clean --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB"' \
|
||||||
|
| gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz
|
||||||
|
|
||||||
|
# 2. Uploaded media (originals + derivatives) out of the named volume.
|
||||||
|
# NOTE the mountpoint is /src, not /media: if the volume is ever empty, Docker
|
||||||
|
# pre-populates a fresh mount from the image's own directory, and alpine ships a
|
||||||
|
# /media containing cdrom/floppy/usb. Mounting somewhere the image has nothing
|
||||||
|
# avoids silently tarring (and polluting the volume with) those.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_media_data:/src:ro -v "$PWD/backups":/backup \
|
||||||
|
alpine tar czf /backup/media_$(date +%Y-%m-%d).tar.gz -C /src .
|
||||||
|
|
||||||
|
# 3. Export archives — a SEPARATE volume (see the security note below).
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \
|
||||||
|
alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src .
|
||||||
|
|
||||||
|
# Offsite sync of the three artefacts above.
|
||||||
|
rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/
|
||||||
```
|
```
|
||||||
|
|
||||||
The `/media` volume holds originals, previews, thumbnails, exports, and DB backups — a single path to back up.
|
Volume names are prefixed with the compose project name — `eventsnap_` if you run
|
||||||
|
from a directory called `eventsnap`. Confirm yours with `docker volume ls`.
|
||||||
|
|
||||||
|
> **Exports are deliberately NOT under `/media`.** They live on their own
|
||||||
|
> `exports_data` volume (`EXPORT_PATH=/exports`) because a keepsake archive
|
||||||
|
> contains every photo in the event; keeping it outside the media tree is what
|
||||||
|
> stops it being reachable except through the ticket-gated download handler.
|
||||||
|
> Backing up only the media volume therefore loses every generated keepsake.
|
||||||
|
|
||||||
|
### When to run it
|
||||||
|
|
||||||
|
**A nightly cron is the wrong shape for this app.** Every irreplaceable byte is
|
||||||
|
created inside one eight-hour window, and nobody can retake a wedding. Run the three
|
||||||
|
commands above:
|
||||||
|
|
||||||
|
1. **The night of the event**, once uploads have stopped. This is the backup that
|
||||||
|
matters; everything else is a formality.
|
||||||
|
2. **After the host releases the gallery**, so the generated keepsake is captured too.
|
||||||
|
3. Weekly thereafter, until the event is archived and torn down.
|
||||||
|
|
||||||
|
Take the DB dump and the media tarball **back to back**, without uploads in flight
|
||||||
|
between them. Upload rows reference files by path — a database from 22:00 and a media
|
||||||
|
volume from 23:00 gives you rows pointing at files the dump doesn't know about, and
|
||||||
|
rows whose files aren't in the tarball. Locking uploads from the host dashboard first
|
||||||
|
(**Uploads sperren**) makes the pair genuinely consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
An untested backup is not a backup. Run this once against a scratch host **before**
|
||||||
|
the event — it is roughly ten minutes, and it is the only way to find out that your
|
||||||
|
tarball is empty or your dump is truncated while that is still a small problem.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0. Stop the app FIRST. Migrations run on boot and a live pool will fight the
|
||||||
|
# restore — a booting app against a half-restored schema can leave the migration
|
||||||
|
# table and the schema disagreeing, which is its own recovery problem.
|
||||||
|
# Leave `db` running: the dump is restored through it.
|
||||||
|
docker compose stop app caddy
|
||||||
|
|
||||||
|
# 1. Database. The dump carries its own DROPs (step 1 of Backup), so this replaces
|
||||||
|
# rather than collides. A dump taken WITHOUT --clean --if-exists will abort here
|
||||||
|
# on the first "already exists" — restore that one into a fresh empty database
|
||||||
|
# instead.
|
||||||
|
gunzip -c ./backups/db_2026-07-29.sql.gz \
|
||||||
|
| docker compose exec -T db \
|
||||||
|
sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" --set ON_ERROR_STOP=1'
|
||||||
|
|
||||||
|
# 2. Media. NOTE the `--numeric-owner` and the chown: the app runs as a
|
||||||
|
# NON-ROOT user (uid 100, gid 101 — `addgroup -S app && adduser -S app`), and a
|
||||||
|
# restore that lands root-owned files makes every upload fail with EACCES deep in
|
||||||
|
# the write path, surfacing to the guest as a generic 500 with nothing in the UI
|
||||||
|
# to suggest permissions. The explicit chown is what guarantees it — BusyBox tar
|
||||||
|
# (which is what `alpine` ships) has no --same-owner, and restores ownership only
|
||||||
|
# because it runs as root here.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_media_data:/dst -v "$PWD/backups":/backup:ro \
|
||||||
|
alpine sh -c 'tar xzf /backup/media_2026-07-29.tar.gz -C /dst \
|
||||||
|
--numeric-owner && chown -R 100:101 /dst'
|
||||||
|
|
||||||
|
# 3. Exports. Same volume-name caveat, same ownership rules.
|
||||||
|
docker run --rm \
|
||||||
|
-v eventsnap_exports_data:/dst -v "$PWD/backups":/backup:ro \
|
||||||
|
alpine sh -c 'tar xzf /backup/exports_2026-07-29.tar.gz -C /dst \
|
||||||
|
--numeric-owner && chown -R 100:101 /dst'
|
||||||
|
|
||||||
|
# 4. Back up. Migrations run, then export recovery re-arms any keepsake whose file
|
||||||
|
# didn't come back with the volume.
|
||||||
|
docker compose up -d app caddy
|
||||||
|
docker compose logs -f app # watch for "migrations applied"
|
||||||
|
|
||||||
|
# 5. Verify — all three, not just the first.
|
||||||
|
curl -fsS https://DOMAIN/health && echo # → ok
|
||||||
|
# … then sign in as host and confirm the feed renders images (proves the media
|
||||||
|
# volume restored AND is readable by uid 100), and that the keepsake downloads.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the media volume restored but images 404 while the feed lists them, the paths are
|
||||||
|
there and the bytes aren't — check `docker compose exec app ls -ln /media/originals`
|
||||||
|
and confirm both the files and the `100:101` ownership.
|
||||||
|
|
||||||
|
The restore is deliberately **not** automated. It is rare, destructive, and the one
|
||||||
|
operation where a script that half-works is worse than a checklist someone reads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -254,7 +465,7 @@ Open:
|
|||||||
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
|
- [ ] 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)
|
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
|
||||||
- [ ] Individual file download button per post
|
- [ ] Individual file download button per post
|
||||||
- [ ] Low-disk alert (< 10 GB free)
|
- [x] Low-disk alert — host dashboard warns below 10 GB free, or whenever the keepsake would not fit
|
||||||
- [ ] Event banner / cover image
|
- [ ] Event banner / cover image
|
||||||
- [ ] Chunked resumable upload for files > 100 MB
|
- [ ] Chunked resumable upload for files > 100 MB
|
||||||
- [ ] Shared Tailwind config between main app and export-viewer
|
- [ ] Shared Tailwind config between main app and export-viewer
|
||||||
|
|||||||
3
backend/migrations/015_raise_upload_rate.down.sql
Normal file
3
backend/migrations/015_raise_upload_rate.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Revert the default upload rate to 10/hour for installs still on the raised
|
||||||
|
-- default (preserves any explicit admin override at another value).
|
||||||
|
UPDATE config SET value = '10' WHERE key = 'upload_rate_per_hour' AND value = '100';
|
||||||
10
backend/migrations/015_raise_upload_rate.up.sql
Normal file
10
backend/migrations/015_raise_upload_rate.up.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Raise the default per-guest upload rate from 10/hour to 100/hour.
|
||||||
|
--
|
||||||
|
-- Rationale: guests routinely upload a burst of 10-20 photos at once (phone
|
||||||
|
-- multi-select). At the old default of 10/hour a real guest's first burst was
|
||||||
|
-- throttled — surfaced by the 2026-07-18 load test. 100/hour comfortably covers
|
||||||
|
-- several bursts across an event while still bounding abuse.
|
||||||
|
--
|
||||||
|
-- Only bump installs still on the old default; an admin who deliberately set a
|
||||||
|
-- different value keeps it (migration 005 seeded 10; this UPDATE is scoped to '10').
|
||||||
|
UPDATE config SET value = '100' WHERE key = 'upload_rate_per_hour' AND value = '10';
|
||||||
28
backend/migrations/016_display_derivative.down.sql
Normal file
28
backend/migrations/016_display_derivative.down.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Drop the view (frees the column dependency), remove the column, then restore the
|
||||||
|
-- pre-016 view definition (matches migration 011).
|
||||||
|
DROP VIEW IF EXISTS v_feed;
|
||||||
|
ALTER TABLE upload DROP COLUMN display_path;
|
||||||
|
|
||||||
|
CREATE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
LEFT JOIN "like" l ON l.upload_id = u.id
|
||||||
|
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE
|
||||||
|
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
|
||||||
34
backend/migrations/016_display_derivative.up.sql
Normal file
34
backend/migrations/016_display_derivative.up.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Display derivative: a big-screen-quality image (~2048px long edge) for the diashow.
|
||||||
|
-- The 800px `preview_path` is sized for phone feeds (data saver); upscaled on a projector
|
||||||
|
-- it looks soft. The diashow uses `display_path` instead — bounded in size (safe to decode
|
||||||
|
-- on weak kiosk hardware) yet sharp on 1080p/4K. NULL until the compression worker (or the
|
||||||
|
-- one-time backfill) generates it; consumers fall back to the original when absent.
|
||||||
|
ALTER TABLE upload ADD COLUMN display_path TEXT;
|
||||||
|
|
||||||
|
-- Recreate (not CREATE OR REPLACE, which only allows appending columns at the end) so the
|
||||||
|
-- new column can sit alongside preview_path/thumbnail_path.
|
||||||
|
DROP VIEW IF EXISTS v_feed;
|
||||||
|
CREATE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.display_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
LEFT JOIN "like" l ON l.upload_id = u.id
|
||||||
|
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE
|
||||||
|
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
|
||||||
1
backend/migrations/017_join_ip_rate.down.sql
Normal file
1
backend/migrations/017_join_ip_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM config WHERE key IN ('join_ip_rate_per_min', 'admin_login_rate_enabled');
|
||||||
18
backend/migrations/017_join_ip_rate.up.sql
Normal file
18
backend/migrations/017_join_ip_rate.up.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Per-IP flood ceiling for /join, and the `admin_login_rate_enabled` toggle that
|
||||||
|
-- every prior migration forgot to seed.
|
||||||
|
--
|
||||||
|
-- Rationale: /join was throttled at 5 requests per 60s keyed on the client IP. At a
|
||||||
|
-- venue every guest is behind one NAT, so the whole party shared a single bucket —
|
||||||
|
-- 12 guests scanning the QR code within a few seconds meant 5 got in and 7 were
|
||||||
|
-- turned away. The handler now keys the real anti-spam bucket per (ip, name), the
|
||||||
|
-- same shape as `recover:{ip}:{name}`, and keeps only a loose per-IP ceiling to bound
|
||||||
|
-- raw volume. 60/min comfortably covers a whole wedding arriving at once while still
|
||||||
|
-- capping a flood from a single source.
|
||||||
|
--
|
||||||
|
-- `admin_login_rate_enabled` is read by auth::handlers::admin_login with a code
|
||||||
|
-- default of `true`, but no migration ever inserted it, so it was invisible to the
|
||||||
|
-- admin config UI and to the e2e reseed. Seed it explicitly.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('join_ip_rate_per_min', '60'),
|
||||||
|
('admin_login_rate_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
2
backend/migrations/018_derivatives_rev.down.sql
Normal file
2
backend/migrations/018_derivatives_rev.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_upload_derivatives_rev;
|
||||||
|
ALTER TABLE upload DROP COLUMN IF EXISTS derivatives_rev;
|
||||||
18
backend/migrations/018_derivatives_rev.up.sql
Normal file
18
backend/migrations/018_derivatives_rev.up.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Track which revision of the derivative pipeline produced an upload's preview/display.
|
||||||
|
--
|
||||||
|
-- Rev 1 applies the EXIF orientation tag. Everything generated before it decoded the raw
|
||||||
|
-- sensor pixels and re-encoded to JPEG (which writes no EXIF), so every portrait phone photo
|
||||||
|
-- was stored sideways in the feed preview, the diashow display and the keepsake — while the
|
||||||
|
-- untouched original still rendered upright.
|
||||||
|
--
|
||||||
|
-- Existing rows default to 0 so the startup backfill can find and re-generate them exactly
|
||||||
|
-- once; bump the constant in services/compression.rs if the pipeline ever changes again.
|
||||||
|
ALTER TABLE upload ADD COLUMN IF NOT EXISTS derivatives_rev SMALLINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Only image derivatives are affected — video thumbnails are extracted by ffmpeg, which
|
||||||
|
-- already honours the rotation matrix. Mark them current so the backfill skips them.
|
||||||
|
UPDATE upload SET derivatives_rev = 1 WHERE mime_type NOT LIKE 'image/%';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_upload_derivatives_rev
|
||||||
|
ON upload (derivatives_rev)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
1
backend/migrations/019_recover_ip_rate.down.sql
Normal file
1
backend/migrations/019_recover_ip_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM config WHERE key = 'recover_ip_rate_per_min';
|
||||||
19
backend/migrations/019_recover_ip_rate.up.sql
Normal file
19
backend/migrations/019_recover_ip_rate.up.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Per-IP flood ceiling for /recover, mirroring the one migration 017 added for /join.
|
||||||
|
--
|
||||||
|
-- Rationale: /recover is keyed `recover:{ip}:{name}` at 5 per 15 minutes. That is the
|
||||||
|
-- right shape for its actual job — stopping someone who knows a display name (they are
|
||||||
|
-- visible on the feed) from burning the victim's 3-strike PIN counter and locking them
|
||||||
|
-- out repeatedly. But the name is attacker-chosen, so cycling names mints a fresh bucket
|
||||||
|
-- every time and the per-IP cost is unbounded.
|
||||||
|
--
|
||||||
|
-- Behind that limiter sits a cost-12 bcrypt verify, including an UNCONDITIONAL throwaway
|
||||||
|
-- verify for names that don't exist — deliberately, to close a timing oracle. So an
|
||||||
|
-- unknown name is the cheapest possible way to make the server do ~200ms of hashing.
|
||||||
|
-- Without a ceiling, one client can saturate the box's CPU with a name generator.
|
||||||
|
--
|
||||||
|
-- 30/min is far above any real recovery attempt (a guest tries their PIN a handful of
|
||||||
|
-- times) while capping a name-cycling flood. The per-(ip, name) bucket is unchanged and
|
||||||
|
-- remains the anti-guessing control.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('recover_ip_rate_per_min', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
1
backend/migrations/020_social_rate.down.sql
Normal file
1
backend/migrations/020_social_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DELETE FROM config WHERE key IN ('social_rate_per_min', 'social_rate_enabled');
|
||||||
16
backend/migrations/020_social_rate.up.sql
Normal file
16
backend/migrations/020_social_rate.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Per-user rate limit for social writes (likes, comments, comment deletions).
|
||||||
|
--
|
||||||
|
-- These were the only writes in the app with no limit at all. Every other mutating
|
||||||
|
-- path -- upload, join, recover, export, admin login -- carries one; social.rs
|
||||||
|
-- carried none, so the coverage was asymmetric rather than deliberately open.
|
||||||
|
--
|
||||||
|
-- Severity is genuinely low for an invited-guest event, and the amplification worry
|
||||||
|
-- turned out to be contained: a like fans an SSE broadcast to ~100 clients, but the
|
||||||
|
-- export regeneration it could otherwise trigger is debounced (REGEN_DEBOUNCE 20s)
|
||||||
|
-- and superseded workers are inert. So this closes the gap for symmetry, not urgency,
|
||||||
|
-- and the ceiling is set high enough that no real guest will ever meet it -- a
|
||||||
|
-- double-tapping enthusiast at a wedding is not the thing being defended against.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('social_rate_per_min', '120'),
|
||||||
|
('social_rate_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::{ConnectInfo, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::jwt;
|
use crate::auth::jwt;
|
||||||
@@ -33,22 +34,32 @@ pub struct JoinResponse {
|
|||||||
|
|
||||||
pub async fn join(
|
pub async fn join(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<JoinRequest>,
|
Json(body): Json<JoinRequest>,
|
||||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
||||||
if rate_limits_on
|
|
||||||
&& join_rate_on
|
// Coarse per-IP flood ceiling. `/join` is pre-auth so there is no user to key on, and
|
||||||
&& !state
|
// at a venue EVERY guest arrives from one public IP — a tight per-IP bucket meant the
|
||||||
.rate_limiter
|
// 6th person through the door was turned away by the 5 ahead of them. So the per-IP
|
||||||
.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
// limit here only bounds raw volume; the real anti-spam bucket is per-name below.
|
||||||
{
|
// Cheap enough to run before validation, which keeps a flood of malformed bodies from
|
||||||
return Err(AppError::TooManyRequests(
|
// being free.
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
if rate_limits_on && join_rate_on {
|
||||||
None,
|
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 60).await;
|
||||||
));
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("join_ip:{ip}"),
|
||||||
|
ip_ceiling,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let display_name = body.display_name.trim();
|
let display_name = body.display_name.trim();
|
||||||
@@ -66,6 +77,23 @@ pub async fn join(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-guest bucket, keyed like the `recover:{ip}:{name}` limiter below. This carries
|
||||||
|
// the original 5/60s anti-spam intent, but one guest retrying can no longer consume
|
||||||
|
// the allowance of everyone else sharing the venue's NAT.
|
||||||
|
if rate_limits_on && join_rate_on {
|
||||||
|
let name_key = display_name.to_lowercase();
|
||||||
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("join:{ip}:{name_key}"),
|
||||||
|
5,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let event = Event::find_or_create(
|
let event = Event::find_or_create(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
&state.config.event_slug,
|
&state.config.event_slug,
|
||||||
@@ -83,7 +111,7 @@ pub async fn join(
|
|||||||
|
|
||||||
// Generate a 4-digit PIN
|
// Generate a 4-digit PIN
|
||||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
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)))?;
|
let pin_hash = hash_password(pin.clone(), 12).await?;
|
||||||
|
|
||||||
// The pre-check above is racy: two simultaneous joins with the same name can both
|
// The pre-check above is racy: two simultaneous joins with the same name can both
|
||||||
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
||||||
@@ -147,8 +175,31 @@ fn dummy_pin_hash() -> &'static str {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a bcrypt verify on the blocking pool.
|
||||||
|
///
|
||||||
|
/// bcrypt at cost 12 is ~200ms of deliberate CPU. Called inline on an async task it pins a
|
||||||
|
/// tokio WORKER thread for that whole time, and the runtime only has one per core — so a
|
||||||
|
/// flood of `/recover` or `/admin/login` attempts stalls every other request on the box,
|
||||||
|
/// including the feed. Offloading moves that cost to the blocking pool, which is sized for
|
||||||
|
/// exactly this and whose saturation degrades logins rather than the whole app.
|
||||||
|
async fn verify_password(candidate: String, hash: String) -> bool {
|
||||||
|
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a secret on the blocking pool. Same reasoning as [`verify_password`] — and this one
|
||||||
|
/// runs on the busiest auth path there is, since every guest who joins gets a PIN hashed.
|
||||||
|
pub async fn hash_password(secret: String, cost: u32) -> Result<String, AppError> {
|
||||||
|
tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn recover(
|
pub async fn recover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<RecoverRequest>,
|
Json(body): Json<RecoverRequest>,
|
||||||
) -> Result<Json<RecoverResponse>, AppError> {
|
) -> Result<Json<RecoverResponse>, AppError> {
|
||||||
@@ -159,19 +210,39 @@ pub async fn recover(
|
|||||||
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
// 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)
|
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
||||||
// softens that into a real cost.
|
// softens that into a real cost.
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
||||||
if rate_limits_on && recover_rate_on {
|
if rate_limits_on && recover_rate_on {
|
||||||
|
// Coarse per-IP ceiling FIRST. The per-(ip, name) bucket below is the anti-guessing
|
||||||
|
// control, but the name is attacker-chosen, so cycling names mints a fresh bucket
|
||||||
|
// every time and leaves the per-IP cost unbounded. That matters more here than
|
||||||
|
// anywhere else: every call runs a cost-12 bcrypt verify, including an
|
||||||
|
// unconditional throwaway one for names that don't exist (see below), so an unknown
|
||||||
|
// name is the CHEAPEST way to make the server do ~200ms of hashing. Checked before
|
||||||
|
// the per-name bucket so a name generator can't walk past it.
|
||||||
|
let ip_ceiling =
|
||||||
|
config::get_usize(&state.config_cache, "recover_ip_rate_per_min", 30).await;
|
||||||
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("recover_ip:{ip}"),
|
||||||
|
ip_ceiling,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let name_key = display_name.to_lowercase();
|
let name_key = display_name.to_lowercase();
|
||||||
if !state.rate_limiter.check(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("recover:{ip}:{name_key}"),
|
format!("recover:{ip}:{name_key}"),
|
||||||
5,
|
5,
|
||||||
Duration::from_secs(15 * 60),
|
Duration::from_secs(15 * 60),
|
||||||
) {
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +259,7 @@ pub async fn recover(
|
|||||||
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
|
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
|
||||||
// timing. Display names are already public on the feed, but this still closes
|
// timing. Display names are already public on the feed, but this still closes
|
||||||
// the /recover enumeration + timing oracle.
|
// the /recover enumeration + timing oracle.
|
||||||
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
|
let _ = verify_password(body.pin.clone(), dummy_pin_hash().to_string()).await;
|
||||||
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,16 +271,19 @@ pub async fn recover(
|
|||||||
// is effectively permanently fragile.
|
// is effectively permanently fragile.
|
||||||
if let Some(locked_until) = user.pin_locked_until {
|
if let Some(locked_until) = user.pin_locked_until {
|
||||||
if Utc::now() < locked_until {
|
if Utc::now() < locked_until {
|
||||||
|
// The exact deadline is known, so surface it as Retry-After instead of
|
||||||
|
// making the client guess at the "15 Minuten" in the copy.
|
||||||
|
let retry_after_secs = (locked_until - Utc::now()).num_seconds().max(1) as u64;
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Lockout window expired — wipe the counter and the timestamp.
|
// Lockout window expired — wipe the counter and the timestamp.
|
||||||
User::reset_pin_attempts(&state.pool, user.id).await?;
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash).unwrap_or(false);
|
let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await;
|
||||||
|
|
||||||
if pin_matches {
|
if pin_matches {
|
||||||
// Reset failed attempts on success
|
// Reset failed attempts on success
|
||||||
@@ -274,6 +348,7 @@ pub struct AdminLoginResponse {
|
|||||||
|
|
||||||
pub async fn admin_login(
|
pub async fn admin_login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<AdminLoginRequest>,
|
Json(body): Json<AdminLoginRequest>,
|
||||||
) -> Result<Json<AdminLoginResponse>, AppError> {
|
) -> Result<Json<AdminLoginResponse>, AppError> {
|
||||||
@@ -287,23 +362,31 @@ pub async fn admin_login(
|
|||||||
// verify) but with no IP-level limit a determined attacker can still mount
|
// 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
|
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
||||||
// honest typos.
|
// honest typos.
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let admin_rate_on =
|
let admin_rate_on =
|
||||||
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||||
|
// Stays keyed by IP on purpose: this guards a single shared credential, so a per-user
|
||||||
|
// or per-name key would just hand an attacker a fresh bucket per guess.
|
||||||
if rate_limits_on
|
if rate_limits_on
|
||||||
&& admin_rate_on
|
&& admin_rate_on
|
||||||
&& !state
|
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
.rate_limiter
|
format!("admin_login:{ip}"),
|
||||||
.check(format!("admin_login:{ip}"), 5, Duration::from_secs(60))
|
5,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash).unwrap_or(false);
|
let valid = verify_password(
|
||||||
|
body.password.clone(),
|
||||||
|
state.config.admin_password_hash.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||||
@@ -329,8 +412,7 @@ pub async fn admin_login(
|
|||||||
let dummy_pin: String = (0..32)
|
let dummy_pin: String = (0..32)
|
||||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||||
.collect();
|
.collect();
|
||||||
let dummy_hash =
|
let dummy_hash = hash_password(dummy_pin.clone(), 4).await?;
|
||||||
bcrypt::hash(&dummy_pin, 4).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
||||||
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
@@ -390,22 +472,23 @@ pub struct PinResetRequestBody {
|
|||||||
/// feed already exposes.
|
/// feed already exposes.
|
||||||
pub async fn request_pin_reset(
|
pub async fn request_pin_reset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<PinResetRequestBody>,
|
Json(body): Json<PinResetRequestBody>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let display_name = body.display_name.trim();
|
let display_name = body.display_name.trim();
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
if rate_limits_on {
|
if rate_limits_on {
|
||||||
let name_key = display_name.to_lowercase();
|
let name_key = display_name.to_lowercase();
|
||||||
if !state.rate_limiter.check(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("pin_reset_req:{ip}:{name_key}"),
|
format!("pin_reset_req:{ip}:{name_key}"),
|
||||||
3,
|
3,
|
||||||
Duration::from_secs(15 * 60),
|
Duration::from_secs(15 * 60),
|
||||||
) {
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,25 @@ pub struct AppConfig {
|
|||||||
pub app_port: u16,
|
pub app_port: u16,
|
||||||
/// Number of concurrent media compression workers (read once at boot).
|
/// Number of concurrent media compression workers (read once at boot).
|
||||||
pub compression_concurrency: usize,
|
pub compression_concurrency: usize,
|
||||||
|
/// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true).
|
||||||
|
/// When false the backend rejects new comments and the frontend hides the whole
|
||||||
|
/// comment UI. Existing comments stay in the DB (hidden), so flipping it back
|
||||||
|
/// restores them. Boot-time immutable, like `compression_concurrency`.
|
||||||
|
pub comments_enabled: bool,
|
||||||
|
/// Default colour theme, used as the fallback when the DB config keys are unset.
|
||||||
|
/// Runtime overrides live in the `config` table (admin UI); these env vars only
|
||||||
|
/// seed the initial default. `preset` is an id the frontend knows (e.g.
|
||||||
|
/// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand +
|
||||||
|
/// accent colours the whole palette is derived from.
|
||||||
|
pub default_theme_preset: String,
|
||||||
|
pub default_theme_primary: String,
|
||||||
|
pub default_theme_accent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned
|
||||||
|
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
|
||||||
|
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||||
@@ -100,6 +117,20 @@ impl AppConfig {
|
|||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.filter(|&n| n >= 1)
|
.filter(|&n| n >= 1)
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
|
comments_enabled: std::env::var("COMMENTS_ENABLED")
|
||||||
|
.map(|v| {
|
||||||
|
!matches!(
|
||||||
|
v.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"false" | "0" | "no" | "off"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(true),
|
||||||
|
default_theme_preset: std::env::var("THEME_PRESET")
|
||||||
|
.unwrap_or_else(|_| "champagne-gold".to_string()),
|
||||||
|
default_theme_primary: std::env::var("THEME_PRIMARY")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
|
||||||
|
default_theme_accent: std::env::var("THEME_ACCENT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::middleware::RequireAdmin;
|
use crate::auth::middleware::RequireAdmin;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -120,6 +120,16 @@ pub async fn patch_config(
|
|||||||
("upload_rate_per_hour", true, 1.0, 100_000.0),
|
("upload_rate_per_hour", true, 1.0, 100_000.0),
|
||||||
("feed_rate_per_min", true, 1.0, 100_000.0),
|
("feed_rate_per_min", true, 1.0, 100_000.0),
|
||||||
("export_rate_per_day", true, 1.0, 100_000.0),
|
("export_rate_per_day", true, 1.0, 100_000.0),
|
||||||
|
// Loose per-IP ceiling on /join. The real anti-spam bucket is per (ip, name); this
|
||||||
|
// only bounds raw volume from one source, so it must stay well above the size of a
|
||||||
|
// party arriving at once (see migration 017).
|
||||||
|
("join_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||||
|
// Same shape for /recover: the per-(ip, name) bucket is the anti-guessing control,
|
||||||
|
// this only bounds a name-cycling flood in front of a cost-12 bcrypt (migration 019).
|
||||||
|
("recover_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||||
|
// Aggregate ceiling on likes + comments + comment deletions, per user per minute.
|
||||||
|
// These were the only mutating endpoints with no limit at all (migration 020).
|
||||||
|
("social_rate_per_min", true, 1.0, 100_000.0),
|
||||||
("quota_tolerance", false, 0.0, 1.0),
|
("quota_tolerance", false, 0.0, 1.0),
|
||||||
("estimated_guest_count", true, 1.0, 1_000_000.0),
|
("estimated_guest_count", true, 1.0, 1_000_000.0),
|
||||||
];
|
];
|
||||||
@@ -134,14 +144,32 @@ pub async fn patch_config(
|
|||||||
// missing from this allowlist — so the switch existed in code and could never be flipped.
|
// missing from this allowlist — so the switch existed in code and could never be flipped.
|
||||||
"admin_login_rate_enabled",
|
"admin_login_rate_enabled",
|
||||||
"recover_rate_enabled",
|
"recover_rate_enabled",
|
||||||
|
"social_rate_enabled",
|
||||||
"quota_enabled",
|
"quota_enabled",
|
||||||
"storage_quota_enabled",
|
"storage_quota_enabled",
|
||||||
"upload_count_quota_enabled",
|
"upload_count_quota_enabled",
|
||||||
];
|
];
|
||||||
const TEXT_KEYS: &[&str] = &["privacy_note"];
|
const TEXT_KEYS: &[&str] = &[
|
||||||
|
"privacy_note",
|
||||||
|
"theme_preset",
|
||||||
|
"theme_primary",
|
||||||
|
"theme_accent",
|
||||||
|
];
|
||||||
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
|
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
|
||||||
|
// Preset ids the frontend knows how to render (mirror of PRESETS in
|
||||||
|
// frontend/src/lib/theme/palette.ts). "custom" means "use the theme_primary/accent
|
||||||
|
// seeds verbatim". Kept in sync by hand — a new preset must be added in both places.
|
||||||
|
const THEME_PRESETS: &[&str] = &[
|
||||||
|
"champagne-gold",
|
||||||
|
"rose",
|
||||||
|
"sage",
|
||||||
|
"dusk-blue",
|
||||||
|
"classic-silver",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
let mut privacy_note_changed = false;
|
let mut privacy_note_changed = false;
|
||||||
|
let mut theme_changed = false;
|
||||||
|
|
||||||
// Validate every key first so a bad value in the batch can't leave a partial
|
// Validate every key first so a bad value in the batch can't leave a partial
|
||||||
// update behind — validation must fully precede any write.
|
// update behind — validation must fully precede any write.
|
||||||
@@ -186,8 +214,23 @@ pub async fn patch_config(
|
|||||||
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if key_str == "privacy_note" {
|
match key_str {
|
||||||
privacy_note_changed = true;
|
"privacy_note" => privacy_note_changed = true,
|
||||||
|
"theme_preset" => {
|
||||||
|
if !THEME_PRESETS.contains(&value.trim()) {
|
||||||
|
return Err(AppError::BadRequest(format!("Ungültiges Theme: {value}.")));
|
||||||
|
}
|
||||||
|
theme_changed = true;
|
||||||
|
}
|
||||||
|
"theme_primary" | "theme_accent" => {
|
||||||
|
if !is_hex_color(value.trim()) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Ungültige Farbe für {key}: muss #rrggbb sein."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
theme_changed = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
@@ -217,16 +260,30 @@ pub async fn patch_config(
|
|||||||
|
|
||||||
// Notify all clients that a publicly-readable config value changed so their stores
|
// 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.
|
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
||||||
if privacy_note_changed {
|
if privacy_note_changed || theme_changed {
|
||||||
|
let mut keys: Vec<&str> = Vec::new();
|
||||||
|
if privacy_note_changed {
|
||||||
|
keys.push("privacy_note");
|
||||||
|
}
|
||||||
|
if theme_changed {
|
||||||
|
keys.push("theme");
|
||||||
|
}
|
||||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
"event-updated",
|
"event-updated",
|
||||||
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
|
serde_json::json!({ "keys": keys }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A strict `#rrggbb` hex-colour check (6 hex digits, leading `#`). Deliberately not
|
||||||
|
/// accepting shorthand/`#rgba` so the value is safe to drop straight into CSS.
|
||||||
|
fn is_hex_color(s: &str) -> bool {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(|b| b.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_export_jobs(
|
pub async fn get_export_jobs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
@@ -274,25 +331,26 @@ pub async fn export_ticket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a download ticket (single-use) and confirm its session still exists.
|
/// Validate a download ticket (single-use) and confirm its session still exists.
|
||||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
|
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
|
||||||
|
/// id to key the export rate limit per-user (see `enforce_export_rate`).
|
||||||
|
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
|
||||||
let token_hash = state
|
let token_hash = state
|
||||||
.sse_tickets
|
.sse_tickets
|
||||||
.consume(ticket)
|
.consume(ticket)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||||
crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
|
let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
|
||||||
Ok(())
|
Ok(session.user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_zip(
|
pub async fn download_zip(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<DownloadQuery>,
|
Query(q): Query<DownloadQuery>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||||
enforce_export_rate(&state, &headers).await?;
|
enforce_export_rate(&state, user_id).await?;
|
||||||
|
|
||||||
let path =
|
let path =
|
||||||
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||||
@@ -343,10 +401,9 @@ async fn resolve_export_file(
|
|||||||
pub async fn download_html(
|
pub async fn download_html(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<DownloadQuery>,
|
Query(q): Query<DownloadQuery>,
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||||
enforce_export_rate(&state, &headers).await?;
|
enforce_export_rate(&state, user_id).await?;
|
||||||
|
|
||||||
let path =
|
let path =
|
||||||
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
|
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
|
||||||
@@ -403,8 +460,13 @@ pub async fn export_status(
|
|||||||
// worker superseded mid-run) is meaningless — surfacing its frozen `running`/77% would show a
|
// worker superseded mid-run) is meaningless — surfacing its frozen `running`/77% would show a
|
||||||
// progress bar that never moves for a keepsake nobody is building. It reads as "locked" (no
|
// progress bar that never moves for a keepsake nobody is building. It reads as "locked" (no
|
||||||
// current job), which is exactly what it is.
|
// current job), which is exactly what it is.
|
||||||
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
|
// `error_message` is carried here, not just on the admin dashboard's job list. The host is the
|
||||||
"SELECT j.type::text, j.status::text, j.progress_pct
|
// one who releases the keepsake and the one who owns the "Erneut versuchen" button, but this
|
||||||
|
// endpoint used to hand them a bare `failed` — so a fully actionable reason (notably the disk
|
||||||
|
// preflight's "needs X GB, Y GB free") was written to the row and then shown to nobody who
|
||||||
|
// could act on it. An admin-only diagnostic is not a diagnostic for the person on the spot.
|
||||||
|
let jobs: Vec<(String, String, i16, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT j.type::text, j.status::text, j.progress_pct, j.error_message
|
||||||
FROM export_job j
|
FROM export_job j
|
||||||
JOIN event e ON e.id = j.event_id
|
JOIN event e ON e.id = j.event_id
|
||||||
WHERE e.id = $1 AND j.epoch = e.export_epoch",
|
WHERE e.id = $1 AND j.epoch = e.export_epoch",
|
||||||
@@ -415,9 +477,21 @@ pub async fn export_status(
|
|||||||
|
|
||||||
let job_status = |type_name: &str| {
|
let job_status = |type_name: &str| {
|
||||||
jobs.iter()
|
jobs.iter()
|
||||||
.find(|(t, _, _)| t == type_name)
|
.find(|(t, _, _, _)| t == type_name)
|
||||||
.map(|(_, status, pct)| serde_json::json!({ "status": status, "progress_pct": pct }))
|
.map(|(_, status, pct, err)| {
|
||||||
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
|
serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"progress_pct": pct,
|
||||||
|
// Only on a failure. A stale message left on a row that has since been re-armed
|
||||||
|
// would otherwise show an error next to a running progress bar.
|
||||||
|
"error_message": if status == "failed" { err.clone() } else { None },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "locked", "progress_pct": 0, "error_message": null,
|
||||||
|
})
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
@@ -430,21 +504,24 @@ pub async fn export_status(
|
|||||||
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
|
/// 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
|
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
|
||||||
/// each request.
|
/// each request.
|
||||||
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
|
let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
|
||||||
if !(rate_limits_on && export_rate_on) {
|
if !(rate_limits_on && export_rate_on) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let ip = client_ip(headers, "unknown");
|
|
||||||
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
|
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
|
||||||
if !state
|
// Keyed per-user. This was the worst of the IP-keyed limiters: 3 downloads per DAY
|
||||||
.rate_limiter
|
// shared across every guest behind the venue's public IP, so the fourth person to
|
||||||
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
|
// fetch their keepsake was locked out until the next day.
|
||||||
{
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("export:{user_id}"),
|
||||||
|
limit,
|
||||||
|
Duration::from_secs(86400),
|
||||||
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::HeaderMap;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -10,7 +9,6 @@ use uuid::Uuid;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -27,6 +25,8 @@ pub struct FeedUpload {
|
|||||||
pub uploader_name: String,
|
pub uploader_name: String,
|
||||||
pub preview_url: Option<String>,
|
pub preview_url: Option<String>,
|
||||||
pub thumbnail_url: Option<String>,
|
pub thumbnail_url: Option<String>,
|
||||||
|
/// Big-screen (~2048px) variant for the diashow. Absent until the derivative exists.
|
||||||
|
pub display_url: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub caption: Option<String>,
|
pub caption: Option<String>,
|
||||||
pub like_count: i64,
|
pub like_count: i64,
|
||||||
@@ -48,6 +48,7 @@ struct FeedRow {
|
|||||||
uploader_name: String,
|
uploader_name: String,
|
||||||
preview_path: Option<String>,
|
preview_path: Option<String>,
|
||||||
thumbnail_path: Option<String>,
|
thumbnail_path: Option<String>,
|
||||||
|
display_path: Option<String>,
|
||||||
mime_type: String,
|
mime_type: String,
|
||||||
caption: Option<String>,
|
caption: Option<String>,
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
@@ -58,21 +59,23 @@ struct FeedRow {
|
|||||||
pub async fn feed(
|
pub async fn feed(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
headers: HeaderMap,
|
|
||||||
Query(q): Query<FeedQuery>,
|
Query(q): Query<FeedQuery>,
|
||||||
) -> Result<Json<FeedResponse>, AppError> {
|
) -> Result<Json<FeedResponse>, AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||||
if rate_limits_on && feed_rate_on {
|
if rate_limits_on && feed_rate_on {
|
||||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||||
if !state
|
// Keyed per-user, exactly like `feed_delta` below: at a venue every guest shares
|
||||||
.rate_limiter
|
// one public IP, so an IP key gave the whole party a single 60/min bucket and the
|
||||||
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
|
// fastest scroller starved everyone else.
|
||||||
{
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("feed:{}", auth.user_id),
|
||||||
|
rate_limit,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,8 @@ pub async fn feed(
|
|||||||
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
|
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
|
||||||
sqlx::query_as::<_, FeedRow>(
|
sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
|
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
|
||||||
v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at
|
v.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
|
||||||
|
v.created_at
|
||||||
FROM v_feed v
|
FROM v_feed v
|
||||||
JOIN upload_hashtag uh ON uh.upload_id = v.id
|
JOIN upload_hashtag uh ON uh.upload_id = v.id
|
||||||
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
|
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
|
||||||
@@ -113,7 +117,7 @@ pub async fn feed(
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, FeedRow>(
|
sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||||
mime_type, caption, like_count, comment_count, created_at
|
display_path, mime_type, caption, like_count, comment_count, created_at
|
||||||
FROM v_feed
|
FROM v_feed
|
||||||
WHERE event_id = $1
|
WHERE event_id = $1
|
||||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
||||||
@@ -154,6 +158,10 @@ pub async fn feed(
|
|||||||
.thumbnail_path
|
.thumbnail_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
|
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
|
||||||
|
let display_url = r
|
||||||
|
.display_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| format!("/api/v1/upload/{}/display", r.id));
|
||||||
FeedUpload {
|
FeedUpload {
|
||||||
liked_by_me: liked_set.contains(&r.id),
|
liked_by_me: liked_set.contains(&r.id),
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -161,6 +169,7 @@ pub async fn feed(
|
|||||||
uploader_name: r.uploader_name,
|
uploader_name: r.uploader_name,
|
||||||
preview_url,
|
preview_url,
|
||||||
thumbnail_url,
|
thumbnail_url,
|
||||||
|
display_url,
|
||||||
mime_type: r.mime_type,
|
mime_type: r.mime_type,
|
||||||
caption: r.caption,
|
caption: r.caption,
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
@@ -216,14 +225,14 @@ pub async fn feed_delta(
|
|||||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||||
if rate_limits_on && feed_rate_on {
|
if rate_limits_on && feed_rate_on {
|
||||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||||
if !state.rate_limiter.check(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("feed_delta:{}", auth.user_id),
|
format!("feed_delta:{}", auth.user_id),
|
||||||
rate_limit,
|
rate_limit,
|
||||||
Duration::from_secs(60),
|
Duration::from_secs(60),
|
||||||
) {
|
) {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +256,7 @@ pub async fn feed_delta(
|
|||||||
// response's `server_time`, so this doesn't re-fetch on every subsequent delta.
|
// response's `server_time`, so this doesn't re-fetch on every subsequent delta.
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(
|
let rows = sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||||
mime_type, caption, like_count, comment_count, created_at
|
display_path, mime_type, caption, like_count, comment_count, created_at
|
||||||
FROM v_feed
|
FROM v_feed
|
||||||
WHERE event_id = $1 AND created_at >= $2
|
WHERE event_id = $1 AND created_at >= $2
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
@@ -304,6 +313,10 @@ pub async fn feed_delta(
|
|||||||
.thumbnail_path
|
.thumbnail_path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
|
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
|
||||||
|
display_url: r
|
||||||
|
.display_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| format!("/api/v1/upload/{}/display", r.id)),
|
||||||
mime_type: r.mime_type,
|
mime_type: r.mime_type,
|
||||||
caption: r.caption,
|
caption: r.caption,
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
|
|||||||
@@ -35,6 +35,32 @@ pub struct EventStatus {
|
|||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub uploads_locked: bool,
|
pub uploads_locked: bool,
|
||||||
pub export_released: bool,
|
pub export_released: bool,
|
||||||
|
/// Free space on the volume the keepsake is written to. `None` when the mount can't be
|
||||||
|
/// resolved — the UI hides the widget rather than rendering a confident zero.
|
||||||
|
pub disk_free_bytes: Option<u64>,
|
||||||
|
/// What a full keepsake build would need right now (both halves).
|
||||||
|
pub keepsake_required_bytes: u64,
|
||||||
|
/// Whether the host should be warned. See [`disk_is_low`].
|
||||||
|
pub disk_low: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute floor below which free space is worth surfacing regardless of gallery size — the
|
||||||
|
/// threshold the README has carried on the roadmap since v1.
|
||||||
|
const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
||||||
|
|
||||||
|
/// Is free space low enough that the host needs to know?
|
||||||
|
///
|
||||||
|
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
|
||||||
|
/// `media_data` and `exports_data` are all Docker named volumes on one filesystem, so a full disk
|
||||||
|
/// does not degrade one subsystem — it stops Postgres writing and takes the event down. That is
|
||||||
|
/// what the absolute floor is for.
|
||||||
|
///
|
||||||
|
/// The second trigger is the one that actually earns its place: the keepsake needs room for two
|
||||||
|
/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they
|
||||||
|
/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end
|
||||||
|
/// into a decision someone can still make.
|
||||||
|
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
||||||
|
free < LOW_DISK_FLOOR_BYTES || free < keepsake_required
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
||||||
@@ -72,11 +98,29 @@ pub async fn get_event_status(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
// Measured on the EXPORT volume, not the media one: that is where the cliff is, and it is a
|
||||||
|
// distinct mount point even when both are backed by the same filesystem. The cached reading is
|
||||||
|
// right here — this is advisory, polled on every dashboard load, and a 15s-stale number costs
|
||||||
|
// nothing (unlike the export preflight, which reads uncached because it is about to write).
|
||||||
|
let free = state
|
||||||
|
.disk_cache
|
||||||
|
.snapshot(&state.config.export_path)
|
||||||
|
.map(|d| d.free);
|
||||||
|
let keepsake_required_bytes =
|
||||||
|
crate::services::export::keepsake_space_required(&state.pool, event.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(Json(EventStatus {
|
Ok(Json(EventStatus {
|
||||||
name: event.name,
|
name: event.name,
|
||||||
is_active: event.is_active,
|
is_active: event.is_active,
|
||||||
uploads_locked: event.uploads_locked_at.is_some(),
|
uploads_locked: event.uploads_locked_at.is_some(),
|
||||||
export_released: event.export_released_at.is_some(),
|
export_released: event.export_released_at.is_some(),
|
||||||
|
disk_free_bytes: free,
|
||||||
|
keepsake_required_bytes,
|
||||||
|
// Unknown free space is NOT low. Fails open, exactly as the upload quota and the export
|
||||||
|
// preflight do: a scary banner on an unreadable mount would train the host to ignore it.
|
||||||
|
disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +346,7 @@ pub async fn rebuild_export(
|
|||||||
r.event_id,
|
r.event_id,
|
||||||
r.event_name,
|
r.event_name,
|
||||||
r.epoch,
|
r.epoch,
|
||||||
|
state.config.comments_enabled,
|
||||||
std::time::Duration::ZERO,
|
std::time::Duration::ZERO,
|
||||||
state.pool.clone(),
|
state.pool.clone(),
|
||||||
state.config.media_path.clone(),
|
state.config.media_path.clone(),
|
||||||
@@ -432,7 +477,7 @@ pub async fn reset_user_pin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
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)))?;
|
let pin_hash = crate::auth::handlers::hash_password(pin.clone(), 12).await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
@@ -549,6 +594,7 @@ pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRege
|
|||||||
regen.event_id,
|
regen.event_id,
|
||||||
regen.event_name,
|
regen.event_name,
|
||||||
regen.epoch,
|
regen.epoch,
|
||||||
|
state.config.comments_enabled,
|
||||||
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
|
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
|
||||||
// delay lets superseded workers fail their claim and do zero work instead of each building
|
// delay lets superseded workers fail their claim and do zero work instead of each building
|
||||||
// a full archive. See export::REGEN_DEBOUNCE.
|
// a full archive. See export::REGEN_DEBOUNCE.
|
||||||
@@ -753,6 +799,7 @@ pub async fn release_gallery(
|
|||||||
event_id,
|
event_id,
|
||||||
event_name,
|
event_name,
|
||||||
epoch,
|
epoch,
|
||||||
|
state.config.comments_enabled,
|
||||||
std::time::Duration::ZERO,
|
std::time::Duration::ZERO,
|
||||||
state.pool.clone(),
|
state.pool.clone(),
|
||||||
state.config.media_path.clone(),
|
state.config.media_path.clone(),
|
||||||
@@ -762,3 +809,45 @@ pub async fn release_gallery(
|
|||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
|
||||||
|
|
||||||
|
const GB: u64 = 1_000_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
|
||||||
|
assert!(!disk_is_low(40 * GB, 25 * GB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
|
||||||
|
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
|
||||||
|
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
|
||||||
|
// the keepsake test comfortably; the floor is what catches this.
|
||||||
|
assert!(disk_is_low(5 * GB, GB));
|
||||||
|
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
|
||||||
|
assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() {
|
||||||
|
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
|
||||||
|
// any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this
|
||||||
|
// before releasing; after releasing, they cannot.
|
||||||
|
assert!(disk_is_low(30 * GB, 66 * GB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_keepsake_trigger_is_exact_at_the_boundary() {
|
||||||
|
assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough");
|
||||||
|
assert!(disk_is_low(66 * GB - 1, 66 * GB));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
|
||||||
|
assert!(!disk_is_low(11 * GB, 0));
|
||||||
|
assert!(disk_is_low(9 * GB, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use serde::Serialize;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::handlers::upload::compute_storage_quota;
|
use crate::handlers::upload::compute_storage_quota;
|
||||||
use crate::models::user::User;
|
use crate::models::user::{User, UserRole};
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -37,12 +37,26 @@ pub async fn get_quota(
|
|||||||
|
|
||||||
let estimate = compute_storage_quota(&state).await;
|
let estimate = compute_storage_quota(&state).await;
|
||||||
|
|
||||||
|
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
|
||||||
|
// must never reach a guest, even though the guest upload UI no longer renders it.
|
||||||
|
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
|
||||||
|
// the code paths that consume it; only the server-wide fields are zeroed.
|
||||||
|
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
|
||||||
|
|
||||||
Ok(Json(QuotaDto {
|
Ok(Json(QuotaDto {
|
||||||
enabled: estimate.limit_bytes.is_some(),
|
enabled: estimate.limit_bytes.is_some(),
|
||||||
used_bytes: user.total_upload_bytes,
|
used_bytes: user.total_upload_bytes,
|
||||||
limit_bytes: estimate.limit_bytes,
|
limit_bytes: estimate.limit_bytes,
|
||||||
active_uploaders: estimate.active_uploaders,
|
active_uploaders: if is_staff {
|
||||||
free_disk_bytes: estimate.free_disk_bytes,
|
estimate.active_uploaders
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
free_disk_bytes: if is_staff {
|
||||||
|
estimate.free_disk_bytes
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,41 @@ use axum::Json;
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct PublicEventDto {
|
pub struct PublicEventDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
/// Whether the comment feature is on (env `COMMENTS_ENABLED`). The frontend hides
|
||||||
|
/// the whole comment UI when false; exposed here so even the pre-auth shell knows.
|
||||||
|
pub comments_enabled: bool,
|
||||||
|
/// Active colour theme. `preset` is an id the frontend maps to a palette (or
|
||||||
|
/// "custom"); `primary`/`accent` are the `#rrggbb` seeds the ramps derive from.
|
||||||
|
/// Resolved as DB-config override → env default. Public so the theme applies on
|
||||||
|
/// the very first (pre-auth) paint without a flash.
|
||||||
|
pub theme_preset: String,
|
||||||
|
pub theme_primary: String,
|
||||||
|
pub theme_accent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public event identity, used by the pre-auth join/recover screens so a guest can
|
/// Public event identity + presentation config, used by the pre-auth join/recover
|
||||||
/// see *which* event they're joining. Only the display name and slug are exposed —
|
/// screens (which event am I joining, what does it look like). Only non-user-scoped
|
||||||
/// nothing user-scoped — so this is safe without a token. Served straight from the
|
/// fields are exposed, so this is safe without a token. Identity comes straight from
|
||||||
/// instance config (no DB round-trip needed).
|
/// instance config; the theme is resolved from the runtime `config` table (admin UI)
|
||||||
|
/// falling back to the env-seeded default.
|
||||||
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
||||||
|
let cache = &state.config_cache;
|
||||||
Json(PublicEventDto {
|
Json(PublicEventDto {
|
||||||
name: state.config.event_name.clone(),
|
name: state.config.event_name.clone(),
|
||||||
slug: state.config.event_slug.clone(),
|
slug: state.config.event_slug.clone(),
|
||||||
|
comments_enabled: state.config.comments_enabled,
|
||||||
|
theme_preset: config::get_str(cache, "theme_preset", &state.config.default_theme_preset)
|
||||||
|
.await,
|
||||||
|
theme_primary: config::get_str(cache, "theme_primary", &state.config.default_theme_primary)
|
||||||
|
.await,
|
||||||
|
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
|
||||||
|
.await,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,40 @@ use crate::error::AppError;
|
|||||||
use crate::models::comment::{Comment, CommentDto};
|
use crate::models::comment::{Comment, CommentDto};
|
||||||
use crate::models::hashtag::{self, Hashtag};
|
use crate::models::hashtag::{self, Hashtag};
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Throttle a social write. Keyed PER USER, like the feed and upload limits and for the same
|
||||||
|
/// reason: at a venue every guest sits behind one NAT, so an IP key hands the whole party a
|
||||||
|
/// single bucket and the most active guest starves everyone else.
|
||||||
|
///
|
||||||
|
/// These were the only mutating endpoints in the app with no limit at all — the coverage was
|
||||||
|
/// asymmetric, not deliberately open. The ceiling is set well above anything a real guest
|
||||||
|
/// produces; this bounds a script, not an enthusiastic double-tapper.
|
||||||
|
async fn check_social_rate(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
|
let social_rate_on = config::get_bool(&state.config_cache, "social_rate_enabled", true).await;
|
||||||
|
if !(rate_limits_on && social_rate_on) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rate_limit = config::get_usize(&state.config_cache, "social_rate_per_min", 120).await;
|
||||||
|
// ONE bucket across likes, comments and comment deletions. Separate buckets would let a
|
||||||
|
// caller triple the aggregate write rate just by alternating between them.
|
||||||
|
state
|
||||||
|
.rate_limiter
|
||||||
|
.check_with_retry(
|
||||||
|
format!("social:{user_id}"),
|
||||||
|
rate_limit,
|
||||||
|
std::time::Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
.map_err(|retry_after_secs| {
|
||||||
|
AppError::TooManyRequests(
|
||||||
|
"Zu viele Aktionen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LikeResponse {
|
pub struct LikeResponse {
|
||||||
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
||||||
@@ -35,6 +67,7 @@ pub async fn toggle_like(
|
|||||||
if user.is_banned {
|
if user.is_banned {
|
||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
check_social_rate(&state, auth.user_id).await?;
|
||||||
|
|
||||||
// Event-scope: the upload must belong to the caller's event (404 otherwise),
|
// Event-scope: the upload must belong to the caller's event (404 otherwise),
|
||||||
// matching the host handlers' find_by_id_and_event pattern.
|
// matching the host handlers' find_by_id_and_event pattern.
|
||||||
@@ -129,12 +162,19 @@ pub async fn add_comment(
|
|||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
Json(body): Json<AddCommentRequest>,
|
Json(body): Json<AddCommentRequest>,
|
||||||
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
||||||
|
// Comments can be disabled instance-wide (env COMMENTS_ENABLED). The frontend hides
|
||||||
|
// the UI, but gate the API too so a stale client or direct call can't slip one in.
|
||||||
|
if !state.config.comments_enabled {
|
||||||
|
return Err(AppError::Forbidden("Kommentare sind deaktiviert.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
if user.is_banned {
|
if user.is_banned {
|
||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
check_social_rate(&state, auth.user_id).await?;
|
||||||
|
|
||||||
// Event-scope: only comment on an upload that belongs to the caller's event.
|
// Event-scope: only comment on an upload that belongs to the caller's event.
|
||||||
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
||||||
@@ -210,6 +250,7 @@ pub async fn delete_comment(
|
|||||||
if auth.is_banned {
|
if auth.is_banned {
|
||||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
}
|
}
|
||||||
|
check_social_rate(&state, auth.user_id).await?;
|
||||||
let comment = Comment::find_by_id(&state.pool, comment_id)
|
let comment = Comment::find_by_id(&state.pool, comment_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ use crate::auth::middleware::RequireAdmin;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Truncates every event-scoped table, wipes media on disk, and reseeds the
|
/// Truncates every event-scoped table, wipes media on disk, and reseeds the `config`
|
||||||
/// `config` table from migration defaults. Requires an admin JWT — even with
|
/// table: numeric values from the migration defaults, but every feature toggle forced
|
||||||
/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
/// OFF (production seeds them ON — see the note at the reseed below). Requires an admin
|
||||||
|
/// JWT — even with `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
||||||
pub async fn truncate_all(
|
pub async fn truncate_all(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
@@ -40,15 +41,29 @@ pub async fn truncate_all(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Reseed config — mirrors migrations 005 and 009. Kept in sync by hand
|
// Reseed config. The NUMERIC values mirror migrations 005/015/016/017/019; the BOOLEAN
|
||||||
// because pulling SQL out of the migration files at runtime is fragile.
|
// toggles deliberately do NOT — migration 009 seeds every one of them `true`
|
||||||
|
// (production), and this forces them `false` so the suite isn't fighting rate limits
|
||||||
|
// and quotas it isn't testing.
|
||||||
|
//
|
||||||
|
// Be aware of what that costs: this runs as an auto-fixture before EVERY test, so no
|
||||||
|
// test starts from production's config unless it explicitly turns a toggle back on
|
||||||
|
// (02-upload/rate-limit, 07-adversarial/ddos, 01-auth/rate-limit-nat, …). That blind
|
||||||
|
// spot is exactly why an entire class of per-IP limiter bugs went unnoticed: the
|
||||||
|
// limiters were simply off. When adding a limiter or quota, add a spec that enables it.
|
||||||
|
//
|
||||||
|
// Kept in sync by hand because pulling SQL out of the migration files at runtime is
|
||||||
|
// fragile — if you add a config key in a migration, add it here too.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO config (key, value) VALUES
|
r#"INSERT INTO config (key, value) VALUES
|
||||||
('max_image_size_mb', '20'),
|
('max_image_size_mb', '20'),
|
||||||
('max_video_size_mb', '500'),
|
('max_video_size_mb', '500'),
|
||||||
('upload_rate_per_hour', '10'),
|
('upload_rate_per_hour', '100'),
|
||||||
('feed_rate_per_min', '60'),
|
('feed_rate_per_min', '60'),
|
||||||
('export_rate_per_day', '3'),
|
('export_rate_per_day', '3'),
|
||||||
|
('join_ip_rate_per_min', '60'),
|
||||||
|
('recover_ip_rate_per_min', '30'),
|
||||||
|
('social_rate_per_min', '120'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
('estimated_guest_count', '100'),
|
('estimated_guest_count', '100'),
|
||||||
('compression_concurrency', '2'),
|
('compression_concurrency', '2'),
|
||||||
@@ -57,6 +72,8 @@ pub async fn truncate_all(
|
|||||||
('feed_rate_enabled', 'false'),
|
('feed_rate_enabled', 'false'),
|
||||||
('export_rate_enabled', 'false'),
|
('export_rate_enabled', 'false'),
|
||||||
('join_rate_enabled', 'false'),
|
('join_rate_enabled', 'false'),
|
||||||
|
('social_rate_enabled', 'false'),
|
||||||
|
('admin_login_rate_enabled', 'false'),
|
||||||
('quota_enabled', 'false'),
|
('quota_enabled', 'false'),
|
||||||
('storage_quota_enabled', 'false'),
|
('storage_quota_enabled', 'false'),
|
||||||
('upload_count_quota_enabled', 'false'),
|
('upload_count_quota_enabled', 'false'),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ pub async fn upload(
|
|||||||
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
||||||
if rate_limits_on && upload_rate_on {
|
if rate_limits_on && upload_rate_on {
|
||||||
let upload_rate =
|
let upload_rate =
|
||||||
config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
|
config::get_i64(&state.config_cache, "upload_rate_per_hour", 100).await as usize;
|
||||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("upload:{}", auth.user_id),
|
format!("upload:{}", auth.user_id),
|
||||||
upload_rate,
|
upload_rate,
|
||||||
@@ -233,6 +233,26 @@ pub async fn upload(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Images only: refuse anything the compression worker could never decode, reading just
|
||||||
|
// the header. Without this the upload is accepted with a 201 and then silently
|
||||||
|
// soft-deleted minutes later when the worker gives up — the guest sees the photo
|
||||||
|
// vanish with, at best, a vague "could not be processed". Rejecting here gives them a
|
||||||
|
// reason at the door that they can act on, and it uses the SAME budget the worker
|
||||||
|
// enforces, so admission and processing cannot disagree.
|
||||||
|
if mime.starts_with("image/") && crate::services::imaging::exceeds_decode_budget(&temp_abs) {
|
||||||
|
let mp = crate::services::imaging::megapixels(&temp_abs);
|
||||||
|
tracing::info!(
|
||||||
|
%mime, megapixels = ?mp,
|
||||||
|
"rejecting an image that exceeds the decode budget at admission"
|
||||||
|
);
|
||||||
|
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||||
|
let detail = mp.map_or(String::new(), |mp| format!(" (ca. {mp:.0} Megapixel)"));
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Bild hat zu viele Bildpunkte{detail} und kann nicht verarbeitet werden. \
|
||||||
|
Bitte verkleinere es und lade es erneut hoch."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
// Per-user storage quota — dynamic formula based on available disk space and the
|
// 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
|
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||||
// disable it on trusted instances.
|
// disable it on trusted instances.
|
||||||
@@ -647,12 +667,95 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of parsing a `Range` request header against a known file length.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum RangeSpec {
|
||||||
|
/// No `Range` header, or one we deliberately don't honour (multi-range, non-`bytes`
|
||||||
|
/// unit, malformed). RFC 9110 lets a server ignore a Range it can't process and reply
|
||||||
|
/// 200 with the full body, which is what every one of these cases does.
|
||||||
|
Full,
|
||||||
|
/// A single satisfiable range, resolved to inclusive absolute offsets.
|
||||||
|
Partial { start: u64, end: u64 },
|
||||||
|
/// Syntactically valid but starts beyond EOF — must be answered 416, not 200, or a
|
||||||
|
/// player can loop re-requesting it.
|
||||||
|
Unsatisfiable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single-range `bytes=` header against `len`.
|
||||||
|
///
|
||||||
|
/// Deliberately supports only the three forms a media element actually sends —
|
||||||
|
/// `bytes=N-`, `bytes=N-M`, `bytes=-S` (suffix) — and treats everything else as `Full`.
|
||||||
|
/// Multi-range responses need `multipart/byteranges`, which no `<video>` requires.
|
||||||
|
fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
|
||||||
|
let Some(raw) = header else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
// Multi-range → fall back to the whole body rather than lie about the content.
|
||||||
|
if spec.contains(',') {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
}
|
||||||
|
let Some((from, to)) = spec.split_once('-') else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
let (from, to) = (from.trim(), to.trim());
|
||||||
|
|
||||||
|
// A zero-length file can satisfy no range at all.
|
||||||
|
if len == 0 {
|
||||||
|
return if from.is_empty() && to.is_empty() {
|
||||||
|
RangeSpec::Full
|
||||||
|
} else {
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start, end) = if from.is_empty() {
|
||||||
|
// Suffix form: the last `to` bytes.
|
||||||
|
let Ok(suffix) = to.parse::<u64>() else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
if suffix == 0 {
|
||||||
|
return RangeSpec::Unsatisfiable;
|
||||||
|
}
|
||||||
|
(len.saturating_sub(suffix), len - 1)
|
||||||
|
} else {
|
||||||
|
let Ok(start) = from.parse::<u64>() else {
|
||||||
|
return RangeSpec::Full;
|
||||||
|
};
|
||||||
|
let end = if to.is_empty() {
|
||||||
|
len - 1
|
||||||
|
} else {
|
||||||
|
match to.parse::<u64>() {
|
||||||
|
// An end past EOF is clamped, not rejected (RFC 9110 §14.1.1).
|
||||||
|
Ok(end) => end.min(len - 1),
|
||||||
|
Err(_) => return RangeSpec::Full,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(start, end)
|
||||||
|
};
|
||||||
|
|
||||||
|
if start >= len || start > end {
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
} else {
|
||||||
|
RangeSpec::Partial { start, end }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stream a media file from disk into an HTTP response with a fixed set of security
|
/// Stream a media file from disk into an HTTP response with a fixed set of security
|
||||||
/// headers. Every media response (original, preview, thumbnail) goes through here so
|
/// headers. Every media response (original, preview, display, thumbnail) goes through here
|
||||||
/// they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
|
/// so they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
|
||||||
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit
|
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit
|
||||||
/// `Content-Disposition` and `Cache-Control`.
|
/// `Content-Disposition` and `Cache-Control`.
|
||||||
|
///
|
||||||
|
/// Honours a single `Range`. This is not an optimisation: iOS Safari opens every `<video>`
|
||||||
|
/// with a `Range: bytes=0-1` probe and abandons the load unless it gets a `206` with a
|
||||||
|
/// `Content-Range`. Without this, video is unplayable on the app's primary platform no
|
||||||
|
/// matter what `src` the element is given. `Accept-Ranges: bytes` is advertised on every
|
||||||
|
/// response so clients know seeking is available before they ask.
|
||||||
async fn stream_media_file(
|
async fn stream_media_file(
|
||||||
|
req_headers: &axum::http::HeaderMap,
|
||||||
absolute: &std::path::Path,
|
absolute: &std::path::Path,
|
||||||
content_type: String,
|
content_type: String,
|
||||||
disposition: &str,
|
disposition: &str,
|
||||||
@@ -660,30 +763,60 @@ async fn stream_media_file(
|
|||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Response, StatusCode, header};
|
use axum::http::{Response, StatusCode, header};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
if !absolute.exists() {
|
if !absolute.exists() {
|
||||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = tokio::fs::File::open(absolute)
|
let mut file = tokio::fs::File::open(absolute)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
let metadata = file
|
let len = file
|
||||||
.metadata()
|
.metadata()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
let stream = ReaderStream::new(file);
|
.len();
|
||||||
|
|
||||||
Response::builder()
|
let range = parse_range(
|
||||||
.status(StatusCode::OK)
|
req_headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
len,
|
||||||
.header(header::CONTENT_DISPOSITION, disposition)
|
);
|
||||||
.header(header::CONTENT_LENGTH, metadata.len())
|
|
||||||
.header(header::CACHE_CONTROL, cache_control)
|
let base = |status: StatusCode| {
|
||||||
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
Response::builder()
|
||||||
.body(Body::from_stream(stream))
|
.status(status)
|
||||||
.map_err(|e| AppError::Internal(e.into()))
|
.header(header::CONTENT_TYPE, content_type.clone())
|
||||||
|
.header(header::CONTENT_DISPOSITION, disposition)
|
||||||
|
.header(header::CACHE_CONTROL, cache_control)
|
||||||
|
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
};
|
||||||
|
|
||||||
|
match range {
|
||||||
|
RangeSpec::Full => base(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_LENGTH, len)
|
||||||
|
.body(Body::from_stream(ReaderStream::new(file)))
|
||||||
|
.map_err(|e| AppError::Internal(e.into())),
|
||||||
|
|
||||||
|
RangeSpec::Partial { start, end } => {
|
||||||
|
file.seek(std::io::SeekFrom::Start(start))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
|
let span = end - start + 1;
|
||||||
|
base(StatusCode::PARTIAL_CONTENT)
|
||||||
|
.header(header::CONTENT_LENGTH, span)
|
||||||
|
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
|
||||||
|
.body(Body::from_stream(ReaderStream::new(file.take(span))))
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
RangeSpec::Unsatisfiable => base(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.map_err(|e| AppError::Internal(e.into())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming download of the original file behind an upload. Used by:
|
/// Streaming download of the original file behind an upload. Used by:
|
||||||
@@ -700,6 +833,7 @@ async fn stream_media_file(
|
|||||||
/// [`get_preview`] / [`get_thumbnail`]).
|
/// [`get_preview`] / [`get_thumbnail`]).
|
||||||
pub async fn get_original(
|
pub async fn get_original(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let media = Upload::find_visible_media(&state.pool, upload_id)
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
@@ -711,10 +845,22 @@ pub async fn get_original(
|
|||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or("original");
|
.unwrap_or("original");
|
||||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
// `inline`, not `attachment`. This route is the only source of playable video bytes
|
||||||
|
// (there is no video derivative), and an attachment disposition is hostile to a
|
||||||
|
// `<video>` element — Safari in particular. It also matches what the UI promises:
|
||||||
|
// the action is labelled "Original anzeigen", i.e. view, not download.
|
||||||
|
let disposition = format!("inline; filename=\"{filename}\"");
|
||||||
|
|
||||||
// Full-res original: force download, never cache at the edge.
|
// Full-res original: never cache at the edge, so a takedown revokes access promptly.
|
||||||
stream_media_file(&absolute, media.mime_type, &disposition, "no-store").await
|
// Range requests still work under no-store; the client simply re-fetches each range.
|
||||||
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
|
&absolute,
|
||||||
|
media.mime_type,
|
||||||
|
&disposition,
|
||||||
|
"no-store",
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streaming access to an upload's compressed **preview** image. Gated exactly like
|
/// Streaming access to an upload's compressed **preview** image. Gated exactly like
|
||||||
@@ -729,6 +875,7 @@ pub async fn get_original(
|
|||||||
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
|
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
|
||||||
pub async fn get_preview(
|
pub async fn get_preview(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let media = Upload::find_visible_media(&state.pool, upload_id)
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
@@ -739,6 +886,33 @@ pub async fn get_preview(
|
|||||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
|
||||||
let absolute = state.config.media_path.join(&rel);
|
let absolute = state.config.media_path.join(&rel);
|
||||||
stream_media_file(
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
|
&absolute,
|
||||||
|
"image/jpeg".to_string(),
|
||||||
|
"inline",
|
||||||
|
"private, max-age=300",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming access to an upload's big-screen **display** derivative (~2048px), used by the
|
||||||
|
/// diashow. Gated identically to [`get_preview`]. 404s when the derivative doesn't exist yet
|
||||||
|
/// (still compressing, or an old upload the backfill hasn't reached) — the diashow then falls
|
||||||
|
/// back to the original.
|
||||||
|
pub async fn get_display(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(upload_id): Path<Uuid>,
|
||||||
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
let rel = media
|
||||||
|
.display_path
|
||||||
|
.ok_or_else(|| AppError::NotFound("Anzeige nicht verfügbar.".into()))?;
|
||||||
|
let absolute = state.config.media_path.join(&rel);
|
||||||
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
&absolute,
|
&absolute,
|
||||||
"image/jpeg".to_string(),
|
"image/jpeg".to_string(),
|
||||||
"inline",
|
"inline",
|
||||||
@@ -751,6 +925,7 @@ pub async fn get_preview(
|
|||||||
/// [`get_preview`].
|
/// [`get_preview`].
|
||||||
pub async fn get_thumbnail(
|
pub async fn get_thumbnail(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let media = Upload::find_visible_media(&state.pool, upload_id)
|
let media = Upload::find_visible_media(&state.pool, upload_id)
|
||||||
@@ -761,6 +936,7 @@ pub async fn get_thumbnail(
|
|||||||
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
|
||||||
let absolute = state.config.media_path.join(&rel);
|
let absolute = state.config.media_path.join(&rel);
|
||||||
stream_media_file(
|
stream_media_file(
|
||||||
|
&headers,
|
||||||
&absolute,
|
&absolute,
|
||||||
"image/jpeg".to_string(),
|
"image/jpeg".to_string(),
|
||||||
"inline",
|
"inline",
|
||||||
@@ -771,7 +947,108 @@ pub async fn get_thumbnail(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::quota_limit_bytes;
|
use super::{RangeSpec, parse_range, quota_limit_bytes};
|
||||||
|
|
||||||
|
// `Range` handling exists because iOS Safari probes every `<video>` with
|
||||||
|
// `Range: bytes=0-1` and abandons the load without a 206. These pin the forms a
|
||||||
|
// media element actually sends, plus the edges that decide 206 vs 200 vs 416.
|
||||||
|
#[test]
|
||||||
|
fn no_range_header_is_a_full_response() {
|
||||||
|
assert_eq!(parse_range(None, 100), RangeSpec::Full);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_ended_range_runs_to_eof() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=10-"), 100),
|
||||||
|
RangeSpec::Partial { start: 10, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_range_is_inclusive_on_both_ends() {
|
||||||
|
// The iOS probe. Two bytes, 0 and 1 — an exclusive end would return one.
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=0-1"), 100),
|
||||||
|
RangeSpec::Partial { start: 0, end: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_range_returns_the_last_n_bytes() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=-20"), 100),
|
||||||
|
RangeSpec::Partial { start: 80, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suffix_longer_than_the_file_clamps_to_the_whole_file() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=-500"), 100),
|
||||||
|
RangeSpec::Partial { start: 0, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_past_eof_is_clamped_not_rejected() {
|
||||||
|
// RFC 9110 §14.1.1 — players routinely ask for more than is there.
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=90-999"), 100),
|
||||||
|
RangeSpec::Partial { start: 90, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_past_eof_is_416_not_a_full_body() {
|
||||||
|
// Answering 200 here makes a player re-request forever.
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=100-"), 100),
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=200-300"), 100),
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inverted_range_is_unsatisfiable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=50-10"), 100),
|
||||||
|
RangeSpec::Unsatisfiable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_or_malformed_forms_fall_back_to_the_full_body() {
|
||||||
|
// Ignoring a Range we can't process and sending 200 is explicitly allowed, and
|
||||||
|
// safer than guessing. Multi-range would need multipart/byteranges, which no
|
||||||
|
// <video> asks for.
|
||||||
|
for header in [
|
||||||
|
"bytes=0-10,20-30", // multi-range
|
||||||
|
"items=0-10", // non-bytes unit
|
||||||
|
"bytes=abc-def", // garbage
|
||||||
|
"bytes=", // empty spec
|
||||||
|
"nonsense", // no unit at all
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_range(Some(header), 100), RangeSpec::Full, "{header}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_byte_is_reachable() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_range(Some("bytes=99-99"), 100),
|
||||||
|
RangeSpec::Partial { start: 99, end: 99 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_file_satisfies_no_range() {
|
||||||
|
assert_eq!(parse_range(Some("bytes=0-"), 0), RangeSpec::Unsatisfiable);
|
||||||
|
assert_eq!(parse_range(None, 0), RangeSpec::Full);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn divides_free_space_by_uploaders_with_tolerance() {
|
fn divides_free_space_by_uploaders_with_tolerance() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use anyhow::Result;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::routing::{delete, get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
@@ -45,6 +44,13 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let state = AppState::new(pool.clone(), config.clone());
|
let state = AppState::new(pool.clone(), config.clone());
|
||||||
|
|
||||||
|
// Regenerate image derivatives an older pipeline produced: the big-screen display for
|
||||||
|
// uploads processed before it existed (v0.17.x), and anything predating the current
|
||||||
|
// DERIVATIVES_REV (rev 1 applies the EXIF orientation, without which every portrait
|
||||||
|
// phone photo is stored sideways). Fire-and-forget behind the compression semaphore;
|
||||||
|
// originals are never touched, so a failure just retries on the next start.
|
||||||
|
state.compression.backfill_stale_derivatives().await;
|
||||||
|
|
||||||
// Re-spawn exports for events that were released but whose keepsake never finished
|
// Re-spawn exports for events that were released but whose keepsake never finished
|
||||||
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
|
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
|
||||||
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
|
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
|
||||||
@@ -53,6 +59,7 @@ async fn main() -> Result<()> {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
config.media_path.clone(),
|
config.media_path.clone(),
|
||||||
config.export_path.clone(),
|
config.export_path.clone(),
|
||||||
|
config.comments_enabled,
|
||||||
state.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -63,6 +70,7 @@ async fn main() -> Result<()> {
|
|||||||
pool,
|
pool,
|
||||||
state.rate_limiter.clone(),
|
state.rate_limiter.clone(),
|
||||||
state.sse_tickets.clone(),
|
state.sse_tickets.clone(),
|
||||||
|
config.media_path.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure media directories exist
|
// Ensure media directories exist
|
||||||
@@ -106,6 +114,10 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/upload/{id}/preview",
|
"/api/v1/upload/{id}/preview",
|
||||||
get(handlers::upload::get_preview),
|
get(handlers::upload::get_preview),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/upload/{id}/display",
|
||||||
|
get(handlers::upload::get_display),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}/thumbnail",
|
"/api/v1/upload/{id}/thumbnail",
|
||||||
get(handlers::upload::get_thumbnail),
|
get(handlers::upload::get_thumbnail),
|
||||||
@@ -219,41 +231,39 @@ async fn main() -> Result<()> {
|
|||||||
api
|
api
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serve media files from disk
|
// NOTE: media is deliberately NOT served over HTTP.
|
||||||
let media_service = ServeDir::new(&config.media_path);
|
//
|
||||||
|
// Files live under `media_path` so the compression worker and the export job can read
|
||||||
|
// them off disk, but nothing may pull them straight from `/media/**` — that bypasses
|
||||||
|
// the visibility checks (soft-delete + ban-hide) that make a host takedown stick.
|
||||||
|
// Every legitimate fetch goes through `/api/v1/upload/{id}/{original,preview,display,
|
||||||
|
// thumbnail}`, which filter via `find_visible_media`; those are the only media URLs the
|
||||||
|
// backend ever emits (see `handlers::feed`).
|
||||||
|
//
|
||||||
|
// This used to be a `ServeDir` on `/media` with four `nest_service` blockers on the
|
||||||
|
// subtrees above it. That was bypassable: axum routes on the RAW path while `ServeDir`
|
||||||
|
// percent-decodes afterwards, so `/media/%70reviews/{id}.jpg` missed every blocker,
|
||||||
|
// fell through to the `ServeDir`, and was decoded back to `previews/` on disk — serving
|
||||||
|
// a taken-down photo to anyone, unauthenticated. Any single escaped byte worked, in all
|
||||||
|
// four subtrees. Deleting the route removes the vector outright rather than racing the
|
||||||
|
// decoder; `/media/**` now 404s regardless of encoding.
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
// Block direct HTTP access to ALL media subtrees. The files live under
|
|
||||||
// `media_path` (so the compression worker and export can read them off disk) but
|
|
||||||
// must NOT be pullable straight from `/media/**` — that bypasses the visibility
|
|
||||||
// checks (soft-delete + ban-hide) in the gated handlers. Every legitimate fetch
|
|
||||||
// goes through `/api/v1/upload/{id}/{original,preview,thumbnail}`, which filter
|
|
||||||
// via `find_visible_media`. The more specific nests take precedence over the
|
|
||||||
// `/media` ServeDir below (which, with all three subtrees blocked, now serves
|
|
||||||
// nothing — kept as a backstop).
|
|
||||||
.nest_service(
|
|
||||||
"/media/originals",
|
|
||||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
|
||||||
)
|
|
||||||
.nest_service(
|
|
||||||
"/media/previews",
|
|
||||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
|
||||||
)
|
|
||||||
.nest_service(
|
|
||||||
"/media/thumbnails",
|
|
||||||
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
|
||||||
)
|
|
||||||
.nest_service("/media", media_service)
|
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
||||||
tracing::info!("listening on {}", listener.local_addr()?);
|
tracing::info!("listening on {}", listener.local_addr()?);
|
||||||
axum::serve(listener, router)
|
// `into_make_service_with_connect_info` is required by the pre-auth handlers, which
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
// extract `ConnectInfo<SocketAddr>` to use the peer address as the rate-limit key when
|
||||||
.await?;
|
// X-Forwarded-For is absent. Without it those extractors fail at runtime.
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub struct VisibleMedia {
|
|||||||
pub original_path: String,
|
pub original_path: String,
|
||||||
pub preview_path: Option<String>,
|
pub preview_path: Option<String>,
|
||||||
pub thumbnail_path: Option<String>,
|
pub thumbnail_path: Option<String>,
|
||||||
|
pub display_path: Option<String>,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ impl Upload {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Result<Option<VisibleMedia>, sqlx::Error> {
|
) -> Result<Option<VisibleMedia>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, VisibleMedia>(
|
sqlx::query_as::<_, VisibleMedia>(
|
||||||
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
|
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.display_path, up.mime_type
|
||||||
FROM upload up
|
FROM upload up
|
||||||
JOIN \"user\" u ON u.id = up.user_id
|
JOIN \"user\" u ON u.id = up.user_id
|
||||||
WHERE up.id = $1 AND up.deleted_at IS NULL
|
WHERE up.id = $1 AND up.deleted_at IS NULL
|
||||||
@@ -134,6 +135,30 @@ impl Upload {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_display_path(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
display_path: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE upload SET display_path = $2 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(display_path)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp which revision of the derivative pipeline produced this row's preview/display,
|
||||||
|
/// so the startup backfill can find rows generated by an older one exactly once.
|
||||||
|
pub async fn set_derivatives_rev(pool: &PgPool, id: Uuid, rev: i16) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE upload SET derivatives_rev = $2 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(rev)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_thumbnail_path(
|
pub async fn set_thumbnail_path(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ impl CompressionWorker {
|
|||||||
self.generation.fetch_add(1, Ordering::SeqCst);
|
self.generation.fetch_add(1, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many times `do_process` is attempted before an upload is given up on. The
|
||||||
|
/// give-up path is user-visible (the photo disappears), so transient infrastructure
|
||||||
|
/// errors must not reach it.
|
||||||
|
const MAX_PROCESS_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// Revision of the image-derivative pipeline. Bump this whenever a change makes existing
|
||||||
|
/// previews/displays wrong, so `backfill_stale_derivatives` regenerates them once on the
|
||||||
|
/// next start. Rev 1 = EXIF orientation is applied.
|
||||||
|
const DERIVATIVES_REV: i16 = 1;
|
||||||
|
|
||||||
/// Spawn a background task to process an uploaded file.
|
/// Spawn a background task to process an uploaded file.
|
||||||
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
@@ -60,10 +70,42 @@ impl CompressionWorker {
|
|||||||
if worker.generation.load(Ordering::SeqCst) != born_at {
|
if worker.generation.load(Ordering::SeqCst) != born_at {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match worker
|
// Retry before giving up. Most failures here are transient and self-clearing —
|
||||||
.do_process(upload_id, &original_path, &mime_type)
|
// an ENOSPC spike while several guests upload at once, a momentary DB-pool
|
||||||
.await
|
// exhaustion, a panic inside the image codec — and the give-up path is
|
||||||
{
|
// user-visible data loss, so it is worth a few seconds to avoid entering it.
|
||||||
|
//
|
||||||
|
// But only for failures that CAN clear. An image that exceeds the decode budget,
|
||||||
|
// is corrupt, or is in an unsupported format fails identically on every attempt,
|
||||||
|
// so retrying it just burns 2s + 4s of backoff and writes three near-identical
|
||||||
|
// warnings before reaching the same conclusion. Give up on those immediately.
|
||||||
|
let mut attempt = 1u32;
|
||||||
|
let outcome = loop {
|
||||||
|
match worker
|
||||||
|
.do_process(upload_id, &original_path, &mime_type)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => break Ok(v),
|
||||||
|
Err(e)
|
||||||
|
if attempt < Self::MAX_PROCESS_ATTEMPTS
|
||||||
|
&& !crate::services::imaging::is_permanent_image_error(&e) =>
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
error = ?e, %upload_id, attempt,
|
||||||
|
"compression attempt failed; retrying"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2u64.pow(attempt))).await;
|
||||||
|
attempt += 1;
|
||||||
|
// The data may have been reset while we slept (e2e TRUNCATE).
|
||||||
|
if worker.generation.load(Ordering::SeqCst) != born_at {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => break Err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match outcome {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!("compression completed for upload {upload_id}");
|
tracing::info!("compression completed for upload {upload_id}");
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
@@ -72,21 +114,31 @@ impl CompressionWorker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
tracing::error!(
|
||||||
// Auto-cleanup: a failed transcode would otherwise leave a
|
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
||||||
// permanently broken feed card, silently charge the uploader's
|
);
|
||||||
// quota, and orphan the original on disk. Refund + soft-delete
|
// Refund + soft-delete (one tx, so v_feed excludes it) so a failed
|
||||||
// (one tx, so v_feed excludes it), remove the orphan file, then
|
// transcode doesn't leave a permanently broken feed card or silently
|
||||||
// tell the uploader (upload-error toast) and evict the card
|
// charge the uploader's quota. Then tell the uploader (upload-error
|
||||||
// everywhere (upload-deleted, already handled by the feed).
|
// toast) and evict the card everywhere (upload-deleted).
|
||||||
|
//
|
||||||
|
// The ORIGINAL IS DELIBERATELY KEPT. This path used to `remove_file` it
|
||||||
|
// unconditionally, which meant any transient error — a disk-full blip
|
||||||
|
// while saving a derivative, a pool hiccup, a panic in the image codec —
|
||||||
|
// irreversibly destroyed the guest's only copy of a photo they can never
|
||||||
|
// retake. The row is only soft-deleted, so keeping the bytes makes the
|
||||||
|
// upload fully recoverable; the file is orphaned rather than lost, and
|
||||||
|
// the path is logged so it can be found. `backfill_stale_derivatives`
|
||||||
|
// already refuses to destroy data on error for exactly this reason.
|
||||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||||
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
|
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
|
||||||
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
||||||
}
|
}
|
||||||
let orphan = worker.media_path.join(&original_path);
|
tracing::warn!(
|
||||||
if let Err(rm) = tokio::fs::remove_file(&orphan).await {
|
%upload_id,
|
||||||
tracing::warn!(error = ?rm, path = %orphan.display(), "failed to remove orphaned original");
|
path = %worker.media_path.join(&original_path).display(),
|
||||||
}
|
"original retained for recovery after compression failure"
|
||||||
|
);
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-error".to_string(),
|
event_type: "upload-error".to_string(),
|
||||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
||||||
@@ -112,11 +164,13 @@ impl CompressionWorker {
|
|||||||
let original = self.media_path.join(original_path);
|
let original = self.media_path.join(original_path);
|
||||||
|
|
||||||
if mime_type.starts_with("image/") {
|
if mime_type.starts_with("image/") {
|
||||||
let preview_rel = self
|
let (preview_rel, display_rel) = self
|
||||||
.generate_image_preview(upload_id, &original, mime_type)
|
.generate_image_derivatives(upload_id, &original, mime_type)
|
||||||
.await?;
|
.await?;
|
||||||
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
|
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
|
||||||
tracing::info!("preview generated for upload {upload_id}");
|
Upload::set_display_path(&self.pool, upload_id, &display_rel).await?;
|
||||||
|
Upload::set_derivatives_rev(&self.pool, upload_id, Self::DERIVATIVES_REV).await?;
|
||||||
|
tracing::info!("preview + display generated for upload {upload_id}");
|
||||||
} else if mime_type.starts_with("video/") {
|
} else if mime_type.starts_with("video/") {
|
||||||
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?;
|
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?;
|
||||||
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
||||||
@@ -127,44 +181,63 @@ impl CompressionWorker {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_image_preview(
|
/// Longest edge of the big-screen "display" derivative used by the diashow. Sized to be
|
||||||
|
/// sharp on 1080p/4K while staying bounded (a ~2048px JPEG decodes to ~16 MB — trivial
|
||||||
|
/// for any kiosk, unlike a raw multi-thousand-pixel original).
|
||||||
|
const DISPLAY_MAX_EDGE: u32 = 2048;
|
||||||
|
/// Longest edge of the phone-feed "preview" (data-saver default).
|
||||||
|
const PREVIEW_MAX_EDGE: u32 = 800;
|
||||||
|
|
||||||
|
/// Decode the image ONCE and emit both derivatives — the 800px `preview` (phone feed)
|
||||||
|
/// and the 2048px `display` (diashow). Returns `(preview_rel, display_rel)`.
|
||||||
|
async fn generate_image_derivatives(
|
||||||
&self,
|
&self,
|
||||||
upload_id: Uuid,
|
upload_id: Uuid,
|
||||||
original: &Path,
|
original: &Path,
|
||||||
mime_type: &str,
|
mime_type: &str,
|
||||||
) -> Result<String> {
|
) -> Result<(String, String)> {
|
||||||
let previews_dir = self.media_path.join("previews");
|
let previews_dir = self.media_path.join("previews");
|
||||||
|
let displays_dir = self.media_path.join("displays");
|
||||||
tokio::fs::create_dir_all(&previews_dir).await?;
|
tokio::fs::create_dir_all(&previews_dir).await?;
|
||||||
|
tokio::fs::create_dir_all(&displays_dir).await?;
|
||||||
|
|
||||||
let preview_filename = format!("{upload_id}.jpg");
|
let filename = format!("{upload_id}.jpg");
|
||||||
let preview_path = previews_dir.join(&preview_filename);
|
let preview_path = previews_dir.join(&filename);
|
||||||
|
let display_path = displays_dir.join(&filename);
|
||||||
let original = original.to_path_buf();
|
let original = original.to_path_buf();
|
||||||
let preview_path_clone = preview_path.clone();
|
|
||||||
let mime_owned = mime_type.to_string();
|
let mime_owned = mime_type.to_string();
|
||||||
|
let preview_max = Self::PREVIEW_MAX_EDGE;
|
||||||
|
let display_max = Self::DISPLAY_MAX_EDGE;
|
||||||
|
|
||||||
// Run blocking image operations in a spawn_blocking task
|
// Run blocking image operations in a spawn_blocking task
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
// Reject decompression bombs *before* fully decoding: the upload body
|
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||||
// cap bounds the file size on disk, but a small file can still decode to
|
// services::imaging for why neither may be skipped.
|
||||||
// enormous dimensions (e.g. a ~1 MB image expanding to 50k×50k px →
|
let img = crate::services::imaging::decode_oriented(&original)?;
|
||||||
// gigabytes), OOM-ing the box during decode/resize. 12000×12000 covers
|
|
||||||
// any real phone photo; max_alloc hard-caps the decode allocation.
|
|
||||||
let mut reader = image::ImageReader::open(&original)
|
|
||||||
.context("failed to open image")?
|
|
||||||
.with_guessed_format()
|
|
||||||
.context("failed to read image header")?;
|
|
||||||
let mut limits = image::Limits::default();
|
|
||||||
limits.max_image_width = Some(12_000);
|
|
||||||
limits.max_image_height = Some(12_000);
|
|
||||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
|
||||||
reader.limits(limits);
|
|
||||||
let img = reader.decode().context("failed to decode image")?;
|
|
||||||
|
|
||||||
// Resize to max 800px wide, preserving aspect ratio
|
// Preview: max 800px, preserving aspect ratio (data-saver feed).
|
||||||
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
|
img.resize(
|
||||||
preview
|
preview_max,
|
||||||
.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
|
preview_max,
|
||||||
.context("failed to save preview")?;
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
)
|
||||||
|
.save_with_format(&preview_path, image::ImageFormat::Jpeg)
|
||||||
|
.context("failed to save preview")?;
|
||||||
|
|
||||||
|
// Display: max 2048px for the diashow. Only DOWNSCALE — never upscale a smaller
|
||||||
|
// original (that adds bytes with no quality gain); re-encode it as JPEG as-is.
|
||||||
|
let display = if img.width() > display_max || img.height() > display_max {
|
||||||
|
img.resize(
|
||||||
|
display_max,
|
||||||
|
display_max,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
display
|
||||||
|
.save_with_format(&display_path, image::ImageFormat::Jpeg)
|
||||||
|
.context("failed to save display")?;
|
||||||
|
|
||||||
// If the original is PNG, try lossless compression in-place
|
// If the original is PNG, try lossless compression in-place
|
||||||
if mime_owned == "image/png" {
|
if mime_owned == "image/png" {
|
||||||
@@ -183,7 +256,73 @@ impl CompressionWorker {
|
|||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
|
|
||||||
Ok(format!("previews/{preview_filename}"))
|
Ok((
|
||||||
|
format!("previews/{filename}"),
|
||||||
|
format!("displays/{filename}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regenerate image derivatives that an older pipeline produced. Fire-and-forget from
|
||||||
|
/// startup; picks up two cases, both of which leave the ORIGINAL untouched:
|
||||||
|
///
|
||||||
|
/// - uploads processed before the `display` derivative existed (preview but no
|
||||||
|
/// `display_path`), and
|
||||||
|
/// - uploads whose derivatives predate `DERIVATIVES_REV` — currently rev 1, which applies
|
||||||
|
/// the EXIF orientation. Everything generated before it is stored sideways for any
|
||||||
|
/// portrait phone photo.
|
||||||
|
///
|
||||||
|
/// Unlike the failure path in `process`, a backfill error is logged and skipped — it must
|
||||||
|
/// NEVER destroy or soft-delete an upload that already has a working preview.
|
||||||
|
pub async fn backfill_stale_derivatives(&self) {
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
|
||||||
|
"SELECT id, original_path, mime_type FROM upload
|
||||||
|
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||||
|
AND original_path IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||||
|
OR derivatives_rev < $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(Self::DERIVATIVES_REV)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "derivative backfill query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("regenerating derivatives for {} upload(s)", rows.len());
|
||||||
|
for (id, original_path, mime_type) in rows {
|
||||||
|
let worker = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = worker.semaphore.acquire().await;
|
||||||
|
let original = worker.media_path.join(&original_path);
|
||||||
|
match worker
|
||||||
|
.generate_image_derivatives(id, &original, &mime_type)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((preview_rel, display_rel)) => {
|
||||||
|
let _ = Upload::set_preview_path(&worker.pool, id, &preview_rel).await;
|
||||||
|
let _ = Upload::set_display_path(&worker.pool, id, &display_rel).await;
|
||||||
|
let _ =
|
||||||
|
Upload::set_derivatives_rev(&worker.pool, id, Self::DERIVATIVES_REV)
|
||||||
|
.await;
|
||||||
|
tracing::info!("derivatives regenerated for upload {id}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Leave the existing derivatives and the original intact; this row is
|
||||||
|
// simply retried on the next start. The rev stays behind, which is the
|
||||||
|
// marker that it still needs doing.
|
||||||
|
tracing::warn!(error = ?e, %id, "derivative backfill failed; leaving as-is");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
|
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ impl Default for DiskCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UNCACHED free-space reading for the filesystem backing `path`.
|
||||||
|
///
|
||||||
|
/// Deliberately bypasses [`DiskCache`]. The cache exists for the quota poll, where a 15s-stale
|
||||||
|
/// number is fine because it is only ever advisory. The export preflight is the opposite case: it
|
||||||
|
/// decides whether to start writing a multi-GB archive, and the sibling export worker running
|
||||||
|
/// concurrently can move free space by tens of gigabytes well inside the TTL. A stale reading there
|
||||||
|
/// would authorise exactly the write that fills the disk.
|
||||||
|
pub fn free_bytes(path: &Path) -> Option<u64> {
|
||||||
|
read_disk_for_path(path).map(|d| d.free)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the filesystem backing `media_path` and read its total/free bytes.
|
/// Resolve the filesystem backing `media_path` and read its total/free bytes.
|
||||||
///
|
///
|
||||||
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
|
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
|
||||||
|
|||||||
@@ -20,6 +20,29 @@ use crate::state::SseEvent;
|
|||||||
|
|
||||||
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
|
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
|
||||||
|
|
||||||
|
// ── Shared visibility filter ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The predicate that decides what lands in a keepsake, as ONE definition.
|
||||||
|
///
|
||||||
|
/// Two queries have to agree on it: [`query_uploads`], which selects the rows the archives are
|
||||||
|
/// built from, and [`estimate_export_bytes`], which sizes them for the disk preflight. They used
|
||||||
|
/// to state it separately, and the direction of drift matters — an estimate that misses rows the
|
||||||
|
/// archive writes UNDER-reserves, which is the exact ENOSPC the preflight exists to prevent.
|
||||||
|
///
|
||||||
|
/// A `SRC:`-marked copy in the integration tests cannot catch that: drift means production moved
|
||||||
|
/// and the copy didn't, so both sides of such a test sit still and it keeps passing. Sharing the
|
||||||
|
/// fragment removes the failure by construction instead, and leaves the test doing what it is
|
||||||
|
/// actually good at — pinning the behaviour.
|
||||||
|
///
|
||||||
|
/// CONTRACT: callers must alias `upload` as `u` and join `"user"` as `usr`, and bind the event id
|
||||||
|
/// as `$1`.
|
||||||
|
macro_rules! export_visibility_where {
|
||||||
|
() => {
|
||||||
|
"WHERE u.event_id = $1 AND u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── DB query rows ────────────────────────────────────────────────────────────
|
// ── DB query rows ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -53,6 +76,9 @@ struct ViewerData {
|
|||||||
struct ViewerEvent {
|
struct ViewerEvent {
|
||||||
name: String,
|
name: String,
|
||||||
exported_at: String,
|
exported_at: String,
|
||||||
|
// Mirrors the live COMMENTS_ENABLED flag so the offline keepsake hides all comment
|
||||||
|
// UI (buttons, counts, sections) when the feature was off for the event.
|
||||||
|
comments_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -238,6 +264,7 @@ pub async fn recover_exports(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
export_path: PathBuf,
|
export_path: PathBuf,
|
||||||
|
comments_enabled: bool,
|
||||||
sse_tx: broadcast::Sender<SseEvent>,
|
sse_tx: broadcast::Sender<SseEvent>,
|
||||||
) {
|
) {
|
||||||
let rows = match sqlx::query_as::<_, (Uuid, String, i64)>(
|
let rows = match sqlx::query_as::<_, (Uuid, String, i64)>(
|
||||||
@@ -297,6 +324,7 @@ pub async fn recover_exports(
|
|||||||
event_id,
|
event_id,
|
||||||
event_name,
|
event_name,
|
||||||
epoch,
|
epoch,
|
||||||
|
comments_enabled,
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
media_path.clone(),
|
media_path.clone(),
|
||||||
@@ -403,6 +431,7 @@ pub fn spawn_export_jobs(
|
|||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
event_name: String,
|
event_name: String,
|
||||||
epoch: i64,
|
epoch: i64,
|
||||||
|
comments_enabled: bool,
|
||||||
delay: Duration,
|
delay: Duration,
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
@@ -439,6 +468,7 @@ pub fn spawn_export_jobs(
|
|||||||
event_id,
|
event_id,
|
||||||
epoch,
|
epoch,
|
||||||
&event_name2,
|
&event_name2,
|
||||||
|
comments_enabled,
|
||||||
&pool2,
|
&pool2,
|
||||||
&media_path2,
|
&media_path2,
|
||||||
&export_path2,
|
&export_path2,
|
||||||
@@ -469,6 +499,15 @@ async fn run_zip_export(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reclaim BEFORE measuring: the superseded archive is already unreachable, and the space it
|
||||||
|
// holds is very often exactly the space this rebuild needs.
|
||||||
|
prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await;
|
||||||
|
// AFTER the claim, not before. A preflight that bailed before claiming would leave the row
|
||||||
|
// `pending` with no worker and no error — the spinner-forever state `mark_failed`'s status
|
||||||
|
// guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the
|
||||||
|
// host gets the reason.
|
||||||
|
ensure_export_space(pool, event_id, export_path).await?;
|
||||||
|
|
||||||
// On error, mark THIS generation failed — a no-op if we've since been superseded (the
|
// On error, mark THIS generation failed — a no-op if we've since been superseded (the
|
||||||
// caller in `spawn_export_jobs` does it, epoch-guarded). Temp artifacts are cleaned up
|
// caller in `spawn_export_jobs` does it, epoch-guarded). Temp artifacts are cleaned up
|
||||||
// here so a failing export can't leak them (which is what fills the disk in the first place).
|
// here so a failing export can't leak them (which is what fills the disk in the first place).
|
||||||
@@ -541,7 +580,7 @@ async fn run_zip_export_inner(
|
|||||||
};
|
};
|
||||||
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
|
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
|
||||||
|
|
||||||
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
|
let builder = keepsake_entry(entry_name, Compression::Stored);
|
||||||
|
|
||||||
// Open BEFORE writing the entry header. A missing source is skipped (the media file
|
// Open BEFORE writing the entry header. A missing source is skipped (the media file
|
||||||
// was deleted, or its processing failed) — but it must be skipped without aborting the
|
// was deleted, or its processing failed) — but it must be skipped without aborting the
|
||||||
@@ -635,10 +674,12 @@ impl MediaSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn run_html_export(
|
async fn run_html_export(
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
epoch: i64,
|
epoch: i64,
|
||||||
event_name: &str,
|
event_name: &str,
|
||||||
|
comments_enabled: bool,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
media_path: &Path,
|
media_path: &Path,
|
||||||
export_path: &Path,
|
export_path: &Path,
|
||||||
@@ -649,10 +690,16 @@ async fn run_html_export(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See run_zip_export: reclaim the superseded generation first, then refuse at the door rather
|
||||||
|
// than ENOSPC mid-write.
|
||||||
|
prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await;
|
||||||
|
ensure_export_space(pool, event_id, export_path).await?;
|
||||||
|
|
||||||
let res = run_html_export_inner(
|
let res = run_html_export_inner(
|
||||||
epoch,
|
epoch,
|
||||||
event_id,
|
event_id,
|
||||||
event_name,
|
event_name,
|
||||||
|
comments_enabled,
|
||||||
pool,
|
pool,
|
||||||
media_path,
|
media_path,
|
||||||
export_path,
|
export_path,
|
||||||
@@ -672,10 +719,12 @@ async fn run_html_export(
|
|||||||
abandon_if_superseded("HTML", event_id, epoch, res)
|
abandon_if_superseded("HTML", event_id, epoch, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn run_html_export_inner(
|
async fn run_html_export_inner(
|
||||||
epoch: i64,
|
epoch: i64,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
event_name: &str,
|
event_name: &str,
|
||||||
|
comments_enabled: bool,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
media_path: &Path,
|
media_path: &Path,
|
||||||
export_path: &Path,
|
export_path: &Path,
|
||||||
@@ -708,10 +757,11 @@ async fn run_html_export_inner(
|
|||||||
let src = media_path.join(&row.original_path);
|
let src = media_path.join(&row.original_path);
|
||||||
// Stat ONCE, up front, and skip this upload if the source is gone. The old code probed with
|
// Stat ONCE, up front, and skip this upload if the source is gone. The old code probed with
|
||||||
// `exists()` here and then did `metadata(&src).await?` further down — a TOCTOU whose `?`
|
// `exists()` here and then did `metadata(&src).await?` further down — a TOCTOU whose `?`
|
||||||
// aborted the ENTIRE keepsake if the file vanished in between. It genuinely can: the
|
// aborted the ENTIRE keepsake if the file vanished in between. It can still happen: the
|
||||||
// compression worker hard-deletes an original when its transcode fails, and it can still be
|
// compression worker no longer deletes originals on failure, but the hourly sweep reclaims
|
||||||
// running when the gallery is released. A missing source must degrade one entry, never the
|
// them once past the retention window, and an owner or host delete can land mid-export. A
|
||||||
// whole archive (which, once released, the host cannot rebuild without reopening uploads).
|
// missing source must degrade one entry, never the whole archive (which, once released, the
|
||||||
|
// host cannot rebuild without reopening uploads).
|
||||||
let src_meta = match tokio::fs::metadata(&src).await {
|
let src_meta = match tokio::fs::metadata(&src).await {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -778,7 +828,12 @@ async fn run_html_export_inner(
|
|||||||
let thumb_path_clone = thumb_path.clone();
|
let thumb_path_clone = thumb_path.clone();
|
||||||
|
|
||||||
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
|
// `decode_oriented`, not `image::open`: the latter ignores the EXIF
|
||||||
|
// orientation tag AND applies no decode limits. Using it here is why every
|
||||||
|
// portrait photo came out sideways in the keepsake's HTML viewer grid — the
|
||||||
|
// re-encode below drops the tag, so the viewer cannot recover it.
|
||||||
|
let img = crate::services::imaging::decode_oriented(&src_clone)
|
||||||
|
.context("failed to open image for thumbnail")?;
|
||||||
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
|
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
|
||||||
resized
|
resized
|
||||||
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
|
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
|
||||||
@@ -799,8 +854,12 @@ async fn run_html_export_inner(
|
|||||||
let full_path_clone = full_path.clone();
|
let full_path_clone = full_path.clone();
|
||||||
|
|
||||||
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let img =
|
// Same reason as the thumbnail above. This branch only runs for originals
|
||||||
image::open(&src_clone).context("failed to open image for compression")?;
|
// over 5 MB, which is why the viewer's full image looked correct for small
|
||||||
|
// photos and sideways for large ones — an inconsistency that reads as a
|
||||||
|
// viewer bug rather than an export one.
|
||||||
|
let img = crate::services::imaging::decode_oriented(&src_clone)
|
||||||
|
.context("failed to open image for compression")?;
|
||||||
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
|
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
|
||||||
resized
|
resized
|
||||||
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
|
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
|
||||||
@@ -882,12 +941,17 @@ async fn run_html_export_inner(
|
|||||||
event: ViewerEvent {
|
event: ViewerEvent {
|
||||||
name: event_name.to_string(),
|
name: event_name.to_string(),
|
||||||
exported_at: Utc::now().to_rfc3339(),
|
exported_at: Utc::now().to_rfc3339(),
|
||||||
|
comments_enabled,
|
||||||
},
|
},
|
||||||
posts: viewer_posts,
|
posts: viewer_posts,
|
||||||
};
|
};
|
||||||
let data_json =
|
let data_json =
|
||||||
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
||||||
|
|
||||||
|
// Match the live app's colour theme in the offline keepsake.
|
||||||
|
let (theme_primary, theme_accent) = resolve_theme_seeds(pool).await;
|
||||||
|
let theme_css = theme_override_css(&theme_primary, &theme_accent);
|
||||||
|
|
||||||
let _ = update_progress(pool, event_id, "html", epoch, 72).await;
|
let _ = update_progress(pool, event_id, "html", epoch, 72).await;
|
||||||
|
|
||||||
// 5. Create ZIP (per-generation paths — see run_zip_export)
|
// 5. Create ZIP (per-generation paths — see run_zip_export)
|
||||||
@@ -899,14 +963,17 @@ async fn run_html_export_inner(
|
|||||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||||
let mut zip = ZipFileWriter::with_tokio(file);
|
let mut zip = ZipFileWriter::with_tokio(file);
|
||||||
|
|
||||||
// Write embedded viewer assets (index.html, _app/*, etc.)
|
// Write the embedded single-file viewer, injecting the export data as a
|
||||||
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
|
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
|
||||||
|
// index.html (file://), where a cross-origin fetch() of a sibling file is
|
||||||
|
// blocked — so the data must be inlined rather than fetched from data.json.
|
||||||
|
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?;
|
||||||
|
|
||||||
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
|
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
|
||||||
|
|
||||||
// Write data.json
|
// Write data.json
|
||||||
{
|
{
|
||||||
let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
|
let builder = keepsake_entry("data.json".into(), Compression::Deflate);
|
||||||
let mut entry = zip.write_entry_stream(builder).await?;
|
let mut entry = zip.write_entry_stream(builder).await?;
|
||||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
|
let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
|
||||||
fcopy(&mut cursor, &mut entry).await?;
|
fcopy(&mut cursor, &mut entry).await?;
|
||||||
@@ -915,7 +982,7 @@ async fn run_html_export_inner(
|
|||||||
|
|
||||||
// Write README.txt
|
// Write README.txt
|
||||||
{
|
{
|
||||||
let builder = ZipEntryBuilder::new("README.txt".into(), Compression::Deflate);
|
let builder = keepsake_entry("README.txt".into(), Compression::Deflate);
|
||||||
let mut entry = zip.write_entry_stream(builder).await?;
|
let mut entry = zip.write_entry_stream(builder).await?;
|
||||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
|
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
|
||||||
fcopy(&mut cursor, &mut entry).await?;
|
fcopy(&mut cursor, &mut entry).await?;
|
||||||
@@ -933,9 +1000,9 @@ async fn run_html_export_inner(
|
|||||||
|
|
||||||
for (name, source) in &media_manifest {
|
for (name, source) in &media_manifest {
|
||||||
let path = source.path();
|
let path = source.path();
|
||||||
// Open-first: a source that disappeared between the manifest being built and now (the
|
// Open-first: a source that disappeared between the manifest being built and now (a
|
||||||
// compression worker deletes originals on transcode failure) must skip this entry, not
|
// delete, or the hourly sweep reclaiming a long-failed original) must skip this entry,
|
||||||
// fail the whole viewer. Opening collapses the check and the use into one operation.
|
// not fail the whole viewer. Opening collapses the check and the use into one operation.
|
||||||
let src_file = match tokio::fs::File::open(path).await {
|
let src_file = match tokio::fs::File::open(path).await {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -948,7 +1015,7 @@ async fn run_html_export_inner(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let entry_name = format!("media/{name}");
|
let entry_name = format!("media/{name}");
|
||||||
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
|
let builder = keepsake_entry(entry_name, Compression::Stored);
|
||||||
let mut zip_entry = zip.write_entry_stream(builder).await?;
|
let mut zip_entry = zip.write_entry_stream(builder).await?;
|
||||||
let mut f = src_file.compat();
|
let mut f = src_file.compat();
|
||||||
fcopy(&mut f, &mut zip_entry).await?;
|
fcopy(&mut f, &mut zip_entry).await?;
|
||||||
@@ -1007,7 +1074,7 @@ async fn run_html_export_inner(
|
|||||||
// ── DB helpers ───────────────────────────────────────────────────────────────
|
// ── DB helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
|
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
|
||||||
Ok(sqlx::query_as::<_, ExportUploadRow>(
|
Ok(sqlx::query_as::<_, ExportUploadRow>(concat!(
|
||||||
"SELECT u.id, u.original_path, u.mime_type, u.caption,
|
"SELECT u.id, u.original_path, u.mime_type, u.caption,
|
||||||
usr.display_name AS uploader_name,
|
usr.display_name AS uploader_name,
|
||||||
COUNT(DISTINCT l.user_id) AS like_count,
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
@@ -1015,11 +1082,12 @@ async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUpload
|
|||||||
FROM upload u
|
FROM upload u
|
||||||
JOIN \"user\" usr ON usr.id = u.user_id
|
JOIN \"user\" usr ON usr.id = u.user_id
|
||||||
LEFT JOIN \"like\" l ON l.upload_id = u.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 AND usr.is_banned = FALSE
|
export_visibility_where!(),
|
||||||
|
"
|
||||||
GROUP BY u.id, usr.display_name
|
GROUP BY u.id, usr.display_name
|
||||||
ORDER BY u.created_at ASC",
|
ORDER BY u.created_at ASC",
|
||||||
)
|
))
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?)
|
.await?)
|
||||||
@@ -1131,6 +1199,197 @@ fn parse_gen_seq(name: &str, prefix: &str, suffix: &str) -> Option<i64> {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filenames a live (current-epoch, `done`) job row still points at — OFF LIMITS to every prune,
|
||||||
|
/// regardless of the epoch encoded in the name.
|
||||||
|
///
|
||||||
|
/// A ViewerOnly regeneration carries the finished ZIP forward by re-stamping its row to the new
|
||||||
|
/// epoch WITHOUT renaming the file, so `Gallery.<event>.<older>.zip` is still the served archive and
|
||||||
|
/// deleting it by filename-epoch would 404 the download.
|
||||||
|
async fn protected_files(pool: &PgPool, event_id: Uuid) -> Vec<String> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT split_part(j.file_path, '/', -1) FROM export_job j
|
||||||
|
JOIN event e ON e.id = j.event_id
|
||||||
|
WHERE j.event_id = $1 AND j.epoch = e.export_epoch
|
||||||
|
AND j.status = 'done' AND j.file_path IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reclaim superseded FINAL archives BEFORE this generation starts writing its own.
|
||||||
|
///
|
||||||
|
/// Peak disk usage used to be two full generations, because the only prune ran after the new archive
|
||||||
|
/// was written, renamed and finalised. That ordering reads as durability ("don't delete the good
|
||||||
|
/// keepsake before the replacement is safe") but it buys nothing: readiness is derived from
|
||||||
|
/// `job.epoch = event.export_epoch AND status = 'done'`, so the moment `invalidate_and_arm` bumps
|
||||||
|
/// the epoch the old archive is ALREADY unreachable — `GET /export/zip` 404s whether the file is on
|
||||||
|
/// disk or not. Keeping it only reserves gigabytes for a download nobody can perform, and for
|
||||||
|
/// `Affects::Both` (a takedown) it is content someone has explicitly asked to have removed. So a
|
||||||
|
/// rebuild reclaims first and peaks at one generation.
|
||||||
|
///
|
||||||
|
/// Narrower than [`prune_stale_export_files`] on purpose: FINAL archives only. Those are inert — a
|
||||||
|
/// superseded worker either already renamed its file (and will delete it itself when its guarded
|
||||||
|
/// `finalize_job` fails) or never will. `.tmp` files and `viewer_tmp_` staging dirs are NOT touched
|
||||||
|
/// here: a superseded worker can still be streaming into those, and at build START it is far more
|
||||||
|
/// likely to be alive than at finalize time.
|
||||||
|
async fn prune_superseded_archives(
|
||||||
|
pool: &PgPool,
|
||||||
|
exports_dir: &Path,
|
||||||
|
prefix: &str,
|
||||||
|
event_id: Uuid,
|
||||||
|
keep_seq: i64,
|
||||||
|
) {
|
||||||
|
let protected = protected_files(pool, event_id).await;
|
||||||
|
let final_prefix = format!("{prefix}.{event_id}.");
|
||||||
|
let mut rd = match tokio::fs::read_dir(exports_dir).await {
|
||||||
|
Ok(rd) => rd,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut reclaimed = 0u64;
|
||||||
|
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if !is_superseded_archive(&name, &final_prefix, keep_seq, &protected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let len = entry.metadata().await.map(|m| m.len()).unwrap_or(0);
|
||||||
|
if tokio::fs::remove_file(entry.path()).await.is_ok() {
|
||||||
|
reclaimed += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reclaimed > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"reclaimed {reclaimed} bytes of superseded {prefix} archives before rebuilding \
|
||||||
|
event {event_id} @ epoch {keep_seq}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `name` a FINAL archive of a strictly-older generation that no live row points at?
|
||||||
|
///
|
||||||
|
/// Pure so the two dangerous cases can be pinned without a filesystem: the carried-forward archive
|
||||||
|
/// (protected despite an older epoch in its name) and the in-flight `.tmp` (never matched at all).
|
||||||
|
fn is_superseded_archive(
|
||||||
|
name: &str,
|
||||||
|
final_prefix: &str,
|
||||||
|
keep_seq: i64,
|
||||||
|
protected: &[String],
|
||||||
|
) -> bool {
|
||||||
|
if protected.iter().any(|p| p == name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
parse_gen_seq(name, final_prefix, ".zip").is_some_and(|n| n < keep_seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes the media in this event's keepsake will occupy, as an UPPER BOUND per archive.
|
||||||
|
///
|
||||||
|
/// Both archives write their media entries `Compression::Stored`, so an archive is essentially a
|
||||||
|
/// byte-for-byte second copy of the originals: `Gallery.zip` always, and `Memories.zip` for every
|
||||||
|
/// video ([`MediaSource::Original`]) and every image at or under 5 MB. Images over 5 MB are
|
||||||
|
/// downscaled to 2000px first, which only makes this estimate more conservative — the direction we
|
||||||
|
/// want, since being wrong low means ENOSPC halfway through.
|
||||||
|
///
|
||||||
|
/// Shares [`query_uploads`]' visibility filter via [`export_visibility_where`], so hidden/banned
|
||||||
|
/// uploads can't be counted here but skipped there (or the reverse, which under-reserves).
|
||||||
|
pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result<u64> {
|
||||||
|
let (bytes,): (i64,) = sqlx::query_as(concat!(
|
||||||
|
"SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint
|
||||||
|
FROM upload u
|
||||||
|
JOIN \"user\" usr ON usr.id = u.user_id
|
||||||
|
",
|
||||||
|
export_visibility_where!(),
|
||||||
|
))
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("estimating the export size")?;
|
||||||
|
Ok(bytes.max(0) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headroom multiplier over the raw media sum: ZIP central directory, per-entry headers, the
|
||||||
|
/// embedded viewer, and the HTML export's temp staging.
|
||||||
|
const EXPORT_SIZE_OVERHEAD_PCT: u64 = 110;
|
||||||
|
|
||||||
|
/// Bytes this export must have available, given the raw media sum and how many jobs are competing.
|
||||||
|
///
|
||||||
|
/// `armed` is the multiplier that keeps two concurrent workers honest. `spawn_export_jobs` starts
|
||||||
|
/// the ZIP and the HTML halves at the same instant and both are gallery-sized, so a worker that
|
||||||
|
/// reserved only for itself would see "it fits", its sibling would independently see the same, and
|
||||||
|
/// together they would not fit — which is precisely the ENOSPC this preflight exists to prevent.
|
||||||
|
/// Computed in `u128` and clamped, NOT with `saturating_mul`: saturating first and then dividing by
|
||||||
|
/// 100 quietly turns an overflow into a number ~100x too small, which is the one direction that
|
||||||
|
/// matters here — an under-estimate authorises the very write the preflight exists to refuse.
|
||||||
|
fn required_free_bytes(media_bytes: u64, armed: i64) -> u64 {
|
||||||
|
let needed = media_bytes as u128 * EXPORT_SIZE_OVERHEAD_PCT as u128 / 100
|
||||||
|
* armed.max(1).min(i64::from(u32::MAX)) as u128;
|
||||||
|
needed.min(u64::MAX as u128) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free bytes a full keepsake build would need RIGHT NOW, both halves included.
|
||||||
|
///
|
||||||
|
/// The same arithmetic the preflight uses, exposed so the host dashboard can warn BEFORE the
|
||||||
|
/// release rather than reporting a failure after it. The preflight can only ever say "this didn't
|
||||||
|
/// fit"; at that point the gallery is full, the event is over, and the remedies (ask guests to stop
|
||||||
|
/// uploading, grow the volume) are all much harder. Hard-codes both halves because that is what a
|
||||||
|
/// release arms.
|
||||||
|
pub async fn keepsake_space_required(pool: &PgPool, event_id: Uuid) -> Result<u64> {
|
||||||
|
Ok(required_free_bytes(
|
||||||
|
estimate_export_bytes(pool, event_id).await?,
|
||||||
|
2,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to start an export that cannot fit, with a reason the host can act on.
|
||||||
|
///
|
||||||
|
/// Without this the failure mode is ENOSPC halfway through a multi-GB write, and the wreckage
|
||||||
|
/// outlives it: the epoch has already moved, so the job row is `failed` at the CURRENT generation
|
||||||
|
/// and `GET /export/zip` 404s, while the last good archive sits on disk unreferenced. The host's
|
||||||
|
/// only escape (`POST /host/export/rebuild`) needs the very space that isn't there. Failing at the
|
||||||
|
/// door instead leaves the disk untouched and puts a number in front of the operator.
|
||||||
|
///
|
||||||
|
/// Both halves are spawned concurrently and both are gallery-sized, so a worker must reserve for
|
||||||
|
/// its live sibling too — otherwise each independently sees "it fits", and together they don't.
|
||||||
|
async fn ensure_export_space(pool: &PgPool, event_id: Uuid, export_path: &Path) -> Result<()> {
|
||||||
|
let media_bytes = estimate_export_bytes(pool, event_id).await?;
|
||||||
|
|
||||||
|
// Every job armed at any epoch for this event that hasn't finished is competing for this disk.
|
||||||
|
let (armed,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM export_job
|
||||||
|
WHERE event_id = $1 AND status IN ('pending', 'running')",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("counting armed export jobs")?;
|
||||||
|
let needed = required_free_bytes(media_bytes, armed);
|
||||||
|
|
||||||
|
// `None` = the mount couldn't be resolved. Fail OPEN, exactly as the upload quota does: refusing
|
||||||
|
// to build the keepsake because we can't read a number would be a worse failure than trying.
|
||||||
|
let Some(free) = crate::services::disk::free_bytes(export_path) else {
|
||||||
|
tracing::warn!("export preflight: disk snapshot unavailable; proceeding without the check");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if free < needed {
|
||||||
|
let gb = |b: u64| b as f64 / 1_000_000_000.0;
|
||||||
|
tracing::error!(
|
||||||
|
needed,
|
||||||
|
free,
|
||||||
|
armed,
|
||||||
|
"export preflight: not enough free space to build the keepsake for event {event_id}"
|
||||||
|
);
|
||||||
|
anyhow::bail!(
|
||||||
|
"Nicht genug Speicherplatz für das Keepsake: benötigt ca. {:.1} GB, frei sind {:.1} GB. \
|
||||||
|
Bitte Speicher freigeben und das Keepsake anschließend neu erstellen.",
|
||||||
|
gb(needed),
|
||||||
|
gb(free)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
|
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
|
||||||
/// ONLY strictly-older generations (`n < keep_seq`) — never `keep_seq`'s own current file,
|
/// ONLY strictly-older generations (`n < keep_seq`) — never `keep_seq`'s own current file,
|
||||||
/// and never a NEWER generation that a concurrent re-release may already be producing (that
|
/// and never a NEWER generation that a concurrent re-release may already be producing (that
|
||||||
@@ -1147,20 +1406,7 @@ async fn prune_stale_export_files(
|
|||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
keep_seq: i64,
|
keep_seq: i64,
|
||||||
) {
|
) {
|
||||||
// Files still referenced by a live (current-epoch) job row are OFF LIMITS regardless of the
|
let protected = protected_files(pool, event_id).await;
|
||||||
// epoch in their name. A ViewerOnly regeneration carries the finished ZIP forward by re-stamping
|
|
||||||
// its row to the new epoch WITHOUT renaming the file — so `Gallery.<event>.<older>.zip` is still
|
|
||||||
// the served archive, and deleting it by filename-epoch would 404 the download.
|
|
||||||
let protected: Vec<String> = sqlx::query_scalar::<_, String>(
|
|
||||||
"SELECT split_part(j.file_path, '/', -1) FROM export_job j
|
|
||||||
JOIN event e ON e.id = j.event_id
|
|
||||||
WHERE j.event_id = $1 AND j.epoch = e.export_epoch
|
|
||||||
AND j.status = 'done' AND j.file_path IS NOT NULL",
|
|
||||||
)
|
|
||||||
.bind(event_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// EVERY shape is event-scoped. All events share one exports volume, so a name keyed only by
|
// EVERY shape is event-scoped. All events share one exports volume, so a name keyed only by
|
||||||
// generation would let event A's prune delete event B's live keepsake (and let two events
|
// generation would let event A's prune delete event B's live keepsake (and let two events
|
||||||
@@ -1301,26 +1547,199 @@ async fn maybe_broadcast_complete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
|
/// Write the embedded viewer into the ZIP, injecting the export data as a
|
||||||
async fn write_dir_to_zip(
|
/// `window.__EXPORT_DATA__` global into `index.html`. The keepsake is opened by
|
||||||
|
/// double-clicking `index.html` (file://), where browsers block a cross-origin
|
||||||
|
/// `fetch()` of a sibling `data.json` — so the data is inlined into the page.
|
||||||
|
/// (`data.json` is still written separately for the http-served case.)
|
||||||
|
/// Permissions stamped on every entry in both archives: `rw-r--r--`.
|
||||||
|
///
|
||||||
|
/// `ZipEntryBuilder::new` leaves the external file attribute at zero, and the host compatibility
|
||||||
|
/// defaults to Unix — so every entry was written with a stored mode of **0000**. Windows Explorer
|
||||||
|
/// ignores Unix modes and was fine, which is exactly why this survived: on Linux and macOS
|
||||||
|
/// `unzip` faithfully applies what the archive asks for, and the guest gets a directory of files
|
||||||
|
/// none of which they can open. `?---------` on every line of `unzip -Z`.
|
||||||
|
///
|
||||||
|
/// That is the keepsake — the artifact the whole event exists to produce — arriving unreadable,
|
||||||
|
/// after distribution, with no server-side symptom at all.
|
||||||
|
/// `S_IFREG | 0644`. The type bits are included because the mode is written whole into the high
|
||||||
|
/// half of the external file attribute: without them extractors see a file of type "unknown"
|
||||||
|
/// (`unzip -Z` renders `?rw-r--r--`), which works but is not what the archive means to say.
|
||||||
|
const KEEPSAKE_ENTRY_MODE: u16 = 0o100_644;
|
||||||
|
|
||||||
|
/// Build a ZIP entry for the keepsake. ALL entries in both archives go through here so the mode
|
||||||
|
/// can't be forgotten at one of the six call sites.
|
||||||
|
fn keepsake_entry(name: String, compression: Compression) -> ZipEntryBuilder {
|
||||||
|
ZipEntryBuilder::new(name.into(), compression).unix_permissions(KEEPSAKE_ENTRY_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape a JSON payload for inlining inside a `<script>` element.
|
||||||
|
///
|
||||||
|
/// See the call site in [`write_viewer_with_data`] for why this is every `<` and not just `</`.
|
||||||
|
/// Kept separate so the property that matters — no `<` survives, and the value still decodes to
|
||||||
|
/// the original — can be asserted without building a ZIP.
|
||||||
|
fn escape_json_for_script(data_json: &str) -> String {
|
||||||
|
data_json.replace('<', "\\u003c")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_viewer_with_data(
|
||||||
dir: &include_dir::Dir<'_>,
|
dir: &include_dir::Dir<'_>,
|
||||||
zip: &mut ZipFileWriter<tokio::fs::File>,
|
zip: &mut ZipFileWriter<tokio::fs::File>,
|
||||||
|
data_json: &str,
|
||||||
|
theme_css: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for file in dir.files() {
|
for file in dir.files() {
|
||||||
let path = file.path().to_string_lossy().to_string();
|
let path = file.path().to_string_lossy().to_string();
|
||||||
let contents = file.contents();
|
if path == "index.html" {
|
||||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
let html = std::str::from_utf8(file.contents())
|
||||||
let mut entry = zip.write_entry_stream(builder).await?;
|
.context("export-viewer index.html is not valid UTF-8")?;
|
||||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
|
// Escape EVERY `<`, not just `</`.
|
||||||
fcopy(&mut cursor, &mut entry).await?;
|
//
|
||||||
entry.close().await?;
|
// `</` -> `<\/` stops the obvious break-out (`</script><img onerror=…>`) and is inert
|
||||||
|
// against XSS. It does not stop the caption steering the HTML TOKENIZER. A caption
|
||||||
|
// containing `<!--<script` with no later `-->` puts the parser into
|
||||||
|
// script-data-double-escaped state; from there the template's own `</script>` only
|
||||||
|
// steps back to script-data-escaped instead of closing the element, and the rest of the
|
||||||
|
// document — including the viewer bundle — is swallowed as script data. Nothing
|
||||||
|
// executes and nothing leaks; `window.__EXPORT_DATA__` is simply never assigned and the
|
||||||
|
// keepsake opens blank.
|
||||||
|
//
|
||||||
|
// That failure is silent and POST-DISTRIBUTION: the export succeeds, the ZIP is
|
||||||
|
// well-formed, the job writes `done`, /export/status is green, and the host hands out a
|
||||||
|
// file that only fails when a guest double-clicks index.html — in every copy, with no
|
||||||
|
// way to fix it after the fact. Reachable from any guest-authored caption or comment,
|
||||||
|
// since both are embedded in the viewer.
|
||||||
|
//
|
||||||
|
// `<` never appears in JSON structural syntax — only inside string values — so a global
|
||||||
|
// replace is sound, and `<` is valid in both JSON and a JS string literal. One
|
||||||
|
// rule covers `</script`, `<!--` and `<script` together, which is the point: the
|
||||||
|
// previous escape was named for the single case it did handle.
|
||||||
|
//
|
||||||
|
// NOTE this is deliberately only for the INLINED copy. `data.json` is written
|
||||||
|
// separately, in no HTML context, and must stay literal.
|
||||||
|
let safe = escape_json_for_script(data_json);
|
||||||
|
// Match the live app's colour theme: inject the same `:root:root{…}` override
|
||||||
|
// the app builds at runtime so a rose/sage/custom event exports a rose/sage
|
||||||
|
// keepsake (not the embedded default gold). The CSS is generated purely from
|
||||||
|
// hex seeds (theme_override_css), so there's nothing to escape.
|
||||||
|
let mut head_inject = String::new();
|
||||||
|
if let Some(css) = theme_css {
|
||||||
|
head_inject.push_str(&format!("<style id=\"es-theme\">{css}</style>"));
|
||||||
|
}
|
||||||
|
head_inject.push_str(&format!("<script>window.__EXPORT_DATA__={safe};</script>"));
|
||||||
|
let injected = match html.find("</head>") {
|
||||||
|
Some(idx) => format!("{}{}{}", &html[..idx], head_inject, &html[idx..]),
|
||||||
|
None => format!("{head_inject}{html}"),
|
||||||
|
};
|
||||||
|
let builder = keepsake_entry(path, Compression::Deflate);
|
||||||
|
let mut entry = zip.write_entry_stream(builder).await?;
|
||||||
|
let mut cursor = AllowStdIo::new(std::io::Cursor::new(injected.as_bytes()));
|
||||||
|
fcopy(&mut cursor, &mut entry).await?;
|
||||||
|
entry.close().await?;
|
||||||
|
} else {
|
||||||
|
let builder = keepsake_entry(path, Compression::Deflate);
|
||||||
|
let mut entry = zip.write_entry_stream(builder).await?;
|
||||||
|
let mut cursor = AllowStdIo::new(std::io::Cursor::new(file.contents()));
|
||||||
|
fcopy(&mut cursor, &mut entry).await?;
|
||||||
|
entry.close().await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for sub_dir in dir.dirs() {
|
for sub_dir in dir.dirs() {
|
||||||
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
|
Box::pin(write_viewer_with_data(sub_dir, zip, data_json, theme_css)).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default champagne-gold seed — matches the hand-tuned ramp already embedded in the
|
||||||
|
/// viewer, so a default-themed event injects no override.
|
||||||
|
const KEEPSAKE_DEFAULT_SEED: &str = "#8a6a2b";
|
||||||
|
|
||||||
|
// stop → color-mix instruction. MIRRORS `LADDER` in frontend/src/lib/theme/palette.ts —
|
||||||
|
// keep the two in sync so an exported keepsake matches the live app pixel-for-pixel.
|
||||||
|
const THEME_LADDER: &[(u16, Option<&str>)] = &[
|
||||||
|
(50, Some("white 90%")),
|
||||||
|
(100, Some("white 80%")),
|
||||||
|
(200, Some("white 62%")),
|
||||||
|
(300, Some("white 42%")),
|
||||||
|
(400, Some("white 22%")),
|
||||||
|
(500, Some("white 9%")),
|
||||||
|
(600, None),
|
||||||
|
(700, Some("black 15%")),
|
||||||
|
(800, Some("black 30%")),
|
||||||
|
(900, Some("black 45%")),
|
||||||
|
(950, Some("black 63%")),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn theme_stop_value(seed: &str, mix: Option<&str>) -> String {
|
||||||
|
match mix {
|
||||||
|
Some(m) => format!("color-mix(in oklab, {seed}, {m})"),
|
||||||
|
None => seed.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_ramp(families: &[&str], seed: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (stop, mix) in THEME_LADDER {
|
||||||
|
let val = theme_stop_value(seed, *mix);
|
||||||
|
for fam in families {
|
||||||
|
out.push_str(&format!("--color-{fam}-{stop}:{val};"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hex_color(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
b.len() == 7 && b[0] == b'#' && b[1..].iter().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the keepsake's `:root:root{…}` colour override from two seed colours, or None
|
||||||
|
/// for the default gold (viewer already carries that ramp) or an invalid seed (fall back
|
||||||
|
/// to the embedded default rather than emit unsafe CSS). MIRRORS `buildPaletteCss` in
|
||||||
|
/// frontend/src/lib/theme/palette.ts.
|
||||||
|
fn theme_override_css(primary: &str, accent: &str) -> Option<String> {
|
||||||
|
if !is_hex_color(primary) || !is_hex_color(accent) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if primary.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED)
|
||||||
|
&& accent.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let accent500 = theme_stop_value(accent, Some("white 9%"));
|
||||||
|
let mut css = String::from(":root:root{");
|
||||||
|
css.push_str(&theme_ramp(&["blue", "primary"], primary));
|
||||||
|
css.push_str(&theme_ramp(&["purple"], accent));
|
||||||
|
css.push_str(&format!(
|
||||||
|
"--color-violet-500:{accent500};--color-violet-600:{accent};"
|
||||||
|
));
|
||||||
|
css.push_str(&format!(
|
||||||
|
"--color-accent-500:{accent500};--color-accent-600:{accent};"
|
||||||
|
));
|
||||||
|
css.push('}');
|
||||||
|
Some(css)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the active theme seeds from the runtime `config` table (set by the admin UI),
|
||||||
|
/// falling back to the default gold. NOTE: an env-only default (THEME_PRIMARY set but
|
||||||
|
/// never saved via the admin UI) isn't stored in this table, so such a keepsake would
|
||||||
|
/// use gold; admin-set themes — the normal path — match the app exactly.
|
||||||
|
async fn resolve_theme_seeds(pool: &PgPool) -> (String, String) {
|
||||||
|
async fn read(pool: &PgPool, key: &str) -> String {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT value FROM config WHERE key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| KEEPSAKE_DEFAULT_SEED.to_string())
|
||||||
|
}
|
||||||
|
(
|
||||||
|
read(pool, "theme_primary").await,
|
||||||
|
read(pool, "theme_accent").await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn ext_from_path(path: &str) -> &str {
|
fn ext_from_path(path: &str) -> &str {
|
||||||
path.rsplit('.').next().unwrap_or("bin")
|
path.rsplit('.').next().unwrap_or("bin")
|
||||||
}
|
}
|
||||||
@@ -1354,3 +1773,187 @@ So geht's:\n\
|
|||||||
Alles ist lokal auf deinem Gerät gespeichert.\n\
|
Alles ist lokal auf deinem Gerät gespeichert.\n\
|
||||||
\n\
|
\n\
|
||||||
Viel Freude mit den Erinnerungen!\n";
|
Viel Freude mit den Erinnerungen!\n";
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const EVT: &str = "11111111-1111-1111-1111-111111111111";
|
||||||
|
|
||||||
|
fn gallery_prefix() -> String {
|
||||||
|
format!("Gallery.{EVT}.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_strictly_older_archive_is_reclaimed() {
|
||||||
|
// The whole point: at the start of a rebuild at epoch 5, generation 4's archive is dead
|
||||||
|
// weight — readiness is derived from `epoch = event.export_epoch`, so it is already
|
||||||
|
// unreachable — and its bytes are very often exactly the bytes the rebuild needs.
|
||||||
|
assert!(is_superseded_archive(
|
||||||
|
&format!("Gallery.{EVT}.4.zip"),
|
||||||
|
&gallery_prefix(),
|
||||||
|
5,
|
||||||
|
&[]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn our_own_and_newer_generations_are_never_touched() {
|
||||||
|
// `keep_seq` is OUR generation; a NEWER one belongs to a re-release that has already
|
||||||
|
// superseded us, and deleting it would let a lagging worker nuke a live keepsake.
|
||||||
|
for seq in [5, 6] {
|
||||||
|
assert!(
|
||||||
|
!is_superseded_archive(
|
||||||
|
&format!("Gallery.{EVT}.{seq}.zip"),
|
||||||
|
&gallery_prefix(),
|
||||||
|
5,
|
||||||
|
&[]
|
||||||
|
),
|
||||||
|
"generation {seq} must survive a prune keeping 5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_carried_forward_archive_survives_despite_an_older_epoch_in_its_name() {
|
||||||
|
// THE dangerous case. A ViewerOnly regeneration (a moderated comment) re-stamps the
|
||||||
|
// finished ZIP's row to the new epoch WITHOUT renaming the file, so the SERVED archive
|
||||||
|
// legitimately carries an older generation number. Pruning it by filename-epoch would 404
|
||||||
|
// the photo download to change nothing in it. The protected set is what stops that, and
|
||||||
|
// moving the prune to build-start makes this case reachable far more often.
|
||||||
|
let carried = format!("Gallery.{EVT}.4.zip");
|
||||||
|
assert!(!is_superseded_archive(
|
||||||
|
&carried,
|
||||||
|
&gallery_prefix(),
|
||||||
|
5,
|
||||||
|
std::slice::from_ref(&carried)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temps_and_staging_dirs_are_out_of_scope_for_the_early_prune() {
|
||||||
|
// A superseded worker may still be streaming into these, and at build START it is much
|
||||||
|
// more likely to be alive than at finalize time. Only inert FINAL archives are reclaimed
|
||||||
|
// here; `prune_stale_export_files` still handles the rest after we win.
|
||||||
|
for name in [
|
||||||
|
format!("Gallery.{EVT}.4.tmp"),
|
||||||
|
format!("viewer_tmp_{EVT}_4"),
|
||||||
|
format!("Memories.{EVT}.4.zip"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_superseded_archive(&name, &gallery_prefix(), 5, &[]),
|
||||||
|
"{name} must not be reclaimed by the pre-build prune"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_events_archive_is_never_reclaimed() {
|
||||||
|
// All events share one exports volume, so the prefix carries the event id.
|
||||||
|
let other = "22222222-2222-2222-2222-222222222222";
|
||||||
|
assert!(!is_superseded_archive(
|
||||||
|
&format!("Gallery.{other}.4.zip"),
|
||||||
|
&gallery_prefix(),
|
||||||
|
5,
|
||||||
|
&[]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_files_are_left_alone() {
|
||||||
|
for name in ["Gallery.zip", "notes.txt", "Gallery..4.zip"] {
|
||||||
|
assert!(!is_superseded_archive(name, &gallery_prefix(), 5, &[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `<` is escaped, whatever it is part of.
|
||||||
|
///
|
||||||
|
/// PREVENTS the regression to `</` -> `<\\/`, which is named for the one case it handles.
|
||||||
|
/// `<!--<script` with no later `-->` drives the HTML tokenizer into
|
||||||
|
/// script-data-double-escaped state, where the template's own `</script>` no longer closes
|
||||||
|
/// the element — the viewer bundle is swallowed as script data, `__EXPORT_DATA__` is never
|
||||||
|
/// assigned, and the keepsake opens blank in every copy the host has already handed out.
|
||||||
|
#[test]
|
||||||
|
fn no_left_angle_bracket_survives_inlining() {
|
||||||
|
for payload in [
|
||||||
|
r#"{"caption":"<!--<script"}"#,
|
||||||
|
r#"{"caption":"</script><img src=x onerror=alert(1)>"}"#,
|
||||||
|
r#"{"caption":"<!--"}"#,
|
||||||
|
r#"{"caption":"<script>"}"#,
|
||||||
|
r#"{"caption":"a < b"}"#,
|
||||||
|
] {
|
||||||
|
let escaped = escape_json_for_script(payload);
|
||||||
|
assert!(
|
||||||
|
!escaped.contains('<'),
|
||||||
|
"a surviving `<` can still steer the tokenizer: {escaped}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The escape must not change what the viewer READS — it is a transport encoding, not a
|
||||||
|
/// sanitiser. A caption is guest-authored text that has to render back exactly.
|
||||||
|
#[test]
|
||||||
|
fn the_payload_still_decodes_to_the_original_value() {
|
||||||
|
// `<` appears only inside JSON string values, never in structural syntax, so a global
|
||||||
|
// replace is sound — this is the assertion that says so.
|
||||||
|
for caption in [
|
||||||
|
"<!--<script",
|
||||||
|
"</script><img src=x onerror=alert(1)>",
|
||||||
|
"a < b und c > d",
|
||||||
|
"ganz normale Bildunterschrift",
|
||||||
|
"Herz <3",
|
||||||
|
] {
|
||||||
|
let json = serde_json::json!({ "posts": [{ "caption": caption }] }).to_string();
|
||||||
|
let escaped = escape_json_for_script(&json);
|
||||||
|
let back: serde_json::Value =
|
||||||
|
serde_json::from_str(&escaped).expect("the escaped form must still be valid JSON");
|
||||||
|
assert_eq!(
|
||||||
|
back["posts"][0]["caption"].as_str(),
|
||||||
|
Some(caption),
|
||||||
|
"the caption must survive the round trip unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing else in the document is touched.
|
||||||
|
#[test]
|
||||||
|
fn a_payload_with_no_angle_brackets_is_unchanged() {
|
||||||
|
let json = r#"{"posts":[{"caption":"schönes Foto"}]}"#;
|
||||||
|
assert_eq!(escape_json_for_script(json), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lone_armed_job_reserves_for_one_archive() {
|
||||||
|
// A ViewerOnly regeneration re-arms only the HTML half — reserving for two would refuse
|
||||||
|
// a rebuild that fits perfectly well.
|
||||||
|
assert_eq!(required_free_bytes(1_000, 1), 1_100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_concurrent_halves_reserve_for_both() {
|
||||||
|
// The bug this exists to prevent: each worker independently sees "it fits", and together
|
||||||
|
// they don't. Both halves are gallery-sized, so the reservation must be for the pair.
|
||||||
|
assert_eq!(required_free_bytes(1_000, 2), 2_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_count_still_reserves_for_one() {
|
||||||
|
// Defensive: a racing status transition must never yield a zero requirement, which would
|
||||||
|
// wave through an export of any size onto a full disk.
|
||||||
|
assert_eq!(required_free_bytes(1_000, 0), 1_100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_gallery_needs_nothing() {
|
||||||
|
assert_eq!(required_free_bytes(0, 2), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pathological_size_saturates_instead_of_wrapping() {
|
||||||
|
// u64 overflow would wrap to a TINY requirement and authorise the exact write we're
|
||||||
|
// guarding against — the failure mode must be "refuse", never "wrap and allow".
|
||||||
|
assert_eq!(required_free_bytes(u64::MAX, 2), u64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
263
backend/src/services/imaging.rs
Normal file
263
backend/src/services/imaging.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//! Shared image decoding.
|
||||||
|
//!
|
||||||
|
//! Exists so there is exactly ONE way to turn a file on disk into a `DynamicImage` in this
|
||||||
|
//! codebase. Two properties have to hold everywhere an image is decoded, and both were
|
||||||
|
//! previously re-derived per call site — which is how they drifted apart:
|
||||||
|
//!
|
||||||
|
//! - **EXIF orientation must be applied.** Phones do not rotate sensor data; they record how
|
||||||
|
//! the camera was held in a tag and store the pixels as shot. `image::open` and
|
||||||
|
//! `ImageReader::decode` both hand back the raw pixels and ignore that tag, and re-encoding
|
||||||
|
//! to JPEG writes no EXIF, so the derivative is permanently sideways while the untouched
|
||||||
|
//! original still renders upright. The compression worker was fixed; the export worker was
|
||||||
|
//! not, so every portrait photo came out sideways in the keepsake's HTML viewer.
|
||||||
|
//! - **Decode limits must be set.** The upload body cap bounds the file on disk, but a small
|
||||||
|
//! file can decode to enormous dimensions (a ~1 MB image expanding to 50k×50k px), OOM-ing
|
||||||
|
//! the box. `image::open` applies NO limits at all, so the export path was also decoding
|
||||||
|
//! arbitrary user-supplied images unbounded.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use image::{DynamicImage, ImageDecoder};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Bounds for any decode of user-supplied image data. The per-axis cap covers any real phone
|
||||||
|
/// photo; `max_alloc` bounds the decoded buffer — but only because `decode_oriented` reserves
|
||||||
|
/// against it explicitly, see there.
|
||||||
|
///
|
||||||
|
/// Sized against the deployment: the app container is capped at 1 GiB and the compression
|
||||||
|
/// worker runs `compression_concurrency` decodes at once (default 2), so 256 MiB per decode
|
||||||
|
/// leaves headroom for the resize buffers and the runtime.
|
||||||
|
fn decode_limits() -> image::Limits {
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(12_000);
|
||||||
|
limits.max_image_height = Some(12_000);
|
||||||
|
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||||
|
limits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when re-running the exact same work on the exact same bytes cannot possibly
|
||||||
|
/// succeed, so retrying only burns wall-clock and log noise.
|
||||||
|
///
|
||||||
|
/// Deliberately narrow. Only the `ImageError` variants that are a property of the *input*
|
||||||
|
/// count: the file will not shrink, gain codec support, or un-corrupt itself between
|
||||||
|
/// attempts. `IoError` is excluded on purpose — an ENOSPC while writing a derivative, or
|
||||||
|
/// EMFILE under load, is exactly the transient case the retry exists for.
|
||||||
|
pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
||||||
|
err.chain().any(|cause| {
|
||||||
|
matches!(
|
||||||
|
cause.downcast_ref::<image::ImageError>(),
|
||||||
|
Some(
|
||||||
|
image::ImageError::Limits(_)
|
||||||
|
| image::ImageError::Unsupported(_)
|
||||||
|
| image::ImageError::Decoding(_)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a decoder for `path` with the budget enforced, WITHOUT reading any pixels.
|
||||||
|
///
|
||||||
|
/// Single source of truth for "may this image be decoded at all": both the upload
|
||||||
|
/// admission check and the compression worker go through here, so they cannot disagree
|
||||||
|
/// about what is acceptable.
|
||||||
|
fn decoder_within_budget(path: &Path) -> Result<impl image::ImageDecoder> {
|
||||||
|
let mut reader = image::ImageReader::open(path)
|
||||||
|
.context("failed to open image")?
|
||||||
|
.with_guessed_format()
|
||||||
|
.context("failed to read image header")?;
|
||||||
|
let mut limits = decode_limits();
|
||||||
|
reader.limits(limits.clone());
|
||||||
|
|
||||||
|
// We need `into_decoder` rather than `decode()` to read the EXIF orientation tag before
|
||||||
|
// the pixels are consumed. But the two are NOT equivalent on safety: `decode()` performs
|
||||||
|
//
|
||||||
|
// limits.reserve(decoder.total_bytes())?;
|
||||||
|
//
|
||||||
|
// between building the decoder and reading the image, and `into_decoder()` skips it (the
|
||||||
|
// crate's own FIXME concedes `from_decoder` doesn't compensate). Nothing else enforces
|
||||||
|
// `max_alloc` — the JPEG decoder's `set_limits` only checks support and dimensions — so
|
||||||
|
// without the line below the budget is inert and the ONLY bound is the per-axis cap. That
|
||||||
|
// leaves 12000x12000 decodable at 412 MiB, and two concurrent at 824 MiB against a 1 GiB
|
||||||
|
// container. Re-add it, exactly as `decode()` does.
|
||||||
|
let mut decoder = reader.into_decoder().context("failed to decode image")?;
|
||||||
|
limits
|
||||||
|
.reserve(decoder.total_bytes())
|
||||||
|
.context("image too large to decode within the memory budget")?;
|
||||||
|
decoder
|
||||||
|
.set_limits(limits)
|
||||||
|
.context("image too large to decode within the memory budget")?;
|
||||||
|
Ok(decoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Megapixels an image would decode to, or `None` if its header can't be read. Used only
|
||||||
|
/// to put a concrete number in the message the guest sees.
|
||||||
|
pub fn megapixels(path: &Path) -> Option<f64> {
|
||||||
|
let reader = image::ImageReader::open(path)
|
||||||
|
.ok()?
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?;
|
||||||
|
let (w, h) = reader.into_dimensions().ok()?;
|
||||||
|
Some(f64::from(w) * f64::from(h) / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when an image cannot be decoded specifically because it would exceed the memory
|
||||||
|
/// budget — read from the header, no pixels touched.
|
||||||
|
///
|
||||||
|
/// Called at upload admission so a guest who sends a 100 MP photo is told at the door, with
|
||||||
|
/// a reason they can act on, instead of the upload being accepted with a 201 and then
|
||||||
|
/// silently soft-deleted minutes later when the worker gives up on it.
|
||||||
|
///
|
||||||
|
/// Deliberately narrow: ONLY the budget. A corrupt, truncated or unsupported file also
|
||||||
|
/// fails to build a decoder, but rejecting those here would change a contract the
|
||||||
|
/// adversarial suite pins on purpose — acceptance follows the magic bytes, and a payload
|
||||||
|
/// with a valid JPEG header is accepted regardless of what follows it. Those go to the
|
||||||
|
/// compression worker as before, which handles them gracefully and (since the retry
|
||||||
|
/// classifier) no longer burns backoff on them.
|
||||||
|
pub fn exceeds_decode_budget(path: &Path) -> bool {
|
||||||
|
match decoder_within_budget(path) {
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(e) => e.chain().any(|cause| {
|
||||||
|
matches!(
|
||||||
|
cause.downcast_ref::<image::ImageError>(),
|
||||||
|
Some(image::ImageError::Limits(_))
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an image from disk with decompression-bomb limits applied and its EXIF
|
||||||
|
/// orientation baked into the pixels.
|
||||||
|
///
|
||||||
|
/// Blocking — call inside `spawn_blocking`.
|
||||||
|
pub fn decode_oriented(path: &Path) -> Result<DynamicImage> {
|
||||||
|
let mut decoder = decoder_within_budget(path)?;
|
||||||
|
|
||||||
|
// Cheap, and it happens BEFORE any pixels are read: an oversized image costs a header
|
||||||
|
// parse, not an allocation.
|
||||||
|
let orientation = decoder
|
||||||
|
.orientation()
|
||||||
|
.unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||||
|
let mut img = DynamicImage::from_decoder(decoder).context("failed to decode image")?;
|
||||||
|
img.apply_orientation(orientation);
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Shared with the e2e suite rather than duplicating 568 KiB of binary: the same file
|
||||||
|
/// drives `02-upload/oversized-image` so both layers assert on one artefact.
|
||||||
|
const HUGE: &str = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../e2e/fixtures/media/huge-99mp.jpg"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_image_that_would_blow_the_allocation_budget() {
|
||||||
|
// 11000x9000 = 99 MP. Deliberately UNDER the 12000px per-axis cap, so the axis check
|
||||||
|
// cannot reject it — the allocation budget is the only thing that can, which is
|
||||||
|
// exactly what makes this a regression test rather than a restatement of the axis cap.
|
||||||
|
// 283 MiB decoded as RGB8 against a 256 MiB budget, from 568 KiB on disk.
|
||||||
|
//
|
||||||
|
// This failed before the guard was restored: `ImageReader::decode` performs
|
||||||
|
// `limits.reserve(decoder.total_bytes())`, and `into_decoder()` — which we need for
|
||||||
|
// the EXIF tag — skips it, so `max_alloc` was inert and this decoded happily.
|
||||||
|
// Map the Ok arm to its dimensions first: on failure `expect_err` Debug-prints the
|
||||||
|
// value, and Debug on a DynamicImage dumps every pixel — 283 MiB of output.
|
||||||
|
let err = decode_oriented(Path::new(HUGE))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("a 99 MP image must be refused, not allocated");
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.to_lowercase().contains("limit") || msg.to_lowercase().contains("memory"),
|
||||||
|
"expected a limits error, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_image_is_a_permanent_failure() {
|
||||||
|
// The retry loop must not burn 2s + 4s of backoff on this: the file will not shrink
|
||||||
|
// between attempts, so all three attempts reach the identical conclusion.
|
||||||
|
let err = decode_oriented(Path::new(HUGE))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("fixture must exceed the budget");
|
||||||
|
assert!(
|
||||||
|
is_permanent_image_error(&err),
|
||||||
|
"a Limits error can never succeed on retry: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_io_error_is_not_permanent() {
|
||||||
|
// The mirror that keeps the classifier honest. ENOSPC while writing a derivative, or
|
||||||
|
// EMFILE under load, is exactly what the retry exists for — misclassifying those as
|
||||||
|
// permanent would turn a transient blip back into the data loss round 1 fixed.
|
||||||
|
let err = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("a missing file must error");
|
||||||
|
assert!(
|
||||||
|
!is_permanent_image_error(&err),
|
||||||
|
"an IO error must stay retryable: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_rejects_only_the_over_budget_case() {
|
||||||
|
// Admission and processing must agree about SIZE — a photo accepted at the door and
|
||||||
|
// then rejected by the worker for being too big is the failure this pair prevents.
|
||||||
|
assert!(
|
||||||
|
exceeds_decode_budget(Path::new(HUGE)),
|
||||||
|
"admission must reject what the decoder rejects for size"
|
||||||
|
);
|
||||||
|
let ordinary = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../e2e/fixtures/media/portrait-exif6.jpg"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!exceeds_decode_budget(Path::new(ordinary)),
|
||||||
|
"admission must accept an ordinary photo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_does_not_reject_a_merely_undecodable_file() {
|
||||||
|
// The narrowing that keeps the adversarial contract intact: a payload with valid
|
||||||
|
// JPEG magic bytes and nothing behind them cannot be decoded, but acceptance follows
|
||||||
|
// the magic bytes by design (07-adversarial/file-upload-attacks). It is the worker's
|
||||||
|
// job to fail it, not admission's — admission is only the resource guard.
|
||||||
|
let dir = std::env::temp_dir().join("eventsnap-imaging-test");
|
||||||
|
std::fs::create_dir_all(&dir).expect("tmp dir");
|
||||||
|
let stub = dir.join("magic-only.jpg");
|
||||||
|
let mut bytes = vec![0u8; 1024];
|
||||||
|
bytes[..3].copy_from_slice(&[0xFF, 0xD8, 0xFF]);
|
||||||
|
std::fs::write(&stub, &bytes).expect("write stub");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!exceeds_decode_budget(&stub),
|
||||||
|
"a corrupt file is not an over-budget file"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decode_oriented(&stub)
|
||||||
|
.map(|i| (i.width(), i.height()))
|
||||||
|
.is_err(),
|
||||||
|
"...but it must still fail in the worker"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&stub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn still_decodes_an_ordinary_photo_and_applies_orientation() {
|
||||||
|
// The guard must not have become a blanket refusal. This fixture is 40x20 stored with
|
||||||
|
// EXIF Orientation=6, so a correct decode returns it rotated to 20x40 portrait.
|
||||||
|
let path = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../e2e/fixtures/media/portrait-exif6.jpg"
|
||||||
|
);
|
||||||
|
let img = decode_oriented(Path::new(path)).expect("an ordinary photo must decode");
|
||||||
|
assert_eq!(
|
||||||
|
(img.width(), img.height()),
|
||||||
|
(20, 40),
|
||||||
|
"EXIF orientation must still be applied after restoring the guard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,13 @@
|
|||||||
//! users staring at a spinner. Resetting them on startup recovers gracefully.
|
//! users staring at a spinner. Resetting them on startup recovers gracefully.
|
||||||
//!
|
//!
|
||||||
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
|
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
|
||||||
//! request: expired sessions (otherwise the table grows unboundedly), and the
|
//! request: expired sessions (otherwise the table grows unboundedly), the
|
||||||
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
||||||
//! accumulate).
|
//! accumulate), and the media of soft-deleted uploads — both the ones whose compression
|
||||||
|
//! permanently failed and the ones a guest or host deliberately removed — which are
|
||||||
|
//! retained for a recovery window and then reclaimed.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -20,6 +23,42 @@ use sqlx::PgPool;
|
|||||||
use crate::services::rate_limiter::RateLimiter;
|
use crate::services::rate_limiter::RateLimiter;
|
||||||
use crate::services::sse_tickets::SseTicketStore;
|
use crate::services::sse_tickets::SseTicketStore;
|
||||||
|
|
||||||
|
/// How long a permanently-failed upload's original is kept on disk before it is
|
||||||
|
/// reclaimed.
|
||||||
|
///
|
||||||
|
/// The compression worker stops deleting originals on failure — a transient error must
|
||||||
|
/// never destroy the guest's only copy of a photo they can't retake. But the row is
|
||||||
|
/// soft-deleted and the uploader's quota IS refunded, so without a sweep those bytes are
|
||||||
|
/// invisible, unowned, and free: a reproducible codec failure lets one guest accumulate
|
||||||
|
/// orphans at no personal cost, and because `active_uploaders` counts only users with
|
||||||
|
/// non-deleted uploads, dropping out of that count actually RAISES everyone's per-user
|
||||||
|
/// ceiling while the disk gets fuller.
|
||||||
|
///
|
||||||
|
/// Two weeks is comfortably longer than any single event, so an operator investigating a
|
||||||
|
/// failed upload still has the file, while the leak stays bounded.
|
||||||
|
const FAILED_ORIGINAL_RETENTION_DAYS: i64 = 14;
|
||||||
|
|
||||||
|
/// How long a DELIBERATELY deleted upload's files are kept before they are reclaimed.
|
||||||
|
///
|
||||||
|
/// The same leak, reached by the ordinary path rather than the exceptional one.
|
||||||
|
/// `soft_delete_in_event` stamps `deleted_at` and refunds `total_upload_bytes`, but nothing ever
|
||||||
|
/// removed the bytes — so the quota stopped bounding the disk. Upload 500 MB, delete, quota is back
|
||||||
|
/// to zero, upload another 500 MB: not an attack, just a guest curating their camera roll, which is
|
||||||
|
/// what people do. The host then sees guests hitting "Du hast dein Upload-Limit erreicht" while the
|
||||||
|
/// admin widget shows a disk full of files no upload row points at, and the quota message is
|
||||||
|
/// actively misleading because the space really is gone — just not to anyone the accounting can
|
||||||
|
/// name.
|
||||||
|
///
|
||||||
|
/// Much shorter than the failure window on purpose. Fourteen days outlives the whole event, so a
|
||||||
|
/// deliberate delete would never reclaim anything while it mattered. A day still gives an operator
|
||||||
|
/// a recovery window for a mis-tap.
|
||||||
|
///
|
||||||
|
/// NOTE what this does NOT do: within the window the bytes are still spent and still unaccounted,
|
||||||
|
/// so a guest deleting and re-uploading through an eight-hour event can outrun the sweep. Bounding
|
||||||
|
/// that would mean holding the quota until the file is actually reclaimed rather than refunding at
|
||||||
|
/// `deleted_at` — a deliberate trade, and the reason the low-disk warning exists.
|
||||||
|
const DELETED_UPLOAD_RETENTION_HOURS: i64 = 24;
|
||||||
|
|
||||||
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
|
/// 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
|
/// before the HTTP server starts taking requests, so users never observe the
|
||||||
/// half-state.
|
/// half-state.
|
||||||
@@ -87,7 +126,12 @@ pub async fn startup_recovery(pool: &PgPool) {
|
|||||||
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
|
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
|
||||||
///
|
///
|
||||||
/// Cadence is 1h — fine for both jobs at our scale.
|
/// Cadence is 1h — fine for both jobs at our scale.
|
||||||
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) {
|
pub fn spawn_periodic_tasks(
|
||||||
|
pool: PgPool,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
sse_tickets: SseTicketStore,
|
||||||
|
media_path: PathBuf,
|
||||||
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||||
// Fire the first tick immediately, then hourly.
|
// Fire the first tick immediately, then hourly.
|
||||||
@@ -95,12 +139,117 @@ pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets
|
|||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
cleanup_sessions(&pool).await;
|
cleanup_sessions(&pool).await;
|
||||||
|
cleanup_deleted_media(&pool, &media_path).await;
|
||||||
rate_limiter.prune();
|
rate_limiter.prune();
|
||||||
sse_tickets.prune();
|
sse_tickets.prune();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reclaim the media of soft-deleted uploads once they are past their retention window.
|
||||||
|
///
|
||||||
|
/// ONLY ever touches rows with `deleted_at IS NOT NULL`, so it can never reach a live upload. Two
|
||||||
|
/// classes, two windows, because the two deletes mean different things:
|
||||||
|
///
|
||||||
|
/// - a compression failure the guest didn't ask for and may want investigated —
|
||||||
|
/// [`FAILED_ORIGINAL_RETENTION_DAYS`];
|
||||||
|
/// - a deliberate removal by the guest or the host — [`DELETED_UPLOAD_RETENTION_HOURS`].
|
||||||
|
///
|
||||||
|
/// ALL FOUR paths are reclaimed, not just the original. The previous version cleared
|
||||||
|
/// `original_path` alone, which was right for its only case (a failed compression produces no
|
||||||
|
/// derivatives) but wrong the moment the sweep reaches a successfully processed upload: preview,
|
||||||
|
/// display and thumbnail are each a separate file on disk, none of them counted in
|
||||||
|
/// `original_size_bytes`, and nothing else ever removed them.
|
||||||
|
///
|
||||||
|
/// Every column is cleared in the same pass, which makes the sweep idempotent and stops a later run
|
||||||
|
/// re-reporting files that are already gone. The ROW is kept: it is the audit trail, it costs a few
|
||||||
|
/// hundred bytes, and `backfill_stale_derivatives` is guarded on `deleted_at IS NULL` so a nulled
|
||||||
|
/// `preview_path` can never make it regenerate what was just reclaimed.
|
||||||
|
async fn cleanup_deleted_media(pool: &PgPool, media_path: &std::path::Path) {
|
||||||
|
type Row = (
|
||||||
|
uuid::Uuid,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
|
||||||
|
WHERE deleted_at IS NOT NULL
|
||||||
|
AND CASE WHEN compression_status = 'failed'
|
||||||
|
THEN deleted_at < NOW() - ($1 || ' days')::interval
|
||||||
|
ELSE deleted_at < NOW() - ($2 || ' hours')::interval
|
||||||
|
END
|
||||||
|
AND (original_path <> '' OR preview_path IS NOT NULL
|
||||||
|
OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
|
||||||
|
)
|
||||||
|
.bind(FAILED_ORIGINAL_RETENTION_DAYS.to_string())
|
||||||
|
.bind(DELETED_UPLOAD_RETENTION_HOURS.to_string())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "deleted-media sweep query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reclaimed = 0usize;
|
||||||
|
for (id, original, preview, display, thumbnail) in rows {
|
||||||
|
let paths: Vec<String> = std::iter::once(original)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.chain([preview, display, thumbnail].into_iter().flatten())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// All-or-nothing per row: the columns are only cleared once every file for that upload is
|
||||||
|
// gone. Clearing after a partial success would strand the survivors with nothing pointing
|
||||||
|
// at them — the same unowned-bytes state this sweep exists to drain.
|
||||||
|
let mut all_gone = true;
|
||||||
|
for rel in &paths {
|
||||||
|
let absolute = media_path.join(rel);
|
||||||
|
match tokio::fs::remove_file(&absolute).await {
|
||||||
|
Ok(()) => reclaimed += 1,
|
||||||
|
// Already gone (manual cleanup, restored backup) — still counts as reclaimed for
|
||||||
|
// the purpose of clearing the columns, or the row is re-selected every hour forever.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, path = %absolute.display(),
|
||||||
|
"could not reclaim deleted media; leaving the row for the next sweep");
|
||||||
|
all_gone = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !all_gone {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
"UPDATE upload SET original_path = '', preview_path = NULL,
|
||||||
|
display_path = NULL, thumbnail_path = NULL
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, %id, "reclaimed the files but could not clear the paths");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reclaimed > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"reclaimed {reclaimed} file(s) from soft-deleted uploads (deliberate deletes after \
|
||||||
|
{DELETED_UPLOAD_RETENTION_HOURS}h, compression failures after \
|
||||||
|
{FAILED_ORIGINAL_RETENTION_DAYS}d)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn cleanup_sessions(pool: &PgPool) {
|
async fn cleanup_sessions(pool: &PgPool) {
|
||||||
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
|
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod compression;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod disk;
|
pub mod disk;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod imaging;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod sse_tickets;
|
pub mod sse_tickets;
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ impl RateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// 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.
|
/// `retry_after_secs` is how long until the oldest slot in the window expires.
|
||||||
|
///
|
||||||
|
/// This is deliberately the ONLY entry point. There used to be a `check()` wrapper
|
||||||
|
/// returning a plain bool, and 7 of the 8 call sites used it and then hard-coded
|
||||||
|
/// `None` for the response's `Retry-After` — so a throttled client was told to back
|
||||||
|
/// off but never for how long. Forcing every caller through the `Result` makes the
|
||||||
|
/// retry delay impossible to discard by accident.
|
||||||
pub fn check_with_retry(
|
pub fn check_with_retry(
|
||||||
&self,
|
&self,
|
||||||
key: impl Into<String>,
|
key: impl Into<String>,
|
||||||
@@ -84,6 +85,11 @@ impl RateLimiter {
|
|||||||
/// appends is the real client. A client can prepend arbitrary spoofed values to
|
/// appends is the real client. A client can prepend arbitrary spoofed values to
|
||||||
/// the left of XFF to dodge throttles — those are ignored here. This assumes
|
/// the left of XFF to dodge throttles — those are ignored here. This assumes
|
||||||
/// exactly one trusted proxy (Caddy); revisit if that changes.
|
/// exactly one trusted proxy (Caddy); revisit if that changes.
|
||||||
|
///
|
||||||
|
/// Pass the peer address as `fallback`, never a constant. Every caller used to pass
|
||||||
|
/// the literal `"unknown"`, so any request that arrived without XFF — i.e. anything
|
||||||
|
/// reaching the app directly rather than through Caddy — shared ONE bucket with every
|
||||||
|
/// other such request, turning the limiter into a self-inflicted global throttle.
|
||||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||||
headers
|
headers
|
||||||
.get("x-forwarded-for")
|
.get("x-forwarded-for")
|
||||||
@@ -104,29 +110,35 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn allows_up_to_max_then_blocks() {
|
fn allows_up_to_max_then_blocks() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("k", 3, MIN));
|
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||||
assert!(rl.check("k", 3, MIN));
|
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||||
assert!(rl.check("k", 3, MIN));
|
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||||
assert!(!rl.check("k", 3, MIN), "the 4th request must be blocked");
|
assert!(
|
||||||
|
rl.check_with_retry("k", 3, MIN).is_err(),
|
||||||
|
"the 4th request must be blocked"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_are_independent() {
|
fn keys_are_independent() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("a", 1, MIN));
|
assert!(rl.check_with_retry("a", 1, MIN).is_ok());
|
||||||
assert!(!rl.check("a", 1, MIN));
|
assert!(rl.check_with_retry("a", 1, MIN).is_err());
|
||||||
assert!(rl.check("b", 1, MIN), "a different key has its own window");
|
assert!(
|
||||||
|
rl.check_with_retry("b", 1, MIN).is_ok(),
|
||||||
|
"a different key has its own window"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_slides_and_allows_again_after_expiry() {
|
fn window_slides_and_allows_again_after_expiry() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
let w = Duration::from_millis(40);
|
let w = Duration::from_millis(40);
|
||||||
assert!(rl.check("k", 1, w));
|
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||||
assert!(!rl.check("k", 1, w));
|
assert!(rl.check_with_retry("k", 1, w).is_err());
|
||||||
std::thread::sleep(Duration::from_millis(55));
|
std::thread::sleep(Duration::from_millis(55));
|
||||||
assert!(
|
assert!(
|
||||||
rl.check("k", 1, w),
|
rl.check_with_retry("k", 1, w).is_ok(),
|
||||||
"the slot should expire once the window passes"
|
"the slot should expire once the window passes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,10 +203,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn clear_resets_every_window() {
|
fn clear_resets_every_window() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("k", 1, MIN));
|
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
||||||
assert!(!rl.check("k", 1, MIN));
|
assert!(rl.check_with_retry("k", 1, MIN).is_err());
|
||||||
rl.clear();
|
rl.clear();
|
||||||
assert!(rl.check("k", 1, MIN), "clear() must free the window");
|
assert!(
|
||||||
|
rl.check_with_retry("k", 1, MIN).is_ok(),
|
||||||
|
"clear() must free the window"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `prune()` is a memory-leak guard: without it a long-lived process keeps one HashMap
|
/// `prune()` is a memory-leak guard: without it a long-lived process keeps one HashMap
|
||||||
@@ -216,7 +231,7 @@ mod tests {
|
|||||||
.insert("stale".to_string(), vec![ancient]);
|
.insert("stale".to_string(), vec![ancient]);
|
||||||
|
|
||||||
// ...alongside a key that is still inside its window.
|
// ...alongside a key that is still inside its window.
|
||||||
assert!(rl.check("live", 5, MIN));
|
assert!(rl.check_with_retry("live", 5, MIN).is_ok());
|
||||||
assert_eq!(rl.windows.lock().unwrap().len(), 2);
|
assert_eq!(rl.windows.lock().unwrap().len(), 2);
|
||||||
|
|
||||||
rl.prune();
|
rl.prune();
|
||||||
@@ -239,13 +254,13 @@ mod tests {
|
|||||||
// prune() dropped live keys, every background sweep would hand attackers a fresh
|
// prune() dropped live keys, every background sweep would hand attackers a fresh
|
||||||
// budget.
|
// budget.
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
assert!(rl.check("k", 1, MIN));
|
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
||||||
assert!(!rl.check("k", 1, MIN));
|
assert!(rl.check_with_retry("k", 1, MIN).is_err());
|
||||||
|
|
||||||
rl.prune();
|
rl.prune();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!rl.check("k", 1, MIN),
|
rl.check_with_retry("k", 1, MIN).is_err(),
|
||||||
"prune() must not clear a window that is still active"
|
"prune() must not clear a window that is still active"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -255,3 +255,91 @@ pub async fn downloadable(pool: &PgPool, event_id: Uuid, export_type: &str) -> O
|
|||||||
.expect("downloadable")
|
.expect("downloadable")
|
||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert an upload of `size` bytes, optionally already soft-deleted.
|
||||||
|
pub async fn seed_upload(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
size: i64,
|
||||||
|
deleted: bool,
|
||||||
|
) -> Uuid {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type,
|
||||||
|
original_size_bytes, deleted_at)
|
||||||
|
VALUES ($1, $2, 'originals/x.jpg', 'image/jpeg', $3,
|
||||||
|
CASE WHEN $4 THEN NOW() ELSE NULL END)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(size)
|
||||||
|
.bind(deleted)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip the moderation flags a ban sets.
|
||||||
|
pub async fn set_user_moderation(pool: &PgPool, user_id: Uuid, banned: bool, hidden: bool) {
|
||||||
|
sqlx::query("UPDATE \"user\" SET is_banned = $2, uploads_hidden = $3 WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(banned)
|
||||||
|
.bind(hidden)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("set moderation");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `services/export.rs::query_uploads` — the visibility filter, verbatim, projected down to
|
||||||
|
/// `(id, original_size_bytes)`. This is the row set that ACTUALLY lands in the archives.
|
||||||
|
///
|
||||||
|
/// Production builds this WHERE from `export_visibility_where!()`, shared with
|
||||||
|
/// `estimate_export_bytes`. A copy here can pin the behaviour but CANNOT detect production moving
|
||||||
|
/// away from it — that is what sharing the fragment is for, not this.
|
||||||
|
pub async fn export_visible_uploads(pool: &PgPool, event_id: Uuid) -> Vec<(Uuid, i64)> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT u.id, u.original_size_bytes
|
||||||
|
FROM upload u
|
||||||
|
JOIN \"user\" usr ON usr.id = u.user_id
|
||||||
|
WHERE u.event_id = $1 AND u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE
|
||||||
|
GROUP BY u.id, usr.display_name
|
||||||
|
ORDER BY u.created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.expect("export_visible_uploads")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `services/export.rs::estimate_export_bytes` — verbatim. Same caveat as above: production
|
||||||
|
/// shares its WHERE with `query_uploads` via `export_visibility_where!()`, so these two copies
|
||||||
|
/// agreeing proves the behaviour, not the absence of drift.
|
||||||
|
pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> i64 {
|
||||||
|
let (bytes,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint
|
||||||
|
FROM upload u
|
||||||
|
JOIN \"user\" usr ON usr.id = u.user_id
|
||||||
|
WHERE u.event_id = $1 AND u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("estimate_export_bytes");
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `services/export.rs::ensure_export_space` — the armed-job count, verbatim.
|
||||||
|
pub async fn armed_job_count(pool: &PgPool, event_id: Uuid) -> i64 {
|
||||||
|
let (n,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM export_job
|
||||||
|
WHERE event_id = $1 AND status IN ('pending', 'running')",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("armed_job_count");
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|||||||
163
backend/tests/export_preflight.rs
Normal file
163
backend/tests/export_preflight.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
//! DB-backed tests for the export disk preflight.
|
||||||
|
//!
|
||||||
|
//! The keepsake used to be built with NO free-space check at all, and the failure that produced was
|
||||||
|
//! not "the export failed" but "the deliverable is stuck and the escape hatch needs the space that
|
||||||
|
//! isn't there":
|
||||||
|
//!
|
||||||
|
//! 1. A takedown bumps the epoch and re-arms both halves.
|
||||||
|
//! 2. The ZIP hits ENOSPC partway through a multi-GB write.
|
||||||
|
//! 3. The job row is now `failed` at the CURRENT epoch, so readiness
|
||||||
|
//! (`epoch = event.export_epoch AND status = 'done'`) is false and `GET /export/zip` 404s —
|
||||||
|
//! while the last good archive sits on disk, unreferenced and unreachable.
|
||||||
|
//! 4. `POST /host/export/rebuild` re-arms the same doomed write.
|
||||||
|
//!
|
||||||
|
//! Two changes close it: reclaim the superseded generation BEFORE building (so peak usage is one
|
||||||
|
//! generation, not two) and refuse up front with a number the host can act on.
|
||||||
|
//!
|
||||||
|
//! What these tests pin is the ESTIMATE — the part that decides. The arithmetic on top of it lives
|
||||||
|
//! in `services/export.rs`'s unit tests; the filesystem selection lives in `is_superseded_archive`.
|
||||||
|
//!
|
||||||
|
//! ON DRIFT, precisely, because it is easy to overclaim here. The hazard is that `query_uploads`
|
||||||
|
//! (which selects the rows the archives are built from) and `estimate_export_bytes` (which sizes
|
||||||
|
//! them) could disagree — and an estimate missing rows the archive writes UNDER-reserves, the one
|
||||||
|
//! direction that reintroduces the ENOSPC. **These tests cannot catch that**, and neither can any
|
||||||
|
//! test in this harness: both sides here are `SRC:`-marked hand-copies in `tests/common/mod.rs`,
|
||||||
|
//! so if production moved and the copies didn't, they would sit still and keep passing.
|
||||||
|
//!
|
||||||
|
//! That is fixed where it can be — the two queries now share one `export_visibility_where!()`
|
||||||
|
//! fragment in `services/export.rs`, so they cannot diverge by construction. What is left for
|
||||||
|
//! these tests is what the convention is genuinely good at: pinning the BEHAVIOUR, so a change
|
||||||
|
//! that deliberately alters the filter has to come here and say so.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// The estimate must equal the sum over EXACTLY the rows `query_uploads` returns — computed from
|
||||||
|
/// that row set, not from a restatement of its WHERE clause.
|
||||||
|
///
|
||||||
|
/// PINS: which uploads the preflight is allowed to count. Each excluded row below is excluded by a
|
||||||
|
/// DIFFERENT predicate, so a change that drops or weakens any one of them fails here and has to be
|
||||||
|
/// argued for. (It does not detect production drifting away from these copies — see the file
|
||||||
|
/// header; `export_visibility_where!()` is what makes that impossible.)
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_estimate_sums_exactly_the_rows_the_archive_will_contain(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
|
||||||
|
let visible = seed_user(&pool, event_id, "Anna").await;
|
||||||
|
let banned = seed_user(&pool, event_id, "Ben").await;
|
||||||
|
let hidden = seed_user(&pool, event_id, "Cara").await;
|
||||||
|
|
||||||
|
seed_upload(&pool, event_id, visible, 1_000, false).await;
|
||||||
|
seed_upload(&pool, event_id, visible, 2_500, false).await;
|
||||||
|
// Each of these is excluded from the archive by a DIFFERENT predicate.
|
||||||
|
seed_upload(&pool, event_id, visible, 9_000, true).await; // soft-deleted
|
||||||
|
seed_upload(&pool, event_id, banned, 9_000, false).await; // uploader banned
|
||||||
|
seed_upload(&pool, event_id, hidden, 9_000, false).await; // uploads hidden
|
||||||
|
|
||||||
|
set_user_moderation(&pool, banned, true, true).await;
|
||||||
|
set_user_moderation(&pool, hidden, false, true).await;
|
||||||
|
|
||||||
|
let rows = export_visible_uploads(&pool, event_id).await;
|
||||||
|
let expected: i64 = rows.iter().map(|(_, bytes)| bytes).sum();
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 2, "only Anna's two live uploads are archived");
|
||||||
|
assert_eq!(
|
||||||
|
estimate_export_bytes(&pool, event_id).await,
|
||||||
|
expected,
|
||||||
|
"the preflight must size the gallery the export will actually write"
|
||||||
|
);
|
||||||
|
assert_eq!(expected, 3_500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An event with nothing to archive estimates zero rather than NULL.
|
||||||
|
///
|
||||||
|
/// PREVENTS: `SUM()` over no rows returning NULL and the decode blowing up — which would abort the
|
||||||
|
/// export with a type error instead of building an (entirely legitimate) empty keepsake.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn an_empty_gallery_estimates_zero_not_null(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
assert_eq!(estimate_export_bytes(&pool, event_id).await, 0);
|
||||||
|
|
||||||
|
// And with a user who has uploaded nothing.
|
||||||
|
seed_user(&pool, event_id, "Anna").await;
|
||||||
|
assert_eq!(estimate_export_bytes(&pool, event_id).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release arms both halves, so the preflight sees a count of 2 and reserves for the pair.
|
||||||
|
///
|
||||||
|
/// PREVENTS: the concurrency under-reservation. `spawn_export_jobs` starts the ZIP and HTML workers
|
||||||
|
/// at the same instant, and BOTH are gallery-sized (`Memories.zip` streams the original for every
|
||||||
|
/// video and every image at or under 5 MB, all `Compression::Stored`). A worker reserving only for
|
||||||
|
/// itself would see "it fits", its sibling would independently see the same, and together they
|
||||||
|
/// would ENOSPC — which is why `required_free_bytes` multiplies by this count.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_release_arms_both_halves_so_the_preflight_reserves_for_two(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user = seed_user(&pool, event_id, "Anna").await;
|
||||||
|
seed_upload(&pool, event_id, user, 1_000, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
0,
|
||||||
|
"nothing is armed before the release"
|
||||||
|
);
|
||||||
|
|
||||||
|
let epoch = release_gallery(&pool, "wedding").await.expect("released");
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
2,
|
||||||
|
"a release arms zip AND html — both compete for the same disk"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A worker that has claimed its half is still competing; `running` must keep counting.
|
||||||
|
assert!(claim_job(&pool, event_id, "zip", epoch).await);
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
2,
|
||||||
|
"claiming moves pending -> running, which must not drop out of the reservation"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only a FINISHED half stops competing.
|
||||||
|
assert!(finalize_job(&pool, event_id, "zip", epoch, "exports/Gallery.zip").await);
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
1,
|
||||||
|
"a done half no longer needs space reserved for it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ViewerOnly regeneration re-arms only the HTML half, so the preflight reserves for one.
|
||||||
|
///
|
||||||
|
/// PREVENTS: over-reservation refusing a rebuild that fits perfectly well. Moderating a comment
|
||||||
|
/// carries the finished ZIP forward untouched; demanding room for a second copy of it would fail
|
||||||
|
/// the one operation that needs no new gallery-sized write at all.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_viewer_only_regeneration_reserves_for_one_half(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user = seed_user(&pool, event_id, "Anna").await;
|
||||||
|
seed_upload(&pool, event_id, user, 1_000, false).await;
|
||||||
|
|
||||||
|
let epoch = release_gallery(&pool, "wedding").await.expect("released");
|
||||||
|
for t in ["zip", "html"] {
|
||||||
|
assert!(claim_job(&pool, event_id, t, epoch).await);
|
||||||
|
assert!(finalize_job(&pool, event_id, t, epoch, &format!("exports/{t}")).await);
|
||||||
|
}
|
||||||
|
assert_eq!(armed_job_count(&pool, event_id).await, 0);
|
||||||
|
|
||||||
|
// A moderated comment: bump the epoch, carry the ZIP forward, re-arm only the viewer.
|
||||||
|
let (_, _, next) = bump_epoch(&pool, "wedding").await.expect("bumped");
|
||||||
|
assert!(
|
||||||
|
carry_zip_forward(&pool, event_id, next).await,
|
||||||
|
"the finished ZIP is re-stamped, not rebuilt"
|
||||||
|
);
|
||||||
|
let mut conn = pool.acquire().await.expect("acquire");
|
||||||
|
enqueue_types_at_epoch(&mut conn, event_id, next, &["html"]).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
armed_job_count(&pool, event_id).await,
|
||||||
|
1,
|
||||||
|
"only the viewer is being rebuilt, so only one archive's worth of space is needed"
|
||||||
|
);
|
||||||
|
}
|
||||||
352
backend/tests/failed_original_sweep.rs
Normal file
352
backend/tests/failed_original_sweep.rs
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
//! DB-backed tests for the deleted-media sweep (`services/maintenance.rs`).
|
||||||
|
//!
|
||||||
|
//! Context, in two halves.
|
||||||
|
//!
|
||||||
|
//! The compression worker deliberately no longer deletes an upload's original when its transcode
|
||||||
|
//! fails — a transient ENOSPC or a codec panic must never destroy the only copy of a photo a guest
|
||||||
|
//! cannot retake. But the row is soft-deleted and the uploader's quota IS refunded, so those bytes
|
||||||
|
//! become invisible, unowned and free.
|
||||||
|
//!
|
||||||
|
//! The SAME hole was reachable by the ordinary path, and that one is not an edge case at all:
|
||||||
|
//! `soft_delete_in_event` refunds `total_upload_bytes` on every guest or host delete and nothing
|
||||||
|
//! removed the files, so the quota stopped bounding the disk. Upload 500 MB, delete, quota back to
|
||||||
|
//! zero, upload another 500 MB — a guest curating their camera roll, which is what people do. The
|
||||||
|
//! sweep used to reach only `compression_status = 'failed'`, so it never touched this case; the
|
||||||
|
//! test below that now asserts an owner-deleted upload IS reclaimed is the one that used to assert
|
||||||
|
//! the opposite.
|
||||||
|
//!
|
||||||
|
//! Two windows, because the two deletes mean different things: 14 days for a failure an operator
|
||||||
|
//! may want to investigate, 24 hours for a removal someone asked for (14 days outlives the whole
|
||||||
|
//! event, so a deliberate delete would never reclaim anything while it mattered).
|
||||||
|
//!
|
||||||
|
//! The selection predicate is the whole safety argument — it must reach both leftovers and never a
|
||||||
|
//! live upload — so that is what these pin, following the same "reproduce the SQL verbatim" pattern
|
||||||
|
//! as `upload_concurrency.rs`. `#[sqlx::test]` gives each test a fresh, migrated database.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const FAILED_DAYS: i64 = 14;
|
||||||
|
const DELETED_HOURS: i64 = 24;
|
||||||
|
|
||||||
|
/// SRC: `services/maintenance.rs::cleanup_deleted_media` — the selection, verbatim.
|
||||||
|
async fn sweep_selects(pool: &PgPool, failed_days: i64, deleted_hours: i64) -> Vec<Uuid> {
|
||||||
|
type Row = (Uuid, String, Option<String>, Option<String>, Option<String>);
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
|
||||||
|
WHERE deleted_at IS NOT NULL
|
||||||
|
AND CASE WHEN compression_status = 'failed'
|
||||||
|
THEN deleted_at < NOW() - ($1 || ' days')::interval
|
||||||
|
ELSE deleted_at < NOW() - ($2 || ' hours')::interval
|
||||||
|
END
|
||||||
|
AND (original_path <> '' OR preview_path IS NOT NULL
|
||||||
|
OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
|
||||||
|
)
|
||||||
|
.bind(failed_days.to_string())
|
||||||
|
.bind(deleted_hours.to_string())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.expect("sweep query")
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, ..)| id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed an upload aged `deleted_hours_ago` (None = live), with optional derivative paths.
|
||||||
|
async fn seed_aged_upload(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
deleted_hours_ago: Option<i64>,
|
||||||
|
original_path: &str,
|
||||||
|
derivatives: bool,
|
||||||
|
) -> Uuid {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes,
|
||||||
|
compression_status, deleted_at,
|
||||||
|
preview_path, display_path, thumbnail_path)
|
||||||
|
VALUES ($1, $2, $3, 'image/jpeg', 1000, $4,
|
||||||
|
CASE WHEN $5::bigint IS NULL THEN NULL
|
||||||
|
ELSE NOW() - ($5::text || ' hours')::interval END,
|
||||||
|
CASE WHEN $6 THEN 'previews/p.jpg' END,
|
||||||
|
CASE WHEN $6 THEN 'displays/d.jpg' END,
|
||||||
|
CASE WHEN $6 THEN 'thumbs/t.jpg' END)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(original_path)
|
||||||
|
.bind(status)
|
||||||
|
.bind(deleted_hours_ago)
|
||||||
|
.bind(derivatives)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live upload is untouchable no matter how the windows are configured.
|
||||||
|
///
|
||||||
|
/// PREVENTS: the catastrophic loosening. Everything else here is about reclaiming more; this is the
|
||||||
|
/// one assertion that must never bend.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_live_upload_is_never_selected(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-live").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Sweeper").await;
|
||||||
|
|
||||||
|
for status in ["done", "failed", "processing", "pending"] {
|
||||||
|
let live = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
None,
|
||||||
|
"originals/e/live.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
!sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
|
||||||
|
.await
|
||||||
|
.contains(&live),
|
||||||
|
"a non-deleted upload with status {status} must never be swept"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE FIX. An upload a guest or host deliberately deleted is reclaimed once past 24 hours.
|
||||||
|
///
|
||||||
|
/// PREVENTS: the regression back to a sweep scoped to `compression_status = 'failed'`, which is
|
||||||
|
/// what let the quota stop bounding the disk. This assertion is the inverse of the one this file
|
||||||
|
/// used to make.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_deliberately_deleted_upload_is_reclaimed_after_a_day(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-deleted").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Curator").await;
|
||||||
|
|
||||||
|
let deleted = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/owner.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Still inside the window — a mis-tap is recoverable for a day.
|
||||||
|
let recent = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(2),
|
||||||
|
"originals/e/recent.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
|
||||||
|
assert!(
|
||||||
|
selected.contains(&deleted),
|
||||||
|
"a deliberate delete past the window must be reclaimed — this is the leak"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!selected.contains(&recent),
|
||||||
|
"a delete inside the window keeps its recovery grace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two windows are independent: a failure is retained far longer than a deliberate delete.
|
||||||
|
///
|
||||||
|
/// PREVENTS: collapsing them into one. Applying 24h to failures would destroy the recovery window
|
||||||
|
/// the retained-original fix exists to provide; applying 14 days to deliberate deletes would mean
|
||||||
|
/// nothing is ever reclaimed during an event.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_two_retention_windows_do_not_bleed_into_each_other(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-windows").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Windows").await;
|
||||||
|
|
||||||
|
// 48h old: past the deliberate window, nowhere near the failure window.
|
||||||
|
let failed_recent = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"failed",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/f-recent.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let deleted_same_age = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/d-same.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// 30 days old: past both.
|
||||||
|
let failed_old = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"failed",
|
||||||
|
Some(30 * 24),
|
||||||
|
"originals/e/f-old.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
|
||||||
|
assert!(
|
||||||
|
!selected.contains(&failed_recent),
|
||||||
|
"a 2-day-old compression failure is still inside its 14-day recovery window"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
selected.contains(&deleted_same_age),
|
||||||
|
"a deliberate delete of the same age is past its 24-hour window"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
selected.contains(&failed_old),
|
||||||
|
"a 30-day-old failure is past both windows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boundary behaviour on both windows.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn retention_windows_are_honoured_at_the_boundary(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-boundary").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Boundary").await;
|
||||||
|
|
||||||
|
let cases = [
|
||||||
|
("failed", 13 * 24, false, "13 days"),
|
||||||
|
("failed", 15 * 24, true, "15 days"),
|
||||||
|
("done", 23, false, "23 hours"),
|
||||||
|
("done", 25, true, "25 hours"),
|
||||||
|
];
|
||||||
|
for (status, hours, expected, label) in cases {
|
||||||
|
let id = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
Some(hours),
|
||||||
|
"originals/e/b.jpg",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
|
||||||
|
.await
|
||||||
|
.contains(&id),
|
||||||
|
expected,
|
||||||
|
"a {status} upload deleted {label} ago: expected swept={expected}"
|
||||||
|
);
|
||||||
|
sqlx::query("DELETE FROM upload WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clean up");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row is re-selected until EVERY one of its paths is cleared.
|
||||||
|
///
|
||||||
|
/// PREVENTS: two failures at once. The sweep used to clear `original_path` alone, which was right
|
||||||
|
/// for its only case (a failed compression produces no derivatives) but leaves preview, display and
|
||||||
|
/// thumbnail on disk the moment it reaches a successfully processed upload — three files per
|
||||||
|
/// upload, none of them counted in `original_size_bytes`, that nothing else ever removes. And a row
|
||||||
|
/// whose paths are all cleared must stop coming back, or every hourly tick logs a phantom reclaim
|
||||||
|
/// forever.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_row_is_reselected_until_every_path_is_cleared(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-idempotent").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Idem").await;
|
||||||
|
let id = seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/once.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await, [id]);
|
||||||
|
|
||||||
|
// Clearing only the original is NOT enough — the derivatives are still on disk.
|
||||||
|
sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clear original");
|
||||||
|
assert_eq!(
|
||||||
|
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await,
|
||||||
|
[id],
|
||||||
|
"derivatives left behind must keep the row selected"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE upload SET preview_path = NULL, display_path = NULL, thumbnail_path = NULL
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clear derivatives");
|
||||||
|
assert!(
|
||||||
|
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
|
||||||
|
.await
|
||||||
|
.is_empty(),
|
||||||
|
"a fully swept row must not come back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The derivative backfill must never resurrect what the sweep just reclaimed.
|
||||||
|
///
|
||||||
|
/// PREVENTS: an interaction, not a bug in either piece. The sweep nulls `preview_path`, and
|
||||||
|
/// `backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT NULL` —
|
||||||
|
/// close enough that a future edit to either could have the backfill re-decode an original that is
|
||||||
|
/// no longer on disk, on every boot. `deleted_at IS NULL` is what keeps them apart.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_backfill_ignores_swept_rows(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "sweep-backfill").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Backfill").await;
|
||||||
|
seed_aged_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"done",
|
||||||
|
Some(48),
|
||||||
|
"originals/e/gone.jpg",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// SRC: `services/compression.rs::backfill_stale_derivatives` — the selection, verbatim.
|
||||||
|
let backfilled: Vec<(Uuid, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, original_path, mime_type FROM upload
|
||||||
|
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||||
|
AND original_path IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||||
|
OR derivatives_rev < $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(1i16)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.expect("backfill query");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
backfilled.is_empty(),
|
||||||
|
"a soft-deleted row must be invisible to the backfill, before or after sweeping"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,3 +11,31 @@ services:
|
|||||||
# is tolerated (warned) rather than rejected.
|
# is tolerated (warned) rather than rejected.
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: development
|
APP_ENV: development
|
||||||
|
# `.env` sets DATABASE_URL to @localhost for the run-backend-natively workflow
|
||||||
|
# (the db port is published above for that). When the app runs IN a container,
|
||||||
|
# localhost is the app itself — point it at the `db` service instead. Creds are
|
||||||
|
# interpolated from .env so nothing is hardcoded.
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
# `.env` sets MEDIA_PATH to a HOST path (/home/fabi/EventSnap/media) for the
|
||||||
|
# run-backend-natively workflow. In a container that path doesn't exist and the
|
||||||
|
# app user can't create it from `/`, so every upload 500s with EACCES. The media
|
||||||
|
# volume is mounted at /media (see docker-compose.yml) — point the app there.
|
||||||
|
MEDIA_PATH: /media
|
||||||
|
# Recent Docker Compose interpolates env_file values, so the `$` segments of the
|
||||||
|
# bcrypt ADMIN_PASSWORD_HASH in .env get eaten (the salt reads as an unset var) —
|
||||||
|
# every admin login then 401s. Re-supply it here with `$` doubled to `$$` so Compose
|
||||||
|
# passes the literal hash. NOTE: production (docker-compose.yml + .env) has the SAME
|
||||||
|
# bug — escape the hash as `$$` in .env, or set it via `environment:` there too.
|
||||||
|
ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye"
|
||||||
|
# Smoke-testing the comment kill-switch: boot-time flag, so it needs a restart
|
||||||
|
# (not an admin-UI toggle). Backend rejects new comments (403) and the frontend
|
||||||
|
# hides the whole comment UI. Flip back to true (or drop this line) to restore.
|
||||||
|
COMMENTS_ENABLED: "false"
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
# The prod caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` expands
|
||||||
|
# to empty and the site block collapses into a malformed global block. Supply it
|
||||||
|
# for local dev (from .env → localhost, which Caddy serves with a local self-signed
|
||||||
|
# cert). NOTE: the prod compose likely needs DOMAIN wired to caddy too.
|
||||||
|
environment:
|
||||||
|
DOMAIN: ${DOMAIN}
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
# 1G, not 512M. DATABASE_MAX_CONNECTIONS defaults to 30 for a ~100-guest event
|
||||||
|
# (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's
|
||||||
|
# default shared_buffers leaves very little headroom at 512M. An OOM here does
|
||||||
|
# not degrade one feature — it takes the event down, because every request
|
||||||
|
# path touches the database. Memory is the cheaper knob than shrinking the
|
||||||
|
# pool back and reintroducing the queueing it was raised to fix.
|
||||||
|
#
|
||||||
|
# Raising DATABASE_MAX_CONNECTIONS further means raising this too.
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -29,6 +37,12 @@ services:
|
|||||||
# Activates the production secret guard in config.rs — refuses to boot with
|
# Activates the production secret guard in config.rs — refuses to boot with
|
||||||
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
|
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
|
# The media volume is mounted at /media (below), so the app MUST write there.
|
||||||
|
# Pin it here rather than trusting .env: if MEDIA_PATH in .env points elsewhere
|
||||||
|
# (e.g. a host path used for running the backend natively) the container can't
|
||||||
|
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
|
||||||
|
# so this is authoritative for the container.
|
||||||
|
MEDIA_PATH: /media
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -40,7 +54,11 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/health || exit 1"]
|
# Use 127.0.0.1, NOT localhost: the app binds IPv4 (0.0.0.0) but `localhost`
|
||||||
|
# resolves to ::1 (IPv6) first inside the container, so a localhost probe gets
|
||||||
|
# "connection refused" and the container never turns healthy — which would leave
|
||||||
|
# Caddy (gated on `condition: service_healthy` below) blocked forever on boot.
|
||||||
|
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3000/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -67,7 +85,9 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3001"
|
- "3001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:3001/ >/dev/null 2>&1 || exit 1"]
|
# 127.0.0.1, not localhost — see the app healthcheck note above (IPv4 bind vs
|
||||||
|
# ::1 resolution would leave this container permanently unhealthy).
|
||||||
|
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3001/ >/dev/null 2>&1 || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -80,6 +100,12 @@ services:
|
|||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# The Caddyfile's site address is `{$DOMAIN}`, read from THIS container's env.
|
||||||
|
# Without it, `{$DOMAIN}` expands to empty, the site block collapses, and Caddy
|
||||||
|
# serves nothing / fails to obtain a TLS cert. `env_file` alone wouldn't help —
|
||||||
|
# Caddy needs it in `environment`, and this keeps the Caddyfile the single source.
|
||||||
|
DOMAIN: ${DOMAIN}
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
@@ -12,10 +12,17 @@
|
|||||||
# Mirror prod's security headers (minus HSTS, which is HTTPS-only).
|
# Mirror prod's security headers (minus HSTS, which is HTTPS-only).
|
||||||
header {
|
header {
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mirror prod's export carve-out: the keepsake download targets a hidden
|
||||||
|
# same-origin iframe, and WebKit enforces XFO before Content-Disposition.
|
||||||
|
# Two disjoint matchers, not an override — see the comment in ../Caddyfile.
|
||||||
|
@framable path /api/v1/export/zip /api/v1/export/html
|
||||||
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
||||||
|
header @framable X-Frame-Options "SAMEORIGIN"
|
||||||
|
header @not_framable X-Frame-Options "DENY"
|
||||||
|
|
||||||
reverse_proxy /api/* app:3000
|
reverse_proxy /api/* app:3000
|
||||||
reverse_proxy /media/* app:3000
|
reverse_proxy /media/* app:3000
|
||||||
reverse_proxy /health app:3000
|
reverse_proxy /health app:3000
|
||||||
|
|||||||
@@ -42,11 +42,29 @@ services:
|
|||||||
EVENT_NAME: E2E Test Event
|
EVENT_NAME: E2E Test Event
|
||||||
APP_PORT: '3000'
|
APP_PORT: '3000'
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
|
# Exports MUST live outside MEDIA_PATH — see the note on the volume below and
|
||||||
|
# config.rs::validate. Omitting this left exports on the container's writable
|
||||||
|
# layer at the /exports default, so the test stack diverged from the prod layout
|
||||||
|
# it claims to mirror, and export-leak/export-video wrote real archives into
|
||||||
|
# ephemeral storage.
|
||||||
|
EXPORT_PATH: /exports
|
||||||
SESSION_EXPIRY_DAYS: '30'
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
||||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/media
|
- media_data:/media
|
||||||
|
# Separate volume, exactly as in production: a keepsake archive contains every
|
||||||
|
# photo in the event, so it is kept off the media tree.
|
||||||
|
- exports_data:/exports
|
||||||
|
# Mirror production's cap (docker-compose.yml). The test stack having NO memory limit is
|
||||||
|
# why an unbounded image decode was invisible here: a 99 MP upload that would OOM-kill the
|
||||||
|
# 1 GiB production container simply succeeded in CI. A test environment more generous than
|
||||||
|
# production cannot catch a resource bug — the same shape as WebKit being absent from CI
|
||||||
|
# and /health existing only in Caddyfile.test.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
expose:
|
expose:
|
||||||
- '3000'
|
- '3000'
|
||||||
|
|
||||||
@@ -75,3 +93,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media_data:
|
media_data:
|
||||||
|
exports_data:
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ export const db = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async compressionStatus(uploadId: string): Promise<string | null> {
|
||||||
|
return withClient(async (c) => {
|
||||||
|
const r = await c.query<{ compression_status: string }>(
|
||||||
|
`SELECT compression_status FROM upload WHERE id = $1`,
|
||||||
|
[uploadId]
|
||||||
|
);
|
||||||
|
return r.rows[0]?.compression_status ?? null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Which revision of the derivative pipeline produced this row's preview/display. */
|
||||||
|
async derivativesRev(uploadId: string): Promise<number | null> {
|
||||||
|
return withClient(async (c) => {
|
||||||
|
const r = await c.query<{ derivatives_rev: number }>(
|
||||||
|
`SELECT derivatives_rev FROM upload WHERE id = $1`,
|
||||||
|
[uploadId]
|
||||||
|
);
|
||||||
|
return r.rows[0]?.derivatives_rev ?? null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async countUploadsForUser(userId: string): Promise<number> {
|
async countUploadsForUser(userId: string): Promise<number> {
|
||||||
return withClient(async (c) => {
|
return withClient(async (c) => {
|
||||||
const r = await c.query<{ count: string }>(
|
const r = await c.query<{ count: string }>(
|
||||||
@@ -84,6 +105,20 @@ export const db = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overstate an upload's recorded size.
|
||||||
|
*
|
||||||
|
* The keepsake size estimate and the low-disk threshold are pure SQL over
|
||||||
|
* `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake
|
||||||
|
* would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the
|
||||||
|
* accounting the warning reads from moves.
|
||||||
|
*/
|
||||||
|
async setUploadSizeBytes(uploadId: string, bytes: number) {
|
||||||
|
await withClient((c) =>
|
||||||
|
c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
async setExportReleased(slug: string, released: boolean) {
|
async setExportReleased(slug: string, released: boolean) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
||||||
@@ -121,7 +156,8 @@ export const db = {
|
|||||||
async fakeExportJob(
|
async fakeExportJob(
|
||||||
eventSlug: string,
|
eventSlug: string,
|
||||||
type: 'zip' | 'html',
|
type: 'zip' | 'html',
|
||||||
status: 'pending' | 'running' | 'done'
|
status: 'pending' | 'running' | 'done' | 'failed',
|
||||||
|
errorMessage: string | null = null
|
||||||
) {
|
) {
|
||||||
await withClient(async (c) => {
|
await withClient(async (c) => {
|
||||||
const ev = await c.query<{ id: string; export_epoch: string }>(
|
const ev = await c.query<{ id: string; export_epoch: string }>(
|
||||||
@@ -130,11 +166,12 @@ export const db = {
|
|||||||
);
|
);
|
||||||
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
||||||
await c.query(
|
await c.query(
|
||||||
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch)
|
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch,
|
||||||
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6)
|
error_message)
|
||||||
|
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6, $7)
|
||||||
ON CONFLICT (event_id, type) DO UPDATE
|
ON CONFLICT (event_id, type) DO UPDATE
|
||||||
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
||||||
epoch = EXCLUDED.epoch`,
|
epoch = EXCLUDED.epoch, error_message = EXCLUDED.error_message`,
|
||||||
[
|
[
|
||||||
ev.rows[0].id,
|
ev.rows[0].id,
|
||||||
type,
|
type,
|
||||||
@@ -142,6 +179,7 @@ export const db = {
|
|||||||
status === 'done' ? 100 : 0,
|
status === 'done' ? 100 : 0,
|
||||||
status === 'done' ? new Date() : null,
|
status === 'done' ? new Date() : null,
|
||||||
ev.rows[0].export_epoch,
|
ev.rows[0].export_epoch,
|
||||||
|
errorMessage,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
29
e2e/helpers/webkit.ts
Normal file
29
e2e/helpers/webkit.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip a test that depends on persisting a Blob/File in IndexedDB when running on
|
||||||
|
* Playwright's WebKit.
|
||||||
|
*
|
||||||
|
* The client upload queue (`frontend/src/lib/upload-queue.ts`) stores the file itself in
|
||||||
|
* IndexedDB so a backgrounded or reloaded phone can resume the upload. Playwright's Linux
|
||||||
|
* WebKit build cannot store Blobs in IndexedDB at all — `put()` fails with
|
||||||
|
* "UnknownError: Error preparing Blob/File data to be stored in object store". Verified to
|
||||||
|
* be the harness, not the app: a Blob constructed in-page with `new Blob([bytes])` fails
|
||||||
|
* exactly the same way, while Chromium stores both that and a `setInputFiles` File fine.
|
||||||
|
* Real iOS Safari supports Blobs in IndexedDB, so this is NOT evidence of a bug on the
|
||||||
|
* platform these tests exist to protect.
|
||||||
|
*
|
||||||
|
* Any test that drives the composer (FAB → UploadSheet → /upload → submit) hits this,
|
||||||
|
* because `handleSubmit` awaits `addToQueue`, which throws before it can navigate.
|
||||||
|
*
|
||||||
|
* This is deliberately narrow. WebKit still runs every API-driven upload test, the whole of
|
||||||
|
* 01-auth, 03-feed and 06-export — including the keepsake download, which only WebKit can
|
||||||
|
* meaningfully verify. If Playwright's WebKit ever gains IndexedDB Blob support, delete this
|
||||||
|
* helper and the four call sites.
|
||||||
|
*/
|
||||||
|
export function skipIfNoIdbBlobs(browserName: string) {
|
||||||
|
test.skip(
|
||||||
|
browserName === 'webkit',
|
||||||
|
"Playwright's Linux WebKit cannot store Blobs in IndexedDB (harness limitation, not an iOS one) — the client upload queue can't be exercised there"
|
||||||
|
);
|
||||||
|
}
|
||||||
4
e2e/loadtest/.gitignore
vendored
Normal file
4
e2e/loadtest/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
results/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
112
e2e/loadtest/README.md
Normal file
112
e2e/loadtest/README.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# EventSnap load / stress test
|
||||||
|
|
||||||
|
Simulates a real event: **~100 guests** joining and uploading **~1000 images** in
|
||||||
|
bursts (10–20 at a time) spread across a compressed time window, a pool of
|
||||||
|
**viewers** holding live SSE connections, and **one real browser** on `/diashow`
|
||||||
|
acting as the showcase display.
|
||||||
|
|
||||||
|
## Goal (this harness is tuned for it)
|
||||||
|
|
||||||
|
**Validate the shipping config.** We run at the real production defaults —
|
||||||
|
compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB
|
||||||
|
pool (default **10**), quotas **on** — and answer: _does the app survive the
|
||||||
|
event, and how far behind real-time does the diashow fall?_
|
||||||
|
|
||||||
|
The headline metric is **pipeline latency**: time from an upload succeeding to
|
||||||
|
its preview being ready (`upload-processed` SSE event) — i.e. _how long until the
|
||||||
|
photo appears on the diashow_. A backlog that builds is fine; a backlog that
|
||||||
|
**never drains** is a fail for a live event.
|
||||||
|
|
||||||
|
## Methodology: what we change vs. shipping
|
||||||
|
|
||||||
|
We **only disable rate limits**. They're per-IP / per-user anti-abuse guards; a
|
||||||
|
synthetic test from one IP trips them in a way real guests (distinct IPs, phones)
|
||||||
|
never would — leaving them on would measure the limiter, not the pipeline.
|
||||||
|
Everything else (compression concurrency, DB pool, quotas) stays at the real
|
||||||
|
default so the result is honest.
|
||||||
|
|
||||||
|
> **Standalone finding to remember:** the shipping `upload_rate_per_hour` default
|
||||||
|
> is **10**. A real guest uploading a burst of 10–20 photos would be throttled by
|
||||||
|
> the shipping config too. That's a genuine event-day issue worth surfacing
|
||||||
|
> separately from this pipeline test.
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
- The isolated test stack up: `cd e2e && npm run stack:up` (Caddy on `:3101`,
|
||||||
|
`EVENTSNAP_TEST_MODE=1`, `/admin/__truncate` live).
|
||||||
|
- Node 24+ (global `fetch`/`FormData`/`Blob`), Python 3 + Pillow, Docker CLI
|
||||||
|
access (used for `docker stats` + `docker exec psql` ground-truth sampling).
|
||||||
|
- `@playwright/test` (already an e2e dep) for the diashow watcher.
|
||||||
|
|
||||||
|
## 1. Generate the image pool (once)
|
||||||
|
|
||||||
|
Realistic phone-sized JPEGs (~2–4 MB, 12 MP, high entropy). The driver reuses
|
||||||
|
this pool at random across all 1000 uploads — real load is byte size + decode
|
||||||
|
cost, not file uniqueness.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 e2e/loadtest/gen-images.py 40 # → /tmp/eventsnap-loadtest/photos
|
||||||
|
```
|
||||||
|
|
||||||
|
~40 images ≈ 120 MB pool; projects to **~3–4 GB** of originals for 1000 uploads
|
||||||
|
(previews/thumbnails add more). The generator prints the projection; check disk.
|
||||||
|
|
||||||
|
## 2. Smoke run first (~1 min)
|
||||||
|
|
||||||
|
Proves the wiring — join, upload, SSE correlation, drain, metrics — before the
|
||||||
|
real thing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LT_GUESTS=5 LT_IMAGES=50 LT_WINDOW_SEC=60 node e2e/loadtest/driver.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Full run (~15 min + drain)
|
||||||
|
|
||||||
|
Two terminals. Start the showcase display first, then the driver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal A — the showcase device
|
||||||
|
node e2e/loadtest/diashow-watch.mjs
|
||||||
|
|
||||||
|
# terminal B — 100 guests / 1000 images / 15-min window (defaults)
|
||||||
|
node e2e/loadtest/driver.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The driver truncates event data first (`LT_TRUNCATE=0` to keep), disables rate
|
||||||
|
limits, joins guests, opens SSE, runs the burst schedule, then **waits for the
|
||||||
|
compression backlog to drain** before reporting.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- Console: live progress every 10 s, then a RESULTS block with pass/fail flags.
|
||||||
|
- `e2e/loadtest/results/run-<timestamp>.json`: full metrics — upload latency
|
||||||
|
percentiles, pipeline latency percentiles, drain time, per-status counts, SSE
|
||||||
|
reconnect/resync counts, and a `docker stats` + DB-connection time series.
|
||||||
|
- `e2e/loadtest/results/diashow/`: periodic screenshots of the live display.
|
||||||
|
|
||||||
|
## What the flags mean
|
||||||
|
|
||||||
|
| Flag | Meaning |
|
||||||
|
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `✗ 5xx` | server errored under load — hard fail |
|
||||||
|
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size |
|
||||||
|
| `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up |
|
||||||
|
| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak |
|
||||||
|
| `⚠ SSE resyncs` | live consumers lagged the broadcast channel |
|
||||||
|
|
||||||
|
## Knobs
|
||||||
|
|
||||||
|
All via env (see header of `driver.mjs`): `LT_GUESTS`, `LT_IMAGES`,
|
||||||
|
`LT_WINDOW_SEC`, `LT_BURST_MIN/MAX`, `LT_BURST_CONC`, `LT_VIEWERS`,
|
||||||
|
`LT_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`,
|
||||||
|
`LT_APP_CONTAINER`, `LT_DB_CONTAINER`.
|
||||||
|
|
||||||
|
To later answer _"what config should I deploy?"_, re-run with a rebuilt stack
|
||||||
|
that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in
|
||||||
|
`docker-compose.test.yml`) and compare the pipeline-latency / drain numbers.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e && npm run stack:down # wipes volumes (media + db)
|
||||||
|
```
|
||||||
109
e2e/loadtest/confirm-diashow-fix.mjs
Normal file
109
e2e/loadtest/confirm-diashow-fix.mjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// End-to-end confirmation of the diashow SSE fix, reproducing the ORIGINAL failure:
|
||||||
|
// kiosk opens /diashow directly BEFORE any photos exist, then a guest uploads.
|
||||||
|
// Pre-fix: stream never opens, display stuck on "Noch keine Beiträge" forever.
|
||||||
|
// Post-fix: stream opens on mount, the uploaded photo appears live.
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3101';
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const j = (r) => r.json();
|
||||||
|
|
||||||
|
const adminLogin = () =>
|
||||||
|
fetch(`${BASE}/api/v1/admin/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: 'admin-test-pw' }),
|
||||||
|
})
|
||||||
|
.then(j)
|
||||||
|
.then((b) => b.jwt);
|
||||||
|
const join = (name) =>
|
||||||
|
fetch(`${BASE}/api/v1/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name }),
|
||||||
|
}).then(j);
|
||||||
|
async function truncate(admin) {
|
||||||
|
await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${admin}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function upload(jwt) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(
|
||||||
|
'file',
|
||||||
|
new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
}),
|
||||||
|
'live.jpg'
|
||||||
|
);
|
||||||
|
form.append('caption', 'LIVE-PROBE');
|
||||||
|
const r = await fetch(`${BASE}/api/v1/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the failing scenario: empty event, then open /diashow directly.
|
||||||
|
let admin = await adminLogin();
|
||||||
|
await truncate(admin);
|
||||||
|
admin = await adminLogin();
|
||||||
|
const showcase = await join('Showcase Display');
|
||||||
|
const guest = await join('Uploading Guest');
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const streamReqs = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.evaluate((g) => {
|
||||||
|
localStorage.setItem('eventsnap_jwt', g.jwt);
|
||||||
|
localStorage.setItem('eventsnap_pin', g.pin);
|
||||||
|
localStorage.setItem('eventsnap_user_id', g.user_id);
|
||||||
|
localStorage.setItem('eventsnap_display_name', 'Showcase');
|
||||||
|
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||||
|
}, showcase);
|
||||||
|
|
||||||
|
// Open /diashow DIRECTLY (never via /feed) on an EMPTY event.
|
||||||
|
await page.goto(`${BASE}/diashow`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await sleep(2500);
|
||||||
|
const emptyBefore = (await page.locator('text=Noch keine Beiträge').count()) > 0;
|
||||||
|
const streamOpened = streamReqs.length > 0;
|
||||||
|
console.log(`\n1) direct /diashow on empty event:`);
|
||||||
|
console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`);
|
||||||
|
console.log(
|
||||||
|
` SSE stream opened: ${streamOpened} ${streamOpened ? '(' + streamReqs.join(', ') + ')' : ''} (expected: true — this is the fix)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now a guest uploads a photo while the display is open.
|
||||||
|
console.log(`\n2) guest uploads a photo (display already open)…`);
|
||||||
|
await upload(guest.jwt);
|
||||||
|
|
||||||
|
// Wait for compression → upload-processed SSE → debounced refresh → slide appears.
|
||||||
|
let appeared = false;
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await sleep(1000);
|
||||||
|
const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0;
|
||||||
|
const imgs = await page.locator('img').count();
|
||||||
|
if (!stillEmpty && imgs > 0) {
|
||||||
|
appeared = true;
|
||||||
|
console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!appeared) console.log(` ✗ photo did NOT appear within 15s`);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'results/diashow-fix-confirm.png' });
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(`\n════ VERDICT ════`);
|
||||||
|
console.log(`SSE opens on direct /diashow: ${streamOpened ? 'YES ✓' : 'NO ✗'}`);
|
||||||
|
console.log(`Live photo appears on showcase: ${appeared ? 'YES ✓' : 'NO ✗'}`);
|
||||||
|
console.log(streamOpened && appeared ? 'FIX CONFIRMED' : 'FIX NOT confirmed');
|
||||||
105
e2e/loadtest/diashow-watch.mjs
Normal file
105
e2e/loadtest/diashow-watch.mjs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* The showcase display. Opens ONE real Chromium browser on /diashow (as a
|
||||||
|
* logged-in guest) for the duration of a load run, so we validate the real
|
||||||
|
* SSE → render path a projector/TV would use — not just the protocol.
|
||||||
|
*
|
||||||
|
* Run it alongside driver.mjs (separate terminal), started first:
|
||||||
|
* node diashow-watch.mjs
|
||||||
|
* # then in another terminal:
|
||||||
|
* node driver.mjs
|
||||||
|
*
|
||||||
|
* It captures:
|
||||||
|
* - periodic screenshots (so you can eyeball that new photos actually appear)
|
||||||
|
* - browser console errors / page crashes / SSE disconnects surfaced in console
|
||||||
|
* - a final count of slides rendered
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* LT_BASE http://localhost:3101
|
||||||
|
* LT_WATCH_SEC 960 how long to keep the display open
|
||||||
|
* LT_SHOT_EVERY_SEC 30 screenshot cadence
|
||||||
|
* LT_OUT_DIR ./results/diashow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium, devices } from '@playwright/test';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASE = process.env.LT_BASE ?? 'http://localhost:3101';
|
||||||
|
const WATCH_SEC = parseInt(process.env.LT_WATCH_SEC ?? '960', 10);
|
||||||
|
const SHOT_EVERY = parseInt(process.env.LT_SHOT_EVERY_SEC ?? '30', 10);
|
||||||
|
const OUT = process.env.LT_OUT_DIR ?? join(__dirname, 'results', 'diashow');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function joinGuest(name) {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name }),
|
||||||
|
});
|
||||||
|
if (res.status !== 201) throw new Error(`join failed ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const guest = await joinGuest('Showcase Display');
|
||||||
|
console.log('[diashow] joined showcase guest, launching browser…');
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
// A TV/projector is landscape; use a desktop-ish large viewport.
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
|
||||||
|
let consoleErrors = 0;
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
consoleErrors++;
|
||||||
|
console.log('[diashow][console.error]', msg.text().slice(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('pageerror', (e) => console.log('[diashow][pageerror]', String(e).slice(0, 200)));
|
||||||
|
page.on('crash', () => console.log('[diashow][CRASH] page crashed'));
|
||||||
|
|
||||||
|
// Seed auth in localStorage on the origin, then open /diashow.
|
||||||
|
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.evaluate((g) => {
|
||||||
|
localStorage.setItem('eventsnap_theme', 'dark');
|
||||||
|
localStorage.setItem('eventsnap_jwt', g.jwt);
|
||||||
|
localStorage.setItem('eventsnap_pin', g.pin);
|
||||||
|
localStorage.setItem('eventsnap_user_id', g.user_id);
|
||||||
|
localStorage.setItem('eventsnap_display_name', 'Showcase Display');
|
||||||
|
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||||
|
}, guest);
|
||||||
|
await page.goto(`${BASE}/diashow`, { waitUntil: 'domcontentloaded' });
|
||||||
|
console.log(`[diashow] on /diashow, watching for ${WATCH_SEC}s (shots every ${SHOT_EVERY}s)`);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let shot = 0;
|
||||||
|
while ((Date.now() - start) / 1000 < WATCH_SEC) {
|
||||||
|
await sleep(SHOT_EVERY * 1000);
|
||||||
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||||
|
// best-effort slide count (diashow renders <img>; adjust selector if markup changes)
|
||||||
|
let imgs = -1;
|
||||||
|
try {
|
||||||
|
imgs = await page.locator('img').count();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const path = join(OUT, `diashow-t${String(elapsed).padStart(4, '0')}.png`);
|
||||||
|
await page.screenshot({ path }).catch(() => {});
|
||||||
|
console.log(`[diashow][t+${elapsed}s] imgs≈${imgs} consoleErrors=${consoleErrors} → ${path}`);
|
||||||
|
shot++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[diashow] done. ${shot} screenshots, ${consoleErrors} console errors total.`);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('[diashow] failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
654
e2e/loadtest/driver.mjs
Normal file
654
e2e/loadtest/driver.mjs
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* EventSnap load / stress driver.
|
||||||
|
*
|
||||||
|
* Simulates ~100 guests joining an event and uploading ~1000 images in bursts
|
||||||
|
* (10-20 at a time) spread across a compressed time window, plus a pool of
|
||||||
|
* viewers holding live SSE connections (like phones with the feed open) and one
|
||||||
|
* "diashow" connection (the showcase display). Measures the metrics that matter
|
||||||
|
* for a live event — especially the *pipeline backlog*: how long between an
|
||||||
|
* upload succeeding and its preview being ready (SSE `upload-processed`), which
|
||||||
|
* is exactly "how long until the photo shows up on the diashow".
|
||||||
|
*
|
||||||
|
* This is an HTTP-level driver on purpose: 100 real browsers would bottleneck
|
||||||
|
* the test box, not the server. One real browser watches /diashow separately
|
||||||
|
* (see diashow-watch.mjs).
|
||||||
|
*
|
||||||
|
* METHODOLOGY NOTE — what we change vs. the shipping config:
|
||||||
|
* We ONLY disable rate limits. They are per-IP/per-user anti-abuse guards; a
|
||||||
|
* synthetic test from one IP would trip them in a way real guests (distinct
|
||||||
|
* IPs, phones) never would, so leaving them on would measure the rate limiter
|
||||||
|
* instead of the pipeline. We DELIBERATELY leave compression concurrency,
|
||||||
|
* DB pool size and quotas at their real defaults — validating those is the
|
||||||
|
* whole point. (Runtime can't change compression concurrency anyway; it's a
|
||||||
|
* boot-time env var.)
|
||||||
|
*
|
||||||
|
* Related real-world finding to keep in mind: the default upload_rate_per_hour
|
||||||
|
* is 10, so a real guest uploading a burst of 10-20 would be throttled by the
|
||||||
|
* SHIPPING config too. That's a genuine event-day issue worth its own report,
|
||||||
|
* independent of this pipeline test.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node driver.mjs # full run (100 guests / 1000 imgs / 15 min)
|
||||||
|
* LT_GUESTS=5 LT_IMAGES=50 LT_WINDOW_SEC=60 node driver.mjs # smoke
|
||||||
|
*
|
||||||
|
* Env knobs (all optional; defaults target the full run):
|
||||||
|
* LT_BASE http://localhost:3101 frontend/caddy base URL
|
||||||
|
* LT_GUESTS 100 number of virtual guests
|
||||||
|
* LT_IMAGES 1000 total uploads to perform
|
||||||
|
* LT_WINDOW_SEC 900 spread uploads over this window
|
||||||
|
* LT_BURST_MIN/MAX 10 / 20 images per burst
|
||||||
|
* LT_BURST_CONC 3 parallel uploads within one burst (phone-like)
|
||||||
|
* LT_VIEWERS 20 extra guests holding SSE + polling feed
|
||||||
|
* LT_PHOTOS_DIR /tmp/eventsnap-loadtest/photos
|
||||||
|
* LT_TRUNCATE 1 wipe event data before the run
|
||||||
|
* LT_DRAIN_TIMEOUT_SEC 600 max wait for compression backlog to drain
|
||||||
|
* LT_APP_CONTAINER e2e-app-1 docker container for CPU/mem sampling
|
||||||
|
* LT_DB_CONTAINER e2e-db-1 docker container for psql ground-truth
|
||||||
|
* LT_ADMIN_PW admin-test-pw
|
||||||
|
* LT_KEEP_RATELIMITS 0 set 1 to NOT disable rate limits
|
||||||
|
* LT_OUT_DIR ./results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// ── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
const cfg = {
|
||||||
|
base: process.env.LT_BASE ?? 'http://localhost:3101',
|
||||||
|
guests: int('LT_GUESTS', 100),
|
||||||
|
images: int('LT_IMAGES', 1000),
|
||||||
|
windowSec: int('LT_WINDOW_SEC', 900),
|
||||||
|
burstMin: int('LT_BURST_MIN', 10),
|
||||||
|
burstMax: int('LT_BURST_MAX', 20),
|
||||||
|
burstConc: int('LT_BURST_CONC', 3),
|
||||||
|
viewers: int('LT_VIEWERS', 20),
|
||||||
|
photosDir: process.env.LT_PHOTOS_DIR ?? '/tmp/eventsnap-loadtest/photos',
|
||||||
|
truncate: process.env.LT_TRUNCATE !== '0',
|
||||||
|
drainTimeoutSec: int('LT_DRAIN_TIMEOUT_SEC', 600),
|
||||||
|
appContainer: process.env.LT_APP_CONTAINER ?? 'e2e-app-1',
|
||||||
|
dbContainer: process.env.LT_DB_CONTAINER ?? 'e2e-db-1',
|
||||||
|
adminPw: process.env.LT_ADMIN_PW ?? 'admin-test-pw',
|
||||||
|
keepRateLimits: process.env.LT_KEEP_RATELIMITS === '1',
|
||||||
|
outDir: process.env.LT_OUT_DIR ?? join(__dirname, 'results'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function int(name, def) {
|
||||||
|
const v = process.env[name];
|
||||||
|
return v === undefined ? def : parseInt(v, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = `${cfg.base}/api/v1`;
|
||||||
|
const now = () => Date.now();
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const rand = (min, max) => min + Math.floor(Math.random() * (max - min + 1));
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||||
|
async function api(path, { method = 'GET', token, json, expect } = {}) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (json !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: json !== undefined ? JSON.stringify(json) : undefined,
|
||||||
|
});
|
||||||
|
let body;
|
||||||
|
if (res.status !== 204) {
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
body = text.length ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expect && !expect.includes(res.status)) {
|
||||||
|
throw new Error(`${method} ${path} → ${res.status}: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
return { status: res.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminLogin = () =>
|
||||||
|
api('/admin/login', { method: 'POST', json: { password: cfg.adminPw }, expect: [200] }).then(
|
||||||
|
(r) => r.body.jwt
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinGuest = (name) =>
|
||||||
|
api('/join', { method: 'POST', json: { display_name: name }, expect: [201] }).then((r) => r.body);
|
||||||
|
|
||||||
|
const patchConfig = (adminJwt, patch) =>
|
||||||
|
api('/admin/config', { method: 'PATCH', token: adminJwt, json: patch, expect: [204] });
|
||||||
|
|
||||||
|
const getConfig = (adminJwt) => api('/admin/config', { token: adminJwt }).then((r) => r.body);
|
||||||
|
|
||||||
|
const truncate = (adminJwt) =>
|
||||||
|
api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] });
|
||||||
|
|
||||||
|
const CAPTIONS = [
|
||||||
|
'Was für ein magischer Tag 💍',
|
||||||
|
'Der erste Tanz 🕺',
|
||||||
|
'Prost! 🥂',
|
||||||
|
'Die Torte 🍰',
|
||||||
|
'Feuerwerk 🎆',
|
||||||
|
'Beste Freunde 💕',
|
||||||
|
'Was für eine Stimmung! 🎉',
|
||||||
|
'Details 🌸',
|
||||||
|
'Sonnenuntergang 🌅',
|
||||||
|
'Tanzfläche brennt 🔥',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert'];
|
||||||
|
|
||||||
|
async function uploadPhoto(jwt, file, buf) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||||
|
const cap = pick(CAPTIONS);
|
||||||
|
if (cap) form.append('caption', cap);
|
||||||
|
form.append('hashtags', `${pick(TAGS)},${pick(TAGS)}`);
|
||||||
|
const t0 = now();
|
||||||
|
const res = await fetch(`${API}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const t1 = now();
|
||||||
|
let id, errText;
|
||||||
|
if (res.status === 201) {
|
||||||
|
id = (await res.json()).id;
|
||||||
|
} else {
|
||||||
|
errText = (await res.text()).slice(0, 200);
|
||||||
|
}
|
||||||
|
return { status: res.status, id, ms: t1 - t0, endTs: t1, bytes: buf.length, errText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE client (auto-reconnecting) ────────────────────────────────────────────
|
||||||
|
class SseClient {
|
||||||
|
constructor(jwt, label, onEvent) {
|
||||||
|
this.jwt = jwt;
|
||||||
|
this.label = label;
|
||||||
|
this.onEvent = onEvent;
|
||||||
|
this.stop = false;
|
||||||
|
this.reconnects = 0;
|
||||||
|
this.resyncs = 0;
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
this._loop();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
async _loop() {
|
||||||
|
while (!this.stop) {
|
||||||
|
try {
|
||||||
|
const tkt = await api('/stream/ticket', { method: 'POST', token: this.jwt, expect: [200] });
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const res = await fetch(`${API}/stream?ticket=${tkt.body.ticket}`, {
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
signal: this.controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error(`stream ${res.status}`);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
while (!this.stop) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let idx;
|
||||||
|
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
const frame = buf.slice(0, idx);
|
||||||
|
buf = buf.slice(idx + 2);
|
||||||
|
this._parseFrame(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (this.stop) return;
|
||||||
|
this.reconnects++;
|
||||||
|
await sleep(500 + Math.random() * 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_parseFrame(frame) {
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (event === 'resync') this.resyncs++;
|
||||||
|
if (!data) return;
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
payload = data;
|
||||||
|
}
|
||||||
|
this.onEvent(event, payload, this.label);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.stop = true;
|
||||||
|
try {
|
||||||
|
this.controller?.abort();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resource sampler (docker stats + psql ground truth) ───────────────────────
|
||||||
|
async function sampleResources() {
|
||||||
|
const out = { ts: now() };
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('docker', [
|
||||||
|
'stats',
|
||||||
|
'--no-stream',
|
||||||
|
'--format',
|
||||||
|
'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}',
|
||||||
|
cfg.appContainer,
|
||||||
|
cfg.dbContainer,
|
||||||
|
]);
|
||||||
|
out.docker = stdout.trim();
|
||||||
|
} catch (e) {
|
||||||
|
out.dockerErr = String(e).slice(0, 120);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await psql(
|
||||||
|
`select count(*) from pg_stat_activity where datname='eventsnap_test'`
|
||||||
|
);
|
||||||
|
out.dbConns = parseInt(stdout.trim(), 10);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function psql(sql) {
|
||||||
|
return execFileAsync('docker', [
|
||||||
|
'exec',
|
||||||
|
cfg.dbContainer,
|
||||||
|
'psql',
|
||||||
|
'-U',
|
||||||
|
'eventsnap_test',
|
||||||
|
'-d',
|
||||||
|
'eventsnap_test',
|
||||||
|
'-tAc',
|
||||||
|
sql,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressionCounts() {
|
||||||
|
try {
|
||||||
|
const { stdout } = await psql(
|
||||||
|
`select compression_status, count(*) from upload where deleted_at is null group by 1`
|
||||||
|
);
|
||||||
|
const map = {};
|
||||||
|
for (const line of stdout.trim().split('\n')) {
|
||||||
|
if (!line) continue;
|
||||||
|
const [status, n] = line.split('|');
|
||||||
|
map[status] = parseInt(n, 10);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch (e) {
|
||||||
|
return { error: String(e).slice(0, 120) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats helpers ─────────────────────────────────────────────────────────────
|
||||||
|
function pct(sorted, p) {
|
||||||
|
if (!sorted.length) return null;
|
||||||
|
const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
||||||
|
return sorted[i];
|
||||||
|
}
|
||||||
|
function summarize(nums) {
|
||||||
|
if (!nums.length) return null;
|
||||||
|
const s = [...nums].sort((a, b) => a - b);
|
||||||
|
const sum = s.reduce((a, b) => a + b, 0);
|
||||||
|
return {
|
||||||
|
n: s.length,
|
||||||
|
min: s[0],
|
||||||
|
max: s[s.length - 1],
|
||||||
|
mean: Math.round(sum / s.length),
|
||||||
|
p50: pct(s, 50),
|
||||||
|
p95: pct(s, 95),
|
||||||
|
p99: pct(s, 99),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image pool ────────────────────────────────────────────────────────────────
|
||||||
|
function loadPool() {
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = readdirSync(cfg.photosDir).filter((f) => f.endsWith('.jpg'));
|
||||||
|
} catch {
|
||||||
|
files = [];
|
||||||
|
}
|
||||||
|
if (!files.length) {
|
||||||
|
console.error(
|
||||||
|
`\n✗ No images in ${cfg.photosDir}. Run first:\n` +
|
||||||
|
` LT_PHOTOS_DIR=${cfg.photosDir} python3 ${join(__dirname, 'gen-images.py')}\n`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const pool = files.map((f) => {
|
||||||
|
const p = join(cfg.photosDir, f);
|
||||||
|
return { buf: readFileSync(p), size: statSync(p).size, name: f };
|
||||||
|
});
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Burst schedule ────────────────────────────────────────────────────────────
|
||||||
|
// Build bursts until we reach the image target, assign each to a random guest at
|
||||||
|
// a random time in the window. Naturally gives some guests several bursts and
|
||||||
|
// some none — like a real event. Guests all join before their first burst.
|
||||||
|
function buildSchedule() {
|
||||||
|
const bursts = [];
|
||||||
|
let remaining = cfg.images;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const size = Math.min(remaining, rand(cfg.burstMin, cfg.burstMax));
|
||||||
|
bursts.push({
|
||||||
|
guest: rand(0, cfg.guests - 1),
|
||||||
|
atMs: Math.floor(Math.random() * cfg.windowSec * 1000),
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
remaining -= size;
|
||||||
|
}
|
||||||
|
bursts.sort((a, b) => a.atMs - b.atMs);
|
||||||
|
return bursts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
console.log('━'.repeat(72));
|
||||||
|
console.log('EventSnap load driver');
|
||||||
|
console.log(
|
||||||
|
` target: ${cfg.guests} guests, ${cfg.images} images, ` +
|
||||||
|
`bursts ${cfg.burstMin}-${cfg.burstMax}, window ${cfg.windowSec}s, ${cfg.viewers} viewers`
|
||||||
|
);
|
||||||
|
console.log(` base: ${cfg.base}`);
|
||||||
|
console.log('━'.repeat(72));
|
||||||
|
|
||||||
|
const pool = loadPool();
|
||||||
|
const avgMb = pool.reduce((a, p) => a + p.size, 0) / pool.length / 1e6;
|
||||||
|
console.log(
|
||||||
|
`[pool] ${pool.length} images, avg ${avgMb.toFixed(2)} MB, ` +
|
||||||
|
`projected originals ~${((avgMb * cfg.images) / 1000).toFixed(1)} GB`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preflight: disk headroom
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('df', ['-BG', '--output=avail', '/']);
|
||||||
|
console.log(`[preflight] disk avail:${stdout.trim().split('\n').pop().trim()}`);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: reset + config
|
||||||
|
const admin = await adminLogin();
|
||||||
|
const cfgBefore = await getConfig(admin);
|
||||||
|
if (cfg.truncate) {
|
||||||
|
console.log('[setup] truncating event data…');
|
||||||
|
await truncate(admin);
|
||||||
|
}
|
||||||
|
const admin2 = await adminLogin();
|
||||||
|
if (!cfg.keepRateLimits) {
|
||||||
|
console.log('[setup] disabling rate limits (methodology: isolate pipeline, not limiter)');
|
||||||
|
await patchConfig(admin2, {
|
||||||
|
rate_limits_enabled: 'false',
|
||||||
|
upload_rate_enabled: 'false',
|
||||||
|
feed_rate_enabled: 'false',
|
||||||
|
join_rate_enabled: 'false',
|
||||||
|
recover_rate_enabled: 'false',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[setup] compression concurrency / quotas left at SHIPPING defaults ` +
|
||||||
|
`(compression_worker_concurrency is a boot env var, not runtime)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Metrics stores
|
||||||
|
const uploads = []; // {status, ms, endTs, bytes, id, guest}
|
||||||
|
const processedAt = new Map(); // upload_id -> ts of upload-processed
|
||||||
|
const uploadEndTs = new Map(); // upload_id -> endTs
|
||||||
|
const errorEvents = []; // upload-error payloads
|
||||||
|
const resources = [];
|
||||||
|
let newUploadEvents = 0;
|
||||||
|
|
||||||
|
// Join guests (staggered, small concurrency)
|
||||||
|
console.log(`[join] joining ${cfg.guests} guests…`);
|
||||||
|
const accounts = new Array(cfg.guests);
|
||||||
|
const joinConc = 10;
|
||||||
|
for (let i = 0; i < cfg.guests; i += joinConc) {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(joinConc, cfg.guests - i) }, (_, k) =>
|
||||||
|
joinGuest(`LoadGuest ${i + k}`).then((a) => (accounts[i + k] = a))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('[join] done');
|
||||||
|
|
||||||
|
// Live connections: 1 diashow + N viewers
|
||||||
|
const onEvent = (event, payload) => {
|
||||||
|
if (event === 'new-upload') newUploadEvents++;
|
||||||
|
else if (event === 'upload-processed' && payload?.upload_id) {
|
||||||
|
if (!processedAt.has(payload.upload_id)) processedAt.set(payload.upload_id, now());
|
||||||
|
} else if (event === 'upload-error' && payload?.upload_id) {
|
||||||
|
errorEvents.push(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sseClients = [];
|
||||||
|
sseClients.push(new SseClient(accounts[0].jwt, 'diashow', onEvent).start());
|
||||||
|
for (let i = 0; i < Math.min(cfg.viewers, cfg.guests); i++) {
|
||||||
|
sseClients.push(new SseClient(accounts[i].jwt, `viewer${i}`, onEvent).start());
|
||||||
|
}
|
||||||
|
console.log(`[sse] opened ${sseClients.length} live connections (1 diashow + viewers)`);
|
||||||
|
|
||||||
|
// Viewers also poll the feed periodically (viewing load)
|
||||||
|
let feedPolls = 0;
|
||||||
|
const feedPoller = setInterval(async () => {
|
||||||
|
const g = accounts[rand(0, Math.min(cfg.viewers, cfg.guests) - 1)];
|
||||||
|
try {
|
||||||
|
await api('/feed', { token: g.jwt });
|
||||||
|
feedPolls++;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// Resource sampler
|
||||||
|
const sampler = setInterval(async () => resources.push(await sampleResources()), 5000);
|
||||||
|
resources.push(await sampleResources());
|
||||||
|
|
||||||
|
// Run the burst schedule
|
||||||
|
const schedule = buildSchedule();
|
||||||
|
console.log(
|
||||||
|
`[run] ${schedule.length} bursts scheduled over ${cfg.windowSec}s ` +
|
||||||
|
`(≈${(cfg.images / (cfg.windowSec / 60)).toFixed(0)} img/min avg). Starting…`
|
||||||
|
);
|
||||||
|
const startTs = now();
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
const runBurst = async (burst) => {
|
||||||
|
const jwt = accounts[burst.guest].jwt;
|
||||||
|
const items = Array.from({ length: burst.size }, () => pick(pool));
|
||||||
|
// upload with phone-like small concurrency inside the burst
|
||||||
|
for (let i = 0; i < items.length; i += cfg.burstConc) {
|
||||||
|
const chunk = items.slice(i, i + cfg.burstConc);
|
||||||
|
const results = await Promise.all(chunk.map((it) => uploadPhoto(jwt, it.name, it.buf)));
|
||||||
|
for (const r of results) {
|
||||||
|
uploads.push({ ...r, guest: burst.guest });
|
||||||
|
if (r.id) uploadEndTs.set(r.id, r.endTs);
|
||||||
|
done++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timers = [];
|
||||||
|
const burstPromises = [];
|
||||||
|
for (const burst of schedule) {
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
burstPromises.push(runBurst(burst));
|
||||||
|
}, burst.atMs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// progress ticker
|
||||||
|
const ticker = setInterval(() => {
|
||||||
|
const elapsed = ((now() - startTs) / 1000).toFixed(0);
|
||||||
|
const ok = uploads.filter((u) => u.status === 201).length;
|
||||||
|
console.log(
|
||||||
|
`[t+${elapsed}s] uploads ${done}/${cfg.images} (ok ${ok}), ` +
|
||||||
|
`processed ${processedAt.size}, new-upload evts ${newUploadEvents}, ` +
|
||||||
|
`feedPolls ${feedPolls}`
|
||||||
|
);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// wait for the window to elapse, then for all scheduled bursts to finish
|
||||||
|
await sleep(cfg.windowSec * 1000 + 500);
|
||||||
|
await Promise.all(burstPromises);
|
||||||
|
clearInterval(ticker);
|
||||||
|
console.log(
|
||||||
|
`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids)
|
||||||
|
console.log('[drain] waiting for compression backlog to clear…');
|
||||||
|
const drainStart = now();
|
||||||
|
const successIds = new Set(uploads.filter((u) => u.id).map((u) => u.id));
|
||||||
|
let lastLog = 0;
|
||||||
|
let drainReason = 'timeout';
|
||||||
|
while (now() - drainStart < cfg.drainTimeoutSec * 1000) {
|
||||||
|
// SSE view (what the diashow actually "sees")
|
||||||
|
const pendingSse = [...successIds].filter((id) => !processedAt.has(id)).length;
|
||||||
|
if (pendingSse === 0) {
|
||||||
|
console.log('[drain] backlog cleared (all upload-processed events received)');
|
||||||
|
drainReason = 'sse-complete';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// DB ground truth — authoritative even if an SSE reconnect dropped events
|
||||||
|
const counts = await compressionCounts();
|
||||||
|
const dbPending = Object.entries(counts)
|
||||||
|
.filter(([s]) => s !== 'done' && s !== 'error')
|
||||||
|
.reduce((a, [, n]) => a + n, 0);
|
||||||
|
if (!counts.error && dbPending === 0) {
|
||||||
|
console.log(`[drain] backlog cleared per DB (db status: ${JSON.stringify(counts)})`);
|
||||||
|
drainReason = 'db-complete';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (now() - lastLog > 5000) {
|
||||||
|
console.log(
|
||||||
|
`[drain] pending(sse) ${pendingSse}, pending(db) ${dbPending} — db: ${JSON.stringify(counts)}`
|
||||||
|
);
|
||||||
|
lastLog = now();
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
const drainMs = now() - drainStart;
|
||||||
|
|
||||||
|
// Stop background work
|
||||||
|
clearInterval(sampler);
|
||||||
|
clearInterval(feedPoller);
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
resources.push(await sampleResources());
|
||||||
|
const finalCounts = await compressionCounts();
|
||||||
|
await sleep(200);
|
||||||
|
sseClients.forEach((c) => c.close());
|
||||||
|
|
||||||
|
// ── Build report ────────────────────────────────────────────────────────────
|
||||||
|
const byStatus = {};
|
||||||
|
for (const u of uploads) byStatus[u.status] = (byStatus[u.status] ?? 0) + 1;
|
||||||
|
const okUploads = uploads.filter((u) => u.status === 201);
|
||||||
|
const uploadLatency = summarize(okUploads.map((u) => u.ms));
|
||||||
|
const pipelineLatency = summarize(
|
||||||
|
[...processedAt.entries()]
|
||||||
|
.filter(([id]) => uploadEndTs.has(id))
|
||||||
|
.map(([id, ts]) => ts - uploadEndTs.get(id))
|
||||||
|
);
|
||||||
|
const totalReconnects = sseClients.reduce((a, c) => a + c.reconnects, 0);
|
||||||
|
const totalResyncs = sseClients.reduce((a, c) => a + c.resyncs, 0);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
config: cfg,
|
||||||
|
startedAt: new Date(startTs).toISOString(),
|
||||||
|
durationSec: Math.round((now() - startTs) / 1000),
|
||||||
|
totals: {
|
||||||
|
uploadsAttempted: uploads.length,
|
||||||
|
uploadsOk: okUploads.length,
|
||||||
|
byStatus,
|
||||||
|
newUploadEvents,
|
||||||
|
processedEvents: processedAt.size,
|
||||||
|
uploadErrorEvents: errorEvents.length,
|
||||||
|
feedPolls,
|
||||||
|
},
|
||||||
|
uploadLatencyMs: uploadLatency,
|
||||||
|
pipelineLatencyMs: pipelineLatency,
|
||||||
|
drain: {
|
||||||
|
ms: drainMs,
|
||||||
|
cleared: drainReason !== 'timeout',
|
||||||
|
reason: drainReason,
|
||||||
|
ssePending: [...successIds].filter((id) => !processedAt.has(id)).length,
|
||||||
|
finalDbCounts: finalCounts,
|
||||||
|
},
|
||||||
|
sse: {
|
||||||
|
connections: sseClients.length,
|
||||||
|
reconnects: totalReconnects,
|
||||||
|
resyncs: totalResyncs,
|
||||||
|
},
|
||||||
|
resources,
|
||||||
|
rateLimitsDisabled: !cfg.keepRateLimits,
|
||||||
|
configBefore: cfgBefore,
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(cfg.outDir, { recursive: true });
|
||||||
|
const stamp = new Date(startTs).toISOString().replace(/[:.]/g, '-');
|
||||||
|
const outPath = join(cfg.outDir, `run-${stamp}.json`);
|
||||||
|
writeFileSync(outPath, JSON.stringify(report, null, 2));
|
||||||
|
|
||||||
|
// ── Print verdict ─────────────────────────────────────────────────────────
|
||||||
|
console.log('\n' + '━'.repeat(72));
|
||||||
|
console.log('RESULTS');
|
||||||
|
console.log('━'.repeat(72));
|
||||||
|
console.log(
|
||||||
|
`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`
|
||||||
|
);
|
||||||
|
console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`);
|
||||||
|
console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`);
|
||||||
|
console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`);
|
||||||
|
console.log(`final db compression status: ${JSON.stringify(finalCounts)}`);
|
||||||
|
console.log(
|
||||||
|
`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`
|
||||||
|
);
|
||||||
|
console.log(`\nfull report → ${outPath}`);
|
||||||
|
|
||||||
|
// Heuristic pass/fail flags (validate shipping config)
|
||||||
|
const flags = [];
|
||||||
|
const err5xx = Object.entries(byStatus)
|
||||||
|
.filter(([s]) => +s >= 500)
|
||||||
|
.reduce((a, [, n]) => a + n, 0);
|
||||||
|
if (err5xx > 0) flags.push(`✗ ${err5xx} server errors (5xx)`);
|
||||||
|
if (byStatus['507']) flags.push(`✗ ${byStatus['507']} quota rejections (507)`);
|
||||||
|
if (byStatus['413']) flags.push(`⚠ ${byStatus['413']} too-large (413)`);
|
||||||
|
if (byStatus['429'])
|
||||||
|
flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`);
|
||||||
|
if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`);
|
||||||
|
if (totalResyncs > sseClients.length) flags.push(`⚠ ${totalResyncs} SSE resyncs (consumer lag)`);
|
||||||
|
if (pipelineLatency && pipelineLatency.p95 > 60000)
|
||||||
|
flags.push(`⚠ pipeline p95 ${(pipelineLatency.p95 / 1000).toFixed(0)}s (diashow lags >1min)`);
|
||||||
|
console.log('\nflags:');
|
||||||
|
if (flags.length) flags.forEach((f) => console.log(' ' + f));
|
||||||
|
else console.log(' ✓ no red flags — default config handled the load');
|
||||||
|
console.log('━'.repeat(72));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('\n✗ driver failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
146
e2e/loadtest/gen-images.py
Normal file
146
e2e/loadtest/gen-images.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a pool of realistic phone-sized JPEGs for the EventSnap load test.
|
||||||
|
|
||||||
|
The stress test uploads ~1000 images, but they don't need to be 1000 unique
|
||||||
|
files — real load comes from realistic *byte size* and *decode cost*, which
|
||||||
|
drive bandwidth, the compression/preview pipeline (decode + 800x800 resize),
|
||||||
|
disk usage and the dynamic storage quota. So we generate a modest POOL of
|
||||||
|
distinct, high-entropy images (~2-4 MB, 12 MP, like a phone camera) and the
|
||||||
|
driver reuses them at random across the 1000 uploads.
|
||||||
|
|
||||||
|
High entropy matters: a flat gradient compresses to almost nothing and would
|
||||||
|
under-stress both the network and the JPEG decoder. We blend random noise over
|
||||||
|
a colorful gradient + big shapes so the files land in a realistic size band,
|
||||||
|
then tune JPEG quality per-image to hit the target size.
|
||||||
|
|
||||||
|
Output: $LT_PHOTOS_DIR (default /tmp/eventsnap-loadtest/photos)
|
||||||
|
Usage: python3 gen-images.py [COUNT] (default 40)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
OUT_DIR = os.environ.get("LT_PHOTOS_DIR", "/tmp/eventsnap-loadtest/photos")
|
||||||
|
COUNT = int(sys.argv[1]) if len(sys.argv) > 1 else 40
|
||||||
|
|
||||||
|
# Target JPEG size band (bytes). Typical modern phone photo.
|
||||||
|
TARGET_MIN = 2_000_000
|
||||||
|
TARGET_MAX = 4_500_000
|
||||||
|
|
||||||
|
# 12 MP-ish, both orientations (phones shoot portrait and landscape).
|
||||||
|
SIZES = [(4032, 3024), (3024, 4032)]
|
||||||
|
|
||||||
|
PALETTES = [
|
||||||
|
[(250, 245, 235), (212, 175, 55), (120, 90, 30)], # champagne / gold
|
||||||
|
[(245, 244, 242), (190, 190, 198), (90, 92, 100)], # silver / pearl
|
||||||
|
[(255, 250, 240), (240, 200, 160), (170, 110, 70)], # warm sunset
|
||||||
|
[(235, 240, 248), (140, 170, 210), (40, 70, 120)], # cool blue hour
|
||||||
|
[(248, 240, 245), (210, 150, 180), (110, 50, 90)], # rose dusk
|
||||||
|
[(240, 248, 242), (150, 200, 170), (40, 110, 80)], # garden green
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def lerp(a, b, t):
|
||||||
|
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def gradient(w, h, palette, rng):
|
||||||
|
"""Diagonal 3-stop gradient base."""
|
||||||
|
base = Image.new("RGB", (w, h))
|
||||||
|
px = base.load()
|
||||||
|
ang = rng.uniform(0, math.pi)
|
||||||
|
dx, dy = math.cos(ang), math.sin(ang)
|
||||||
|
# precompute per-column/row projection for speed
|
||||||
|
maxproj = abs(dx) * w + abs(dy) * h
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(0, w, 4): # step 4 then fill — good enough, much faster
|
||||||
|
t = (dx * x + dy * y) / maxproj
|
||||||
|
t = min(1.0, max(0.0, t + rng.uniform(-0.02, 0.02)))
|
||||||
|
if t < 0.5:
|
||||||
|
c = lerp(palette[0], palette[1], t * 2)
|
||||||
|
else:
|
||||||
|
c = lerp(palette[1], palette[2], (t - 0.5) * 2)
|
||||||
|
for k in range(4):
|
||||||
|
if x + k < w:
|
||||||
|
px[x + k, y] = c
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def add_shapes(img, rng):
|
||||||
|
d = ImageDraw.Draw(img, "RGBA")
|
||||||
|
w, h = img.size
|
||||||
|
for _ in range(rng.randint(6, 14)):
|
||||||
|
x0 = rng.randint(-w // 5, w)
|
||||||
|
y0 = rng.randint(-h // 5, h)
|
||||||
|
r = rng.randint(w // 12, w // 3)
|
||||||
|
col = (rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255), rng.randint(20, 90))
|
||||||
|
if rng.random() < 0.5:
|
||||||
|
d.ellipse([x0, y0, x0 + r, y0 + r], fill=col)
|
||||||
|
else:
|
||||||
|
d.rectangle([x0, y0, x0 + r, y0 + int(r * rng.uniform(0.4, 1.6))], fill=col)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def noisy(w, h, rng):
|
||||||
|
"""Full-resolution RGB noise from urandom — maximum entropy."""
|
||||||
|
return Image.frombytes("RGB", (w, h), os.urandom(w * h * 3))
|
||||||
|
|
||||||
|
|
||||||
|
def label(img, idx, rng):
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
txt = f"EventSnap load #{idx:03d}"
|
||||||
|
try:
|
||||||
|
font = ImageFont.load_default(size=64)
|
||||||
|
except TypeError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
d.text((60, 60), txt, fill=(255, 255, 255), font=font)
|
||||||
|
d.text((62, 62), txt, fill=(0, 0, 0), font=font) # cheap shadow offset
|
||||||
|
|
||||||
|
|
||||||
|
def encode_to_band(img, path, rng):
|
||||||
|
"""Try qualities high→low until the file lands under TARGET_MAX; keep the
|
||||||
|
first that also clears TARGET_MIN if possible."""
|
||||||
|
best = None
|
||||||
|
for q in (92, 88, 84, 80, 76, 72):
|
||||||
|
img.save(path, "JPEG", quality=q, optimize=False)
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
best = (q, size)
|
||||||
|
if size <= TARGET_MAX:
|
||||||
|
if size >= TARGET_MIN:
|
||||||
|
return q, size
|
||||||
|
# under the band — noise alpha likely too low; accept anyway at high q
|
||||||
|
return q, size
|
||||||
|
return best # even q72 too big; accept the smallest we made
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
rng = random.Random(20260718) # deterministic pool
|
||||||
|
total_bytes = 0
|
||||||
|
print(f"[gen] writing {COUNT} images to {OUT_DIR}")
|
||||||
|
for i in range(COUNT):
|
||||||
|
w, h = rng.choice(SIZES)
|
||||||
|
palette = rng.choice(PALETTES)
|
||||||
|
base = gradient(w, h, palette, rng)
|
||||||
|
base = add_shapes(base, rng)
|
||||||
|
# blend noise to inject entropy -> realistic JPEG size
|
||||||
|
alpha = rng.uniform(0.28, 0.42)
|
||||||
|
base = Image.blend(base, noisy(w, h, rng), alpha)
|
||||||
|
label(base, i, rng)
|
||||||
|
path = os.path.join(OUT_DIR, f"photo_{i:03d}.jpg")
|
||||||
|
q, size = encode_to_band(base, path, rng)
|
||||||
|
total_bytes += size
|
||||||
|
print(f" photo_{i:03d}.jpg {w}x{h} q{q} {size/1_000_000:.2f} MB")
|
||||||
|
avg = total_bytes / COUNT
|
||||||
|
print(f"[gen] done. {COUNT} images, avg {avg/1_000_000:.2f} MB, "
|
||||||
|
f"pool total {total_bytes/1_000_000:.1f} MB")
|
||||||
|
print(f"[gen] projected for 1000 uploads (originals only): "
|
||||||
|
f"~{avg*1000/1_000_000_000:.1f} GB")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -37,12 +37,19 @@ export class ExportPage {
|
|||||||
/**
|
/**
|
||||||
* The "Download" button inside the card whose heading is `heading`.
|
* The "Download" button inside the card whose heading is `heading`.
|
||||||
*
|
*
|
||||||
* Scoped to the card element (`div.rounded-xl`) rather than "any div containing the
|
* Scoped to the card element rather than "any div containing the heading" — the latter
|
||||||
* heading" — the latter also matches the page wrapper, which contains BOTH cards' buttons.
|
* also matches the page wrapper, which contains BOTH cards' buttons.
|
||||||
|
*
|
||||||
|
* The scope class is `div.card` (see the ZIP/HTML cards in
|
||||||
|
* frontend/src/routes/export/+page.svelte). It was previously `div.rounded-xl`, which
|
||||||
|
* matched NOTHING: `.card` is a Tailwind `@apply` component class
|
||||||
|
* (frontend/src/lib/styles/components.css) so the DOM only ever carries `class="card p-5"`
|
||||||
|
* — and the utility it applies is `rounded-2xl` anyway. Both card-scoped locators were
|
||||||
|
* therefore dead, which is why the "shows enabled download buttons" test was red.
|
||||||
*/
|
*/
|
||||||
private cardButton(heading: string): Locator {
|
private cardButton(heading: string): Locator {
|
||||||
return this.page
|
return this.page
|
||||||
.locator('div.rounded-xl')
|
.locator('div.card')
|
||||||
.filter({ has: this.page.getByRole('heading', { name: heading, exact: true }) })
|
.filter({ has: this.page.getByRole('heading', { name: heading, exact: true }) })
|
||||||
.getByRole('button', { name: 'Download', exact: true });
|
.getByRole('button', { name: 'Download', exact: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,8 +146,18 @@ export default defineConfig({
|
|||||||
// that on `@smoke` — which exists on exactly two specs — meant the entire iOS guarantee was
|
// that on `@smoke` — which exists on exactly two specs — meant the entire iOS guarantee was
|
||||||
// one happy path and one join test. Every other UA here is a secondary browser and a smoke
|
// one happy path and one join test. Every other UA here is a secondary browser and a smoke
|
||||||
// check is proportionate; WebKit is not. Give it the core journeys the guest actually walks:
|
// check is proportionate; WebKit is not. Give it the core journeys the guest actually walks:
|
||||||
// join/recover, upload, and browse the feed.
|
// join/recover, upload, browse the feed — and take the keepsake home.
|
||||||
testMatch: ['**/__smoke/**', '**/01-auth/**', '**/02-upload/**', '**/03-feed/**'],
|
//
|
||||||
|
// 06-export is here because WebKit is the ONLY engine that enforces X-Frame-Options on the
|
||||||
|
// hidden download iframe. Excluding it is what let a site-wide `XFO: DENY` ship a keepsake
|
||||||
|
// download that silently did nothing on iOS. See 06-export/download-iframe.spec.ts.
|
||||||
|
testMatch: [
|
||||||
|
'**/__smoke/**',
|
||||||
|
'**/01-auth/**',
|
||||||
|
'**/02-upload/**',
|
||||||
|
'**/03-feed/**',
|
||||||
|
'**/06-export/**',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'firefox-android',
|
name: 'firefox-android',
|
||||||
|
|||||||
255
e2e/shots.mjs
Normal file
255
e2e/shots.mjs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// One-off: seed a realistic feed, then capture mobile screenshots (light + dark).
|
||||||
|
// Run from the e2e dir so @playwright/test and pg resolve.
|
||||||
|
import { chromium, devices } from '@playwright/test';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
import { readFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:3101';
|
||||||
|
const PHOTOS = '/tmp/eventsnap-shots/photos';
|
||||||
|
const OUT = '/tmp/eventsnap-shots';
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const api = (path, opts = {}) =>
|
||||||
|
fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({
|
||||||
|
status: r.status,
|
||||||
|
body: await r.text().then((t) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(t);
|
||||||
|
} catch {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const authHeaders = (jwt, json = true) => ({
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adminLogin() {
|
||||||
|
const r = await api('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: 'admin-test-pw' }),
|
||||||
|
});
|
||||||
|
return r.body.jwt;
|
||||||
|
}
|
||||||
|
async function joinGuest(name) {
|
||||||
|
const r = await api('/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name }),
|
||||||
|
});
|
||||||
|
if (r.status !== 201)
|
||||||
|
throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body));
|
||||||
|
return r.body; // {jwt,pin,user_id}
|
||||||
|
}
|
||||||
|
async function upload(jwt, file, caption, hashtags) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg');
|
||||||
|
if (caption) form.append('caption', caption);
|
||||||
|
if (hashtags) form.append('hashtags', hashtags);
|
||||||
|
const r = await fetch(`${BASE}/api/v1/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(jwt, false),
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text()));
|
||||||
|
return (await r.json()).id;
|
||||||
|
}
|
||||||
|
async function like(jwt, id) {
|
||||||
|
await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) });
|
||||||
|
}
|
||||||
|
async function comment(jwt, id, body) {
|
||||||
|
await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(jwt),
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pg = () =>
|
||||||
|
new Client({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 55432,
|
||||||
|
user: 'eventsnap_test',
|
||||||
|
password: 'eventsnap_test',
|
||||||
|
database: 'eventsnap_test',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function truncate(adminJwt) {
|
||||||
|
await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(adminJwt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function patchConfig(adminJwt, patch) {
|
||||||
|
await fetch(`${BASE}/api/v1/admin/config`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders(adminJwt),
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SEED ----
|
||||||
|
console.log('[seed] admin login + reset');
|
||||||
|
let admin = await adminLogin();
|
||||||
|
await truncate(admin);
|
||||||
|
admin = await adminLogin();
|
||||||
|
await patchConfig(admin, {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
const guests = [
|
||||||
|
'Anna Bauer',
|
||||||
|
'Lukas Weber',
|
||||||
|
'Mia Schulz',
|
||||||
|
'Jonas Fischer',
|
||||||
|
'Emma Wagner',
|
||||||
|
'Ben Hoffmann',
|
||||||
|
'Sophie Klein',
|
||||||
|
'Paul Richter',
|
||||||
|
];
|
||||||
|
const accounts = {};
|
||||||
|
for (const g of guests) accounts[g] = await joinGuest(g);
|
||||||
|
console.log('[seed] joined', guests.length, 'guests');
|
||||||
|
|
||||||
|
const posts = [
|
||||||
|
['Anna Bauer', 'photo_00.jpg', 'Was für ein magischer Tag 💍✨', 'hochzeit,liebe'],
|
||||||
|
['Lukas Weber', 'photo_01.jpg', 'Der erste Tanz 🕺💃', 'party,tanzen'],
|
||||||
|
['Mia Schulz', 'photo_02.jpg', 'Sonnenuntergang am See 🌅', 'natur,abend'],
|
||||||
|
['Jonas Fischer', 'photo_03.jpg', 'Prost auf das Brautpaar! 🥂', 'feier,freunde'],
|
||||||
|
['Emma Wagner', 'photo_04.jpg', 'Die Torte war ein Traum 🍰', 'dessert,liebe'],
|
||||||
|
['Ben Hoffmann', 'photo_05.jpg', 'Feuerwerk zum Abschluss 🎆', 'party,abend'],
|
||||||
|
['Sophie Klein', 'photo_06.jpg', 'Beste Freunde für immer 💕', 'freunde,feier'],
|
||||||
|
['Paul Richter', 'photo_07.jpg', 'Was für eine Stimmung! 🎉', 'party'],
|
||||||
|
['Anna Bauer', 'photo_08.jpg', 'Details, die zählen 🌸', 'hochzeit'],
|
||||||
|
['Mia Schulz', 'photo_09.jpg', 'Tanzfläche brennt 🔥', 'tanzen,party'],
|
||||||
|
];
|
||||||
|
const ids = [];
|
||||||
|
for (const [who, f, cap, tags] of posts) {
|
||||||
|
const id = await upload(accounts[who].jwt, `${PHOTOS}/${f}`, cap, tags);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
console.log('[seed] uploaded', ids.length, 'photos');
|
||||||
|
|
||||||
|
// Likes: distribute varied counts
|
||||||
|
const likeMatrix = [7, 3, 12, 5, 9, 4, 6, 2, 8, 5];
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const n = Math.min(likeMatrix[i], guests.length);
|
||||||
|
for (let j = 0; j < n; j++) await like(accounts[guests[j]].jwt, ids[i]);
|
||||||
|
}
|
||||||
|
// Comments on a few
|
||||||
|
await comment(accounts['Lukas Weber'].jwt, ids[0], 'Wunderschön! 😍');
|
||||||
|
await comment(accounts['Mia Schulz'].jwt, ids[0], 'Der Moment war perfekt.');
|
||||||
|
await comment(accounts['Anna Bauer'].jwt, ids[2], 'Traumhaft 🌅');
|
||||||
|
await comment(accounts['Ben Hoffmann'].jwt, ids[1], 'Was ein Abend!');
|
||||||
|
console.log('[seed] likes + comments done');
|
||||||
|
|
||||||
|
// Make one guest a host so /host renders populated
|
||||||
|
await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders(admin),
|
||||||
|
body: JSON.stringify({ role: 'host' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for compression to finish so previews render
|
||||||
|
const c = pg();
|
||||||
|
await c.connect();
|
||||||
|
for (let t = 0; t < 40; t++) {
|
||||||
|
const r = await c.query(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`
|
||||||
|
);
|
||||||
|
if (r.rows[0].n === 0) {
|
||||||
|
console.log('[seed] compression done');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((res) => setTimeout(res, 500));
|
||||||
|
}
|
||||||
|
await c.end();
|
||||||
|
|
||||||
|
// ---- SCREENSHOTS ----
|
||||||
|
const device = devices['Pixel 7'];
|
||||||
|
const anna = accounts['Anna Bauer']; // host
|
||||||
|
const shot = async (label, theme, who, route, prep) => {
|
||||||
|
const ctx = await browser.newContext({ ...device });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
// Seed localStorage on the origin
|
||||||
|
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.evaluate(
|
||||||
|
({ jwt, pin, uid, name, theme, mode }) => {
|
||||||
|
localStorage.setItem('eventsnap_theme', theme);
|
||||||
|
localStorage.setItem('eventsnap_data_mode', mode);
|
||||||
|
if (jwt) {
|
||||||
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
|
localStorage.setItem('eventsnap_pin', pin);
|
||||||
|
localStorage.setItem('eventsnap_user_id', uid);
|
||||||
|
localStorage.setItem('eventsnap_display_name', name);
|
||||||
|
}
|
||||||
|
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||||
|
},
|
||||||
|
{ jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }
|
||||||
|
);
|
||||||
|
await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
if (prep) await prep(page);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: `${OUT}/${label}-${theme}.png`, fullPage: false });
|
||||||
|
await ctx.close();
|
||||||
|
console.log('[shot]', label, theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const hostWho = { ...anna, name: 'Anna Bauer' };
|
||||||
|
const adminWho = { jwt: admin, pin: '', user_id: '', name: 'Admin' };
|
||||||
|
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await shot('01-join', theme, null, '/join');
|
||||||
|
await shot('02-feed-list', theme, hostWho, '/feed');
|
||||||
|
await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => {
|
||||||
|
await p
|
||||||
|
.getByLabel('Rasteransicht')
|
||||||
|
.click()
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
await shot('04-lightbox', theme, hostWho, '/feed', async (p) => {
|
||||||
|
await p
|
||||||
|
.locator('img')
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
.catch(() => {});
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
});
|
||||||
|
await shot('05-account', theme, hostWho, '/account');
|
||||||
|
await shot('06-host', theme, hostWho, '/host');
|
||||||
|
await shot('07-admin', theme, adminWho, '/admin');
|
||||||
|
await shot('08-upload', theme, hostWho, '/upload');
|
||||||
|
await shot('09-diashow', theme, hostWho, '/diashow');
|
||||||
|
}
|
||||||
|
// Onboarding: fresh guest, guide not seen
|
||||||
|
{
|
||||||
|
const ctx = await browser.newContext({ ...device });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.evaluate((w) => {
|
||||||
|
localStorage.setItem('eventsnap_theme', 'light');
|
||||||
|
localStorage.setItem('eventsnap_jwt', w.jwt);
|
||||||
|
localStorage.setItem('eventsnap_pin', w.pin);
|
||||||
|
localStorage.setItem('eventsnap_user_id', w.user_id);
|
||||||
|
localStorage.setItem('eventsnap_display_name', 'Emma Wagner');
|
||||||
|
}, accounts['Emma Wagner']);
|
||||||
|
await page.goto(`${BASE}/feed`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await page.screenshot({ path: `${OUT}/10-onboarding-light.png` });
|
||||||
|
await ctx.close();
|
||||||
|
console.log('[shot] onboarding');
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('DONE');
|
||||||
@@ -13,7 +13,11 @@ test.describe('Auth — join flow', () => {
|
|||||||
const join = new JoinPage(page);
|
const join = new JoinPage(page);
|
||||||
await join.goto();
|
await join.goto();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Willkommen!' })).toBeVisible();
|
// The join form's landing state. There is no "Willkommen!" heading — the wedding
|
||||||
|
// redesign (f243bfe) split it into a "Willkommen bei" lead-in plus the event name as
|
||||||
|
// the <h1>, and this assertion was never updated, so it had been failing since.
|
||||||
|
// Anchor on the testid the markup provides rather than on copy.
|
||||||
|
await expect(page.getByTestId('join-event-name')).toBeVisible();
|
||||||
|
|
||||||
const { pin } = await join.joinAs('Alice');
|
const { pin } = await join.joinAs('Alice');
|
||||||
expect(pin).toMatch(/^\d{4}$/);
|
expect(pin).toMatch(/^\d{4}$/);
|
||||||
|
|||||||
206
e2e/specs/01-auth/rate-limit-shared-nat.spec.ts
Normal file
206
e2e/specs/01-auth/rate-limit-shared-nat.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — the door must not close on a venue behind one NAT.
|
||||||
|
*
|
||||||
|
* `/join` was throttled 5 per 60s keyed purely on the client IP. Every guest at a venue
|
||||||
|
* arrives from the same public IP (that is what a NAT is), so the whole party shared one
|
||||||
|
* bucket: 12 guests scanning the QR code within a few seconds meant 5 got in and 7 were
|
||||||
|
* turned away — with no Retry-After to tell them when to try again. `/feed` (60/min) and
|
||||||
|
* `/export` (3/DAY) had the identical defect.
|
||||||
|
*
|
||||||
|
* These ran green for the same structural reason every time: the e2e reseed forces every
|
||||||
|
* limiter toggle OFF before each test, so nothing here was ever exercised. Enable them
|
||||||
|
* explicitly, exactly as 02-upload/rate-limit does.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
test.describe('Rate limits — guests behind a shared NAT', () => {
|
||||||
|
test('a dozen guests can all join from one IP, and 429s carry Retry-After', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
join_rate_enabled: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Twelve DISTINCT guests, same source IP — the arrival burst at a real party.
|
||||||
|
const names = Array.from({ length: 12 }, (_, i) => `NatGuest${i}`);
|
||||||
|
const results = await Promise.all(
|
||||||
|
names.map((display_name) =>
|
||||||
|
fetch(`${BASE}/api/v1/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rejected = results.filter((r) => r.status === 429);
|
||||||
|
expect(
|
||||||
|
rejected.length,
|
||||||
|
`all 12 guests must get in from one IP; ${rejected.length} were turned away`
|
||||||
|
).toBe(0);
|
||||||
|
expect(results.every((r) => r.status === 201)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one guest retrying their own name is still throttled, and told for how long', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
// The per-name bucket must still bite — otherwise the NAT fix would have simply
|
||||||
|
// removed the anti-spam limit rather than re-keyed it.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
join_rate_enabled: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
const attempt = () =>
|
||||||
|
fetch(`${BASE}/api/v1/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: 'RepeatOffender' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5 per 60s for the same (ip, name): the first succeeds (201), the next four collide
|
||||||
|
// with the taken name (409), and the sixth exhausts the bucket.
|
||||||
|
const codes: number[] = [];
|
||||||
|
for (let i = 0; i < 6; i++) codes.push((await attempt()).status);
|
||||||
|
|
||||||
|
expect(codes[0], 'the first join should succeed').toBe(201);
|
||||||
|
expect(codes.at(-1), 'the 6th attempt on one name must be throttled').toBe(429);
|
||||||
|
|
||||||
|
const throttled = await attempt();
|
||||||
|
expect(throttled.status).toBe(429);
|
||||||
|
const retryAfter = throttled.headers.get('retry-after');
|
||||||
|
expect(retryAfter, '429 must tell the client when to come back').toBeTruthy();
|
||||||
|
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||||
|
expect(Number(retryAfter)).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the feed limit is per-user, not per-IP', async ({ api, adminToken, guest }) => {
|
||||||
|
// Two guests, one IP. With a limit of 3/min an IP key would let the first guest's
|
||||||
|
// three reads starve the second entirely.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
feed_rate_enabled: 'true',
|
||||||
|
feed_rate_per_min: '3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = await guest('FeedHog');
|
||||||
|
const b = await guest('FeedVictim');
|
||||||
|
const read = (jwt: string) =>
|
||||||
|
fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||||
|
|
||||||
|
// Guest A burns their whole allowance.
|
||||||
|
for (let i = 0; i < 3; i++) expect((await read(a.jwt)).status).toBe(200);
|
||||||
|
expect((await read(a.jwt)).status, "A's own 4th read is throttled").toBe(429);
|
||||||
|
|
||||||
|
// Guest B must be entirely unaffected.
|
||||||
|
expect((await read(b.jwt)).status, 'B must not inherit A’s exhausted bucket').toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the export limit is per-user — one guest cannot spend the whole venue’s quota', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
host,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
// The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch
|
||||||
|
// their keepsake was locked out until tomorrow.
|
||||||
|
await db.setExportReleased('e2e-test-event', true);
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
export_rate_enabled: 'true',
|
||||||
|
export_rate_per_day: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mintAndFetch = async (jwt: string) => {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
const { ticket } = await res.json();
|
||||||
|
return fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = await guest('ExportFirst');
|
||||||
|
const b = await guest('ExportSecond');
|
||||||
|
|
||||||
|
// A spends their single daily allowance. The archive itself may not exist (404) —
|
||||||
|
// what matters is that the limiter admitted the request rather than 429ing it.
|
||||||
|
expect((await mintAndFetch(a.jwt)).status).not.toBe(429);
|
||||||
|
expect((await mintAndFetch(a.jwt)).status, 'A’s second download is throttled').toBe(429);
|
||||||
|
|
||||||
|
// B shares A's IP and must still get their keepsake.
|
||||||
|
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by A’s download').not.toBe(
|
||||||
|
429
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the host too, for good measure.
|
||||||
|
expect((await mintAndFetch(host.jwt)).status).not.toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Rate limits — /recover name cycling', () => {
|
||||||
|
test('cycling names from one IP hits the ceiling, while one name is still throttled', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
// /recover is keyed `recover:{ip}:{name}` — right for its job (stopping someone who
|
||||||
|
// knows a display name from burning the victim's 3-strike PIN counter), but the name is
|
||||||
|
// ATTACKER-CHOSEN, so cycling names minted a fresh bucket every time. Behind it sits a
|
||||||
|
// cost-12 bcrypt verify, including an unconditional throwaway one for unknown names, so
|
||||||
|
// a name generator was the cheapest way to make the server hash forever.
|
||||||
|
//
|
||||||
|
// Squeeze the ceiling so the flood is reproducible without firing 30+ requests.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
recover_rate_enabled: 'true',
|
||||||
|
recover_ip_rate_per_min: '5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const attempt = (name: string) =>
|
||||||
|
fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name, pin: '0000' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every name is distinct, so the per-name bucket can never fire — only the ceiling can.
|
||||||
|
const codes: number[] = [];
|
||||||
|
for (let i = 0; i < 12; i++) codes.push((await attempt(`Unbekannt${i}_${Date.now()}`)).status);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
codes.filter((c) => c === 429).length,
|
||||||
|
'name cycling must be capped by the per-IP ceiling'
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const throttled = await attempt(`Unbekannt99_${Date.now()}`);
|
||||||
|
expect(throttled.status).toBe(429);
|
||||||
|
expect(Number(throttled.headers.get('retry-after'))).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the per-name bucket still protects a real account', async ({ api, adminToken, guest }) => {
|
||||||
|
// The ceiling must not have REPLACED the anti-guessing control. With a generous ceiling,
|
||||||
|
// repeated wrong PINs against ONE name must still be shut down by the per-name bucket.
|
||||||
|
const victim = await guest('PinVictim');
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
recover_rate_enabled: 'true',
|
||||||
|
recover_ip_rate_per_min: '1000',
|
||||||
|
});
|
||||||
|
|
||||||
|
const attempt = () =>
|
||||||
|
fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: victim.displayName, pin: '9999' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codes: number[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) codes.push((await attempt()).status);
|
||||||
|
expect(codes.at(-1), 'guessing one name must still be throttled').toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
219
e2e/specs/02-upload/burst-queue.spec.ts
Normal file
219
e2e/specs/02-upload/burst-queue.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Client upload-queue under a realistic burst — the scenario an event actually
|
||||||
|
* produces (a guest multi-selects 10-20 photos at once) and the one the
|
||||||
|
* server-side load test could NOT cover, because that test hit POST /upload
|
||||||
|
* directly and bypassed the browser queue entirely.
|
||||||
|
*
|
||||||
|
* Drives the REAL client path (UploadSheet → /upload → addToQueue →
|
||||||
|
* processQueue → XHR) and asserts the four properties that matter for not
|
||||||
|
* losing a guest's photos:
|
||||||
|
*
|
||||||
|
* 1. SERIAL drain — the queue uploads ONE file at a time per device
|
||||||
|
* (processQueue's find-next-pending loop), never a
|
||||||
|
* parallel fan-out. Proven by counting how many upload
|
||||||
|
* requests sit in the intercept handler at once.
|
||||||
|
* 2. PERSISTENCE — every staged file is written to IndexedDB with its
|
||||||
|
* blob before it's sent, and the blob is dropped only
|
||||||
|
* after the upload succeeds. Read straight out of IDB
|
||||||
|
* mid-burst.
|
||||||
|
* 3. ALL LAND — every file in the burst is created server-side.
|
||||||
|
* 4. RESUME ON RELOAD — a hard reload mid-burst (tab closed / PWA killed)
|
||||||
|
* resumes the remaining uploads from IndexedDB via
|
||||||
|
* loadQueue(), losing nothing.
|
||||||
|
*
|
||||||
|
* A tiny per-upload latency is injected via route interception so the serial
|
||||||
|
* drain is observable and there's a window to reload mid-burst — no need for
|
||||||
|
* large fixture files.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
|
||||||
|
import { FeedPage, UploadSheet } from '../../page-objects';
|
||||||
|
import { mkdtempSync, copyFileSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||||
|
const BURST = 16; // in the 10-20 "multi-select" range
|
||||||
|
|
||||||
|
// Make BURST distinct files. The queue dedupes on name+size+lastModified, so
|
||||||
|
// distinct NAMES are enough to keep them from collapsing into one item — content
|
||||||
|
// can be identical (we only assert rows are created, not what they decode to).
|
||||||
|
function makeBurstFiles(): { dir: string; paths: string[] } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-burst-'));
|
||||||
|
const paths = Array.from({ length: BURST }, (_, i) => {
|
||||||
|
const p = join(dir, `burst_${String(i).padStart(2, '0')}.jpg`);
|
||||||
|
copyFileSync(SAMPLE_JPG, p);
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
return { dir, paths };
|
||||||
|
}
|
||||||
|
|
||||||
|
// White-box: read the queue rows straight from IndexedDB, including whether the
|
||||||
|
// blob is still attached — so we can prove blobs are kept until upload and
|
||||||
|
// dropped after. Mirrors the reader in offline-resume.spec.
|
||||||
|
async function queueRows(page: Page): Promise<Array<{ status: string; hasBlob: boolean }>> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
return new Promise<Array<{ status: string; hasBlob: boolean }>>((resolve, reject) => {
|
||||||
|
const req = indexedDB.open('eventsnap-uploads', 3);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const dbh = req.result;
|
||||||
|
const tx = dbh.transaction('queue', 'readonly');
|
||||||
|
const all = tx.objectStore('queue').getAll();
|
||||||
|
all.onsuccess = () =>
|
||||||
|
resolve(all.result.map((r: any) => ({ status: r.status, hasBlob: r.blob != null })));
|
||||||
|
all.onerror = () => reject(all.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept POST /upload to (a) inject latency so the serial drain is observable
|
||||||
|
* and (b) count how many uploads are in the handler simultaneously. A serial
|
||||||
|
* client never has more than one in flight; a parallel fan-out would spike.
|
||||||
|
* Returns a `peak()` accessor for the max concurrency seen.
|
||||||
|
*/
|
||||||
|
async function instrumentUploads(page: Page, delayMs: number) {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
await page.route('**/api/v1/upload', async (route) => {
|
||||||
|
active++;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
active--;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
return { peak: () => peak };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmUploadChunk(page: Page) {
|
||||||
|
// Load the code-split /upload route while online so a later reload/navigation
|
||||||
|
// doesn't fetch the chunk at a bad moment (mirrors offline-resume.spec).
|
||||||
|
await page.goto('/upload');
|
||||||
|
await page.goto('/feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Upload — client queue under a burst', () => {
|
||||||
|
test('a 16-file burst drains serially, persists to IndexedDB, and all land', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
skipIfNoIdbBlobs(browserName);
|
||||||
|
const g = await guest('BurstSerial');
|
||||||
|
await signIn(page, g);
|
||||||
|
await warmUploadChunk(page);
|
||||||
|
const uploads = await instrumentUploads(page, 200);
|
||||||
|
|
||||||
|
// Stage the whole burst through the real UI.
|
||||||
|
const { dir, paths } = makeBurstFiles();
|
||||||
|
try {
|
||||||
|
const feed = new FeedPage(page);
|
||||||
|
const sheet = new UploadSheet(page);
|
||||||
|
await feed.openUploadSheet();
|
||||||
|
await sheet.stageFiles(paths);
|
||||||
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
||||||
|
await sheet.fillCaption('burst of 16 #hochzeit');
|
||||||
|
await sheet.submit();
|
||||||
|
await page.waitForURL('**/feed', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// (2) PERSISTENCE: all 16 are written to IndexedDB with blobs essentially
|
||||||
|
// immediately — before most have been sent. Catch the burst while the
|
||||||
|
// queue still holds most of them.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await queueRows(page)).length, { timeout: 10_000 })
|
||||||
|
.toBe(BURST);
|
||||||
|
|
||||||
|
// While draining, pending rows keep their blob; done rows have dropped it.
|
||||||
|
// Poll for a mid-burst moment that shows both invariants at once.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const rows = await queueRows(page);
|
||||||
|
const pendingKeepBlob = rows
|
||||||
|
.filter((r) => r.status === 'pending' || r.status === 'uploading')
|
||||||
|
.every((r) => r.hasBlob);
|
||||||
|
const doneDropBlob = rows.filter((r) => r.status === 'done').every((r) => !r.hasBlob);
|
||||||
|
return pendingKeepBlob && doneDropBlob;
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 }
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// (3) ALL LAND: every file is created server-side.
|
||||||
|
await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 }).toBe(BURST);
|
||||||
|
|
||||||
|
// (1) SERIAL: never more than one upload in flight at a time.
|
||||||
|
expect(uploads.peak()).toBe(1);
|
||||||
|
|
||||||
|
// Clean end — the queue settles all items to done (no stuck error/blocked).
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await queueRows(page)).every((r) => r.status === 'done'), {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hard reload mid-burst resumes the remaining uploads — nothing lost', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
skipIfNoIdbBlobs(browserName);
|
||||||
|
const g = await guest('BurstResume');
|
||||||
|
await signIn(page, g);
|
||||||
|
await warmUploadChunk(page);
|
||||||
|
// Bigger delay → a wide, reliable mid-burst window to reload inside. (We don't
|
||||||
|
// assert seriality here — test 1 already proves it, and a reload aborts the
|
||||||
|
// in-flight request mid-intercept, which would overcount concurrency.)
|
||||||
|
await instrumentUploads(page, 300);
|
||||||
|
|
||||||
|
const { dir, paths } = makeBurstFiles();
|
||||||
|
try {
|
||||||
|
const feed = new FeedPage(page);
|
||||||
|
const sheet = new UploadSheet(page);
|
||||||
|
await feed.openUploadSheet();
|
||||||
|
await sheet.stageFiles(paths);
|
||||||
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
||||||
|
await sheet.fillCaption('burst then reload');
|
||||||
|
await sheet.submit();
|
||||||
|
await page.waitForURL('**/feed', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Wait until we're genuinely mid-burst: a few landed, but far from all.
|
||||||
|
await expect
|
||||||
|
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
|
||||||
|
.toBeGreaterThanOrEqual(3);
|
||||||
|
const landedBeforeReload = await db.countUploadsForUser(g.userId);
|
||||||
|
expect(landedBeforeReload).toBeLessThan(BURST);
|
||||||
|
|
||||||
|
// Hard reload — wipes the JS module (and its in-flight drain), exactly like
|
||||||
|
// a closed tab / killed PWA. The remaining pending items live only in
|
||||||
|
// IndexedDB now.
|
||||||
|
await page.reload();
|
||||||
|
// The queue only resumes where loadQueue() runs — the /upload route's
|
||||||
|
// onMount. Navigating there is the "reopen the composer" recovery path.
|
||||||
|
await page.goto('/upload');
|
||||||
|
|
||||||
|
// (4) RESUME: every file ends up server-side without re-staging anything.
|
||||||
|
// `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload
|
||||||
|
// that succeeded server-side in the ~1ms between the XHR load event and the
|
||||||
|
// IndexedDB 'done' write, caught by the reload, then re-sent on resume).
|
||||||
|
// That's at worst one extra row, never a lost photo — the property we care
|
||||||
|
// about. A shortfall (< BURST) would be a real data-loss regression.
|
||||||
|
await expect
|
||||||
|
.poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 })
|
||||||
|
.toBeGreaterThanOrEqual(BURST);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
80
e2e/specs/02-upload/exif-orientation.spec.ts
Normal file
80
e2e/specs/02-upload/exif-orientation.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — EXIF orientation must be applied when generating derivatives.
|
||||||
|
*
|
||||||
|
* Phones do not rotate sensor data. They shoot in the sensor's native landscape and record
|
||||||
|
* how the camera was held in an EXIF `Orientation` tag. `image`'s `decode()` returns the raw
|
||||||
|
* pixels and ignores that tag, and the JPEG re-encode writes no EXIF at all — so every
|
||||||
|
* portrait photo was stored SIDEWAYS in the 800px feed preview, the 2048px diashow display
|
||||||
|
* and the keepsake, while "Original anzeigen" still rendered it upright (the original keeps
|
||||||
|
* its tag). That asymmetry is why it reads as a viewer bug instead of a pipeline one.
|
||||||
|
*
|
||||||
|
* The fixture is 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"),
|
||||||
|
* so a correctly-processed derivative is PORTRAIT (20x40). Asserting on the aspect ratio
|
||||||
|
* rather than the bytes keeps this robust across encoder changes.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a baseline/progressive JPEG's pixel dimensions from its SOF marker.
|
||||||
|
* Avoids pulling an image dependency into the suite for one assertion.
|
||||||
|
*/
|
||||||
|
function jpegSize(buf: Buffer): { width: number; height: number } {
|
||||||
|
let i = 2; // skip SOI
|
||||||
|
while (i < buf.length) {
|
||||||
|
if (buf[i] !== 0xff) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const marker = buf[i + 1];
|
||||||
|
// SOF0..SOF15, excluding DHT (c4), JPGA (c8) and DAC (cc)
|
||||||
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||||
|
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
||||||
|
}
|
||||||
|
i += 2 + buf.readUInt16BE(i + 2);
|
||||||
|
}
|
||||||
|
throw new Error('no SOF marker found — not a JPEG?');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Upload — EXIF orientation', () => {
|
||||||
|
test('a rotated photo is upright in the preview and the display derivative', async ({
|
||||||
|
guest,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
const g = await guest('SidewaysShooter');
|
||||||
|
|
||||||
|
const res = await uploadRaw(g.jwt, readFileSync(EXIF_FIXTURE), {
|
||||||
|
filename: 'portrait-exif6.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
caption: 'hochkant',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const { id } = (await res.json()) as { id: string };
|
||||||
|
|
||||||
|
// Sanity: the SOURCE really is stored landscape with the tag, otherwise this test
|
||||||
|
// could pass against a pipeline that does nothing.
|
||||||
|
const source = jpegSize(readFileSync(EXIF_FIXTURE));
|
||||||
|
expect(source.width).toBeGreaterThan(source.height);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => db.compressionStatus(id), { timeout: 30_000, intervals: [250] })
|
||||||
|
.toBe('done');
|
||||||
|
|
||||||
|
for (const variant of ['preview', 'display'] as const) {
|
||||||
|
const r = await fetch(`${BASE}/api/v1/upload/${id}/${variant}`, {
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
});
|
||||||
|
expect(r.status, `${variant} must be served`).toBe(200);
|
||||||
|
const { width, height } = jpegSize(Buffer.from(await r.arrayBuffer()));
|
||||||
|
expect(
|
||||||
|
height,
|
||||||
|
`${variant} must be portrait (${width}x${height}) — EXIF orientation was not applied`
|
||||||
|
).toBeGreaterThan(width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* IndexedDB queue resumption after refresh, and SSE `upload-processed`.
|
* IndexedDB queue resumption after refresh, and SSE `upload-processed`.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
|
||||||
import { FeedPage, UploadSheet } from '../../page-objects';
|
import { FeedPage, UploadSheet } from '../../page-objects';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -49,7 +50,9 @@ test.describe('Upload — gallery path', () => {
|
|||||||
guest,
|
guest,
|
||||||
signIn,
|
signIn,
|
||||||
db,
|
db,
|
||||||
|
browserName,
|
||||||
}) => {
|
}) => {
|
||||||
|
skipIfNoIdbBlobs(browserName);
|
||||||
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
||||||
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
||||||
// `upgrade` callback opened a *new* transaction, which throws during a
|
// `upgrade` callback opened a *new* transaction, which throws during a
|
||||||
|
|||||||
100
e2e/specs/02-upload/oversized-image.spec.ts
Normal file
100
e2e/specs/02-upload/oversized-image.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — an image that would blow the decode budget must be refused at the
|
||||||
|
* door, with a reason the guest can act on, and must never allocate.
|
||||||
|
*
|
||||||
|
* Two defects met here.
|
||||||
|
*
|
||||||
|
* 1. The budget was inert. `max_alloc = 256 MiB` was set, but reading the EXIF orientation
|
||||||
|
* tag requires `ImageReader::into_decoder()`, which skips the
|
||||||
|
* `limits.reserve(decoder.total_bytes())` that `decode()` performs — and nothing else
|
||||||
|
* enforces it (the JPEG decoder's `set_limits` only checks support and dimensions). The
|
||||||
|
* only real bound was the 12000px per-axis cap, leaving two concurrent decodes at
|
||||||
|
* 824 MiB against a 1 GiB container. This suite could not have caught it either, because
|
||||||
|
* the e2e app container had NO memory limit while production is capped at 1 GiB; that cap
|
||||||
|
* is now mirrored in docker-compose.test.yml so these assertions mean something.
|
||||||
|
*
|
||||||
|
* 2. Even with the budget restored, the upload was ACCEPTED with a 201 and then silently
|
||||||
|
* soft-deleted minutes later when the worker gave up — the photo simply vanished, with at
|
||||||
|
* best a vague "could not be processed". Admission now runs the same budget check against
|
||||||
|
* the header, so the guest is told immediately and told why.
|
||||||
|
*
|
||||||
|
* Fixture: 11000x9000 = 99 MP, 568 KiB on disk. Deliberately UNDER the per-axis cap, so the
|
||||||
|
* axis check cannot be what rejects it — 283 MiB decoded against a 256 MiB budget. A fixture
|
||||||
|
* at 13000px would pass this test against a build with no budget at all.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const HUGE = join(process.cwd(), 'fixtures', 'media', 'huge-99mp.jpg');
|
||||||
|
const SAMPLE = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||||
|
|
||||||
|
test.describe('Upload — an oversized image is refused, not allocated', () => {
|
||||||
|
test('a 99 MP upload is rejected at admission with a readable reason', async ({ guest, db }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const g = await guest('BombThrower');
|
||||||
|
const before = await db.countUploadsForUser(g.userId);
|
||||||
|
|
||||||
|
const res = await uploadRaw(g.jwt, readFileSync(HUGE), {
|
||||||
|
filename: 'huge.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
caption: 'zu gross',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4xx, not 201-then-vanish. The queue classifies this as terminal, so the guest gets the
|
||||||
|
// message rather than watching the photo disappear.
|
||||||
|
expect(res.status, 'an undecodable image must be refused at the door').toBe(400);
|
||||||
|
const body = (await res.json()) as { message?: string };
|
||||||
|
expect(body.message ?? '', 'the reason must be actionable, not generic').toMatch(/bildpunkte/i);
|
||||||
|
expect(body.message ?? '', 'and should name the size so it is obvious why').toMatch(/99/);
|
||||||
|
|
||||||
|
// Nothing was stored — no row to soft-delete later, no orphaned file to sweep.
|
||||||
|
expect(await db.countUploadsForUser(g.userId)).toBe(before);
|
||||||
|
|
||||||
|
// The backend never allocated: it is still alive and still doing useful work.
|
||||||
|
expect((await fetch(`${BASE}/health`)).status).toBe(200);
|
||||||
|
const ok = await uploadRaw(g.jwt, readFileSync(SAMPLE), {
|
||||||
|
filename: 'after.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(ok.status).toBe(201);
|
||||||
|
const after = (await ok.json()) as { id: string };
|
||||||
|
await expect.poll(() => db.compressionStatus(after.id), { timeout: 30_000 }).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burst of oversized uploads leaves the container alive', async ({ guest }) => {
|
||||||
|
// The concurrent case is the one that OOM'd: `compression_concurrency` is 2, so decodes
|
||||||
|
// overlapped. Four at once is comfortably past that, and must still cost only header
|
||||||
|
// reads.
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const g = await guest('BombThrower2');
|
||||||
|
const bytes = readFileSync(HUGE);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 4 }, (_, i) =>
|
||||||
|
uploadRaw(g.jwt, bytes, { filename: `huge-${i}.jpg`, contentType: 'image/jpeg' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(results.map((r) => r.status)).toEqual([400, 400, 400, 400]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await fetch(`${BASE}/health`)).status,
|
||||||
|
'concurrent oversized uploads must not kill the backend'
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ordinary photo is unaffected by the admission check', async ({ guest, db }) => {
|
||||||
|
// The mirror that keeps the check honest: a budget that rejected everything would pass
|
||||||
|
// both tests above.
|
||||||
|
const g = await guest('NormalShooter');
|
||||||
|
const res = await uploadRaw(g.jwt, readFileSync(SAMPLE), {
|
||||||
|
filename: 'normal.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const { id } = (await res.json()) as { id: string };
|
||||||
|
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,14 +56,21 @@ function upload(jwt: string, name: string) {
|
|||||||
/**
|
/**
|
||||||
* Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`.
|
* Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`.
|
||||||
* limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free.
|
* limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free.
|
||||||
|
*
|
||||||
|
* `staffJwt` reads the calibration inputs, `jwt` is the guest the limit is being aimed at.
|
||||||
|
* They must be different tokens: `free_disk_bytes` and `active_uploaders` are raw server
|
||||||
|
* telemetry and `/me/quota` zeroes both for non-staff (handlers/me.rs — "must never reach a
|
||||||
|
* guest"). Calibrating off the guest's own response divides by zero and yields a NaN
|
||||||
|
* tolerance, which is what silently broke this whole describe block.
|
||||||
*/
|
*/
|
||||||
async function setLimitTo(
|
async function setLimitTo(
|
||||||
api: any,
|
api: any,
|
||||||
adminToken: string,
|
adminToken: string,
|
||||||
|
staffJwt: string,
|
||||||
jwt: string,
|
jwt: string,
|
||||||
targetBytes: number
|
targetBytes: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const q = await quotaOf(jwt);
|
const q = await quotaOf(staffJwt);
|
||||||
expect(
|
expect(
|
||||||
q.free_disk_bytes,
|
q.free_disk_bytes,
|
||||||
'the disk must be readable, else quota fails OPEN and proves nothing'
|
'the disk must be readable, else quota fails OPEN and proves nothing'
|
||||||
@@ -72,6 +79,7 @@ async function setLimitTo(
|
|||||||
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
||||||
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
||||||
|
|
||||||
|
// Read back through the GUEST, whose ceiling is the one under test.
|
||||||
const after = await quotaOf(jwt);
|
const after = await quotaOf(jwt);
|
||||||
expect(after.enabled).toBe(true);
|
expect(after.enabled).toBe(true);
|
||||||
return after.limit_bytes as number;
|
return after.limit_bytes as number;
|
||||||
@@ -89,10 +97,11 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
const g = await guest('QuotaOver');
|
const g = await guest('QuotaOver');
|
||||||
// Ceiling below one file: the very first upload must be refused.
|
// Ceiling below one file: the very first upload must be refused.
|
||||||
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE / 2));
|
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE / 2));
|
||||||
expect(limit).toBeLessThan(SIZE);
|
expect(limit).toBeLessThan(SIZE);
|
||||||
|
|
||||||
const res = await upload(g.jwt, 'too-big.jpg');
|
const res = await upload(g.jwt, 'too-big.jpg');
|
||||||
@@ -111,9 +120,10 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
const g = await guest('QuotaUnder');
|
const g = await guest('QuotaUnder');
|
||||||
await setLimitTo(api, adminToken, g.jwt, SIZE * 4);
|
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 4);
|
||||||
|
|
||||||
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
|
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
|
||||||
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
|
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
|
||||||
@@ -123,11 +133,12 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
const g = await guest('QuotaRacer');
|
const g = await guest('QuotaRacer');
|
||||||
|
|
||||||
// Room for exactly ONE file.
|
// Room for exactly ONE file.
|
||||||
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE * 1.5));
|
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE * 1.5));
|
||||||
expect(limit).toBeGreaterThanOrEqual(SIZE);
|
expect(limit).toBeGreaterThanOrEqual(SIZE);
|
||||||
expect(limit).toBeLessThan(SIZE * 2);
|
expect(limit).toBeLessThan(SIZE * 2);
|
||||||
|
|
||||||
@@ -170,10 +181,11 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
|
host,
|
||||||
}) => {
|
}) => {
|
||||||
// Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget.
|
// Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget.
|
||||||
const g = await guest('QuotaWidget');
|
const g = await guest('QuotaWidget');
|
||||||
await setLimitTo(api, adminToken, g.jwt, SIZE * 10);
|
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 10);
|
||||||
|
|
||||||
const before = await quotaOf(g.jwt);
|
const before = await quotaOf(g.jwt);
|
||||||
expect(before.enabled).toBe(true);
|
expect(before.enabled).toBe(true);
|
||||||
|
|||||||
87
e2e/specs/02-upload/rejection-visible.spec.ts
Normal file
87
e2e/specs/02-upload/rejection-visible.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — a rejected upload must tell the user something.
|
||||||
|
*
|
||||||
|
* `UploadQueue.svelte` was 162 lines of complete, working UI — the only renderer of an
|
||||||
|
* item's error text, the only "Erneut" retry button, the only rate-limit countdown — and it
|
||||||
|
* was never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were all
|
||||||
|
* unreachable at runtime. On a terminal rejection the store dropped the blob, wrote a clear
|
||||||
|
* German reason into `entry.error` with the comment "so the UI shows a clear reason", and
|
||||||
|
* there was no such UI.
|
||||||
|
*
|
||||||
|
* Meanwhile the FAB badge counted only pending/uploading, so a rejected photo decremented it
|
||||||
|
* exactly as if it had succeeded. Net effect: the photo silently vanished — no toast, no
|
||||||
|
* queue row, no error text, and it never appeared in the feed.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
|
||||||
|
import { FeedPage, UploadSheet } from '../../page-objects';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||||
|
|
||||||
|
test.describe('Upload — a rejected upload is surfaced', () => {
|
||||||
|
test('a terminally rejected upload toasts, and stays visible in the queue', async ({
|
||||||
|
page,
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
skipIfNoIdbBlobs(browserName);
|
||||||
|
const g = await guest('RejectedUploader');
|
||||||
|
await signIn(page, g);
|
||||||
|
|
||||||
|
const feed = new FeedPage(page);
|
||||||
|
const sheet = new UploadSheet(page);
|
||||||
|
await feed.openUploadSheet();
|
||||||
|
await sheet.stageFiles([SAMPLE_JPG]);
|
||||||
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
||||||
|
|
||||||
|
// Ban the uploader between staging and sending, so the POST comes back 403 — a
|
||||||
|
// terminal 4xx the server will keep rejecting, which is the path that purges the blob.
|
||||||
|
await api.banUser(host.jwt, g.userId);
|
||||||
|
|
||||||
|
await sheet.submit();
|
||||||
|
|
||||||
|
// 1. The user is told, wherever they are (the flow lands them on /feed).
|
||||||
|
const toast = page.getByRole('region', { name: 'Benachrichtigungen' });
|
||||||
|
await expect(toast).toContainText(/sample\.jpg/i, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// 2. The queue row survives with its reason and is reachable on /upload — this is what
|
||||||
|
// the orphaned component made impossible.
|
||||||
|
await page.goto('/upload');
|
||||||
|
const queue = page.getByText('Upload-Warteschlange');
|
||||||
|
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// Both the status chip ("Gesperrt") and the server's reason ("Du bist gesperrt.") must
|
||||||
|
// render — the reason is the part that had no UI at all before.
|
||||||
|
await expect(page.getByText('Gesperrt', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Du bist gesperrt.')).toBeVisible();
|
||||||
|
|
||||||
|
// 3. The badge must not read as success. It counted only pending/uploading before, so a
|
||||||
|
// rejected item dropped it to 0 — indistinguishable from a completed upload.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const req = indexedDB.open('eventsnap-uploads', 3);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const tx = req.result.transaction('queue', 'readonly');
|
||||||
|
const all = tx.objectStore('queue').getAll();
|
||||||
|
all.onsuccess = () =>
|
||||||
|
resolve(
|
||||||
|
all.result.filter((r: { status: string }) => r.status === 'blocked').length
|
||||||
|
);
|
||||||
|
all.onerror = () => reject(all.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{ timeout: 10_000 }
|
||||||
|
)
|
||||||
|
.toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
e2e/specs/03-feed/social-rate-limit.spec.ts
Normal file
136
e2e/specs/03-feed/social-rate-limit.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — likes, comments and comment deletions are rate limited.
|
||||||
|
*
|
||||||
|
* These were the only mutating endpoints in the app with no limit at all. Every other write path
|
||||||
|
* -- upload, join, recover, export, admin login -- carried one; `social.rs` carried none, so the
|
||||||
|
* coverage was asymmetric rather than deliberately open.
|
||||||
|
*
|
||||||
|
* Severity is genuinely low for an invited-guest event, and the amplification worry is contained:
|
||||||
|
* a like does fan an SSE broadcast to every connected client, but the export regeneration a
|
||||||
|
* comment deletion triggers is debounced (REGEN_DEBOUNCE 20s) and superseded workers are inert. So
|
||||||
|
* this closes the gap for symmetry, and the ceiling is set well above anything a real guest
|
||||||
|
* produces -- it bounds a script, not an enthusiastic double-tapper.
|
||||||
|
*
|
||||||
|
* The bucket is shared across all three actions on purpose: separate buckets would let a caller
|
||||||
|
* triple the aggregate write rate just by alternating between them. That is what the second test
|
||||||
|
* pins, and it is the part most likely to be lost in a refactor.
|
||||||
|
*
|
||||||
|
* Keyed per USER, not per IP — at a venue every guest is behind one NAT, so an IP key would hand
|
||||||
|
* the whole party one bucket. Third test.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
const like = (jwt: string, uploadId: string) =>
|
||||||
|
fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = (jwt: string, uploadId: string, body: string) =>
|
||||||
|
fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Social — rate limit', () => {
|
||||||
|
test('a burst of likes past the ceiling returns 429 with Retry-After', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
social_rate_enabled: 'true',
|
||||||
|
social_rate_per_min: '3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const g = await guest('Tapper');
|
||||||
|
const uploadId = await seedUpload(g.jwt);
|
||||||
|
|
||||||
|
// Sequential, not parallel: a toggle flips state, so ordering matters for the assertion.
|
||||||
|
const statuses: number[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) statuses.push((await like(g.jwt, uploadId)).status);
|
||||||
|
|
||||||
|
expect(statuses.slice(0, 3), 'the first three are within the ceiling').toEqual([200, 200, 200]);
|
||||||
|
expect(statuses.slice(3), 'everything past it is refused').toEqual([429, 429]);
|
||||||
|
|
||||||
|
const limited = await like(g.jwt, uploadId);
|
||||||
|
expect(limited.status).toBe(429);
|
||||||
|
expect(
|
||||||
|
limited.headers.get('retry-after'),
|
||||||
|
'a 429 without Retry-After tells the client nothing about when to come back'
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('likes and comments share one bucket', async ({ api, adminToken, guest }) => {
|
||||||
|
// THE assertion. Per-action buckets would let a caller triple the aggregate write rate by
|
||||||
|
// alternating, which defeats the point of having a ceiling at all.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
social_rate_enabled: 'true',
|
||||||
|
social_rate_per_min: '2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const g = await guest('Mixer');
|
||||||
|
const uploadId = await seedUpload(g.jwt);
|
||||||
|
|
||||||
|
expect((await like(g.jwt, uploadId)).status).toBe(200);
|
||||||
|
expect((await comment(g.jwt, uploadId, 'schön!')).status).toBe(201);
|
||||||
|
// Two writes spent, whichever endpoints they went to.
|
||||||
|
expect(
|
||||||
|
(await comment(g.jwt, uploadId, 'noch eins')).status,
|
||||||
|
'a comment must consume the same budget a like does'
|
||||||
|
).toBe(429);
|
||||||
|
expect((await like(g.jwt, uploadId)).status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one guest hitting the ceiling does not block another', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
|
// Keyed per user, not per IP. Every request in this suite comes from one address, which is
|
||||||
|
// exactly the venue-NAT shape that made the /join and /feed limits turn guests away.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
social_rate_enabled: 'true',
|
||||||
|
social_rate_per_min: '2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const noisy = await guest('Noisy');
|
||||||
|
const quiet = await guest('Quiet');
|
||||||
|
const uploadId = await seedUpload(noisy.jwt);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) await like(noisy.jwt, uploadId);
|
||||||
|
expect((await like(noisy.jwt, uploadId)).status).toBe(429);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await like(quiet.jwt, uploadId)).status,
|
||||||
|
'a second guest behind the same IP must have their own budget'
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flipping social_rate_enabled off bypasses the limit', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
|
// The toggle has to actually be honoured, or the admin switch is decorative — the failure
|
||||||
|
// mode two other per-area toggles already shipped with.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
social_rate_enabled: 'false',
|
||||||
|
social_rate_per_min: '2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const g = await guest('Unlimited');
|
||||||
|
const uploadId = await seedUpload(g.jwt);
|
||||||
|
|
||||||
|
const statuses: number[] = [];
|
||||||
|
for (let i = 0; i < 6; i++) statuses.push((await like(g.jwt, uploadId)).status);
|
||||||
|
expect(statuses.every((s) => s === 200)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
e2e/specs/03-feed/video-playback.spec.ts
Normal file
181
e2e/specs/03-feed/video-playback.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — videos must actually play.
|
||||||
|
*
|
||||||
|
* Two independent defects made every video unplayable, and nothing in the suite covered
|
||||||
|
* either one (no test anywhere played media or asserted a `<video>` src):
|
||||||
|
*
|
||||||
|
* 1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
|
||||||
|
* Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
|
||||||
|
* the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
|
||||||
|
* served as image/jpeg with nosniff so the browser can't even sniff its way out.
|
||||||
|
* Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
|
||||||
|
*
|
||||||
|
* 2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
|
||||||
|
* an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
|
||||||
|
* `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
|
||||||
|
* app's primary platform even in `original` data mode.
|
||||||
|
*
|
||||||
|
* Fixing either alone still leaves video broken, so both are asserted here.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');
|
||||||
|
|
||||||
|
async function seedVideo(jwt: string): Promise<string> {
|
||||||
|
const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
|
||||||
|
filename: 'clip.mp4',
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
caption: 'ein Video',
|
||||||
|
});
|
||||||
|
if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Video — the lightbox plays it', () => {
|
||||||
|
test('the <video> src is the original, not the thumbnail JPEG', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
const g = await guest('VideoWatcher');
|
||||||
|
const id = await seedVideo(g.jwt);
|
||||||
|
|
||||||
|
// The poster assertion below needs the ffmpeg thumbnail to EXIST — the lightbox binds
|
||||||
|
// `poster={upload.thumbnail_url ?? undefined}`, so the attribute is simply absent until
|
||||||
|
// compression finishes. Without this wait the test races the worker and fails against a
|
||||||
|
// cold stack (first run after `stack:down -v`, cold ffmpeg), which is exactly when a suite
|
||||||
|
// is least likely to be believed. The `src` assertion is unconditional; only the poster
|
||||||
|
// needs the wait.
|
||||||
|
await expect.poll(() => db.compressionStatus(id), { timeout: 60_000 }).toBe('done');
|
||||||
|
|
||||||
|
await signIn(page, g);
|
||||||
|
await page.goto('/feed');
|
||||||
|
|
||||||
|
const card = page.locator('article').first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||||
|
|
||||||
|
const video = page.locator('video');
|
||||||
|
await expect(video).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The bug in one assertion: this was `/thumbnail` in the default data mode.
|
||||||
|
await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);
|
||||||
|
|
||||||
|
// The poster SHOULD still be the thumbnail — that's what it's for.
|
||||||
|
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
|
||||||
|
|
||||||
|
// And the browser must accept the bytes as media. preload="none" means nothing is
|
||||||
|
// fetched until we ask, so drive a load explicitly and wait for metadata.
|
||||||
|
const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
|
||||||
|
el.preload = 'metadata';
|
||||||
|
el.load();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (el.readyState > 0) return resolve();
|
||||||
|
el.addEventListener('loadedmetadata', () => resolve(), { once: true });
|
||||||
|
el.addEventListener('error', () => resolve(), { once: true });
|
||||||
|
setTimeout(resolve, 15_000);
|
||||||
|
});
|
||||||
|
return { ready: el.readyState, err: el.error?.message ?? null };
|
||||||
|
});
|
||||||
|
expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
|
||||||
|
expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the video body is not downloaded before the user presses play', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// saver mode exists to protect a guest's mobile data, and there is no smaller video
|
||||||
|
// derivative to fall back to — so opening the lightbox must not pull the file down.
|
||||||
|
//
|
||||||
|
// Asserting "no request at all" would be wrong: WebKit opens a connection for a
|
||||||
|
// preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
|
||||||
|
// response status 0, nothing transferred), whereas Chromium issues nothing. The
|
||||||
|
// portable guarantee — and the one that actually protects the data plan — is that no
|
||||||
|
// response carrying the body ever completes. Dropping preload="none" fails this.
|
||||||
|
const g = await guest('VideoThrifty');
|
||||||
|
const id = await seedVideo(g.jwt);
|
||||||
|
|
||||||
|
await signIn(page, g);
|
||||||
|
const delivered: string[] = [];
|
||||||
|
page.on('response', (r) => {
|
||||||
|
if (!r.url().includes(`/upload/${id}/original`)) return;
|
||||||
|
// status 0 = aborted before any bytes landed.
|
||||||
|
if (r.status() === 200 || r.status() === 206) {
|
||||||
|
delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/feed');
|
||||||
|
const card = page.locator('article').first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||||
|
await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Media — HTTP Range', () => {
|
||||||
|
test('a range request is answered 206 with the right slice', async ({ guest }) => {
|
||||||
|
const g = await guest('RangeReader');
|
||||||
|
const id = await seedVideo(g.jwt);
|
||||||
|
const url = `${BASE}/api/v1/upload/${id}/original`;
|
||||||
|
|
||||||
|
const full = await fetch(url);
|
||||||
|
expect(full.status).toBe(200);
|
||||||
|
expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
|
||||||
|
const total = Number(full.headers.get('content-length'));
|
||||||
|
expect(total).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
|
||||||
|
const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
|
||||||
|
expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
|
||||||
|
expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
|
||||||
|
expect(Number(probe.headers.get('content-length'))).toBe(2);
|
||||||
|
expect((await probe.arrayBuffer()).byteLength).toBe(2);
|
||||||
|
|
||||||
|
// A mid-file seek must return the matching slice, not the whole body.
|
||||||
|
const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
|
||||||
|
expect(mid.status).toBe(206);
|
||||||
|
expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
|
||||||
|
const midBytes = Buffer.from(await mid.arrayBuffer());
|
||||||
|
expect(midBytes.byteLength).toBe(10);
|
||||||
|
expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));
|
||||||
|
|
||||||
|
// Open-ended range runs to EOF.
|
||||||
|
const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
|
||||||
|
expect(tail.status).toBe(206);
|
||||||
|
expect(Number(tail.headers.get('content-length'))).toBe(5);
|
||||||
|
|
||||||
|
// Past EOF must be 416 — answering 200 makes a player re-request forever.
|
||||||
|
const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
|
||||||
|
expect(bad.status).toBe(416);
|
||||||
|
expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image derivatives are range-capable too', async ({ guest, db }) => {
|
||||||
|
// Same helper serves all four media routes, so the guarantee is uniform.
|
||||||
|
const g = await guest('RangeImages');
|
||||||
|
const res = await uploadRaw(
|
||||||
|
g.jwt,
|
||||||
|
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
|
||||||
|
{ filename: 'r.jpg', contentType: 'image/jpeg' }
|
||||||
|
);
|
||||||
|
const { id } = (await res.json()) as { id: string };
|
||||||
|
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||||
|
|
||||||
|
const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
|
||||||
|
headers: { Range: 'bytes=0-9' },
|
||||||
|
});
|
||||||
|
expect(preview.status).toBe(206);
|
||||||
|
expect(Number(preview.headers.get('content-length'))).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
e2e/specs/04-host/low-disk-warning.spec.ts
Normal file
97
e2e/specs/04-host/low-disk-warning.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — the host is warned about storage BEFORE it becomes unrecoverable.
|
||||||
|
*
|
||||||
|
* Storage visibility used to exist in exactly one place: a passive "Speicherauslastung" widget on
|
||||||
|
* the ADMIN dashboard. A host who isn't the admin had no view of it at all, and nothing anywhere
|
||||||
|
* warned anyone. README listed a low-disk alert under "Planned (v1.x)".
|
||||||
|
*
|
||||||
|
* Two things make that a safety net rather than a nice-to-have:
|
||||||
|
*
|
||||||
|
* - `postgres_data`, `media_data` and `exports_data` are all Docker named volumes on ONE
|
||||||
|
* filesystem. A full disk doesn't degrade a subsystem; Postgres stops being able to write and
|
||||||
|
* the whole event goes down.
|
||||||
|
* - The keepsake needs room for TWO gallery-sized archives (both write their media
|
||||||
|
* `Compression::Stored`; `Memories.zip` streams the original for every video and every image
|
||||||
|
* at or under 5 MB). The export preflight can refuse cleanly, but only AFTER the release —
|
||||||
|
* when the event is over, the gallery is full, and every remedy is harder.
|
||||||
|
*
|
||||||
|
* So the threshold is deliberately NOT a fixed number alone. It fires on an absolute floor (10 GB,
|
||||||
|
* the figure the README always carried) OR on "you could not build the keepsake right now", which
|
||||||
|
* is the trigger a host can still act on.
|
||||||
|
*
|
||||||
|
* These drive it through `original_size_bytes` rather than a genuinely full disk: the estimate is
|
||||||
|
* pure SQL over that column, so overstating one row moves the accounting the warning reads without
|
||||||
|
* touching a byte on disk.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
/** Comfortably larger than any disk this suite could run on. */
|
||||||
|
const ABSURD_BYTES = 500_000_000_000_000;
|
||||||
|
|
||||||
|
test.describe('Host — low-disk warning', () => {
|
||||||
|
test('a gallery too big to export warns the host, with the numbers', async ({
|
||||||
|
page,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
const g = await guest('BigShooter');
|
||||||
|
const uploadId = await seedUpload(g.jwt);
|
||||||
|
await db.setUploadSizeBytes(uploadId, ABSURD_BYTES);
|
||||||
|
|
||||||
|
await signIn(page, host);
|
||||||
|
await page.goto('/host');
|
||||||
|
|
||||||
|
const warning = page.getByTestId('low-disk-warning');
|
||||||
|
await expect(warning, 'the host must be warned before releasing').toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
// The actionable half: not just "low", but "the keepsake cannot be built".
|
||||||
|
await expect(warning).toContainText(/nicht.*erstellt werden/i);
|
||||||
|
// And the consequence that makes it urgent — the event, not just the download.
|
||||||
|
await expect(warning).toContainText(/gesamte Event/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the API reports the requirement and the verdict together', async ({ host, guest, db }) => {
|
||||||
|
const g = await guest('BigShooter2');
|
||||||
|
const uploadId = await seedUpload(g.jwt);
|
||||||
|
await db.setUploadSizeBytes(uploadId, ABSURD_BYTES);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/api/v1/host/event`, {
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
disk_low: boolean;
|
||||||
|
disk_free_bytes: number | null;
|
||||||
|
keepsake_required_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.disk_low).toBe(true);
|
||||||
|
expect(
|
||||||
|
body.keepsake_required_bytes,
|
||||||
|
'both halves are armed by a release, so the requirement covers two archives'
|
||||||
|
).toBeGreaterThan(ABSURD_BYTES);
|
||||||
|
expect(body.disk_free_bytes).not.toBeNull();
|
||||||
|
expect(body.keepsake_required_bytes).toBeGreaterThan(body.disk_free_bytes!);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ordinary gallery shows no warning at all', async ({ page, host, guest, signIn }) => {
|
||||||
|
// The mirror that keeps the above honest. A warning that is always on is a warning nobody
|
||||||
|
// reads — and it would sit at the very top of the dashboard, above the PIN-reset queue.
|
||||||
|
const g = await guest('NormalShooter');
|
||||||
|
await seedUpload(g.jwt);
|
||||||
|
|
||||||
|
await signIn(page, host);
|
||||||
|
await page.goto('/host');
|
||||||
|
|
||||||
|
// Wait for the dashboard to actually be loaded before asserting on an absence.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Host-Dashboard' })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('low-disk-warning')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
149
e2e/specs/04-host/moderation-ui.spec.ts
Normal file
149
e2e/specs/04-host/moderation-ui.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — a host must be able to remove a guest's content FROM THE UI.
|
||||||
|
*
|
||||||
|
* `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were fully implemented,
|
||||||
|
* transactional, SSE-broadcasting, audit-logged — and had zero frontend callers. The feed's
|
||||||
|
* context sheet offered "Löschen" only when `target.user_id === myUserId`, so the only lever
|
||||||
|
* a host actually had against an unwanted photo was banning the uploader. That is both
|
||||||
|
* disproportionate and ineffective: a ban doesn't retract what was already posted, and
|
||||||
|
* because the ban check runs BEFORE the ownership check on the guest delete route, banning
|
||||||
|
* the author makes their abusive comment permanently undeletable by them too.
|
||||||
|
*
|
||||||
|
* The API side was already covered (04-host/moderation). What was missing is the wiring,
|
||||||
|
* so these tests drive the real UI.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
test.describe('Host — moderation from the UI', () => {
|
||||||
|
test("a host removes a guest's photo via the feed context sheet", async ({
|
||||||
|
page,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
const g = await guest('PhotoOffender');
|
||||||
|
const uploadId = await seedUpload(g.jwt);
|
||||||
|
|
||||||
|
await signIn(page, host);
|
||||||
|
await page.goto('/feed');
|
||||||
|
|
||||||
|
const card = page.locator('article').filter({ hasText: g.displayName }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||||
|
const remove = page.getByRole('button', { name: /beitrag entfernen/i });
|
||||||
|
await expect(remove, 'a host must be offered a removal action on a guest post').toBeVisible();
|
||||||
|
await remove.click();
|
||||||
|
|
||||||
|
const sheet = page.getByTestId('confirm-sheet');
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
// Moderation copy, not "delete my post" copy.
|
||||||
|
await expect(sheet).toContainText(/beitrag entfernen/i);
|
||||||
|
await page.getByTestId('confirm-sheet-confirm').click();
|
||||||
|
|
||||||
|
await expect(card).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// And it is really gone server-side, not just dropped from the local list.
|
||||||
|
const res = await fetch(`${BASE}/api/v1/feed`, {
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.uploads.some((u: { id: string }) => u.id === uploadId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a guest is NOT offered any delete action on someone else’s post', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// The mirror that makes the test above meaningful: if this affordance rendered for
|
||||||
|
// everyone, the host test would still pass on a build that shipped moderation to guests.
|
||||||
|
const author = await guest('SomeAuthor');
|
||||||
|
await seedUpload(author.jwt);
|
||||||
|
const viewer = await guest('NosyViewer');
|
||||||
|
|
||||||
|
await signIn(page, viewer);
|
||||||
|
await page.goto('/feed');
|
||||||
|
|
||||||
|
const card = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /beitrag entfernen/i })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /^löschen$/i })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a promoted guest gets host powers without signing out and back in', async ({
|
||||||
|
page,
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// The JWT is never reissued — the backend slides the session row forward and treats the
|
||||||
|
// DB row as authoritative. So the token of a promoted guest still claims `role: guest`
|
||||||
|
// for up to 30 days. The UI read that frozen claim, which meant a guest promoted at the
|
||||||
|
// party saw no Host-Dashboard and no moderation actions until they signed out and back
|
||||||
|
// in — while `/me/context` had been handing the client the real role all along.
|
||||||
|
const g = await guest('LatePromotion');
|
||||||
|
await signIn(page, g);
|
||||||
|
|
||||||
|
await page.goto('/account');
|
||||||
|
await expect(page.getByRole('link', { name: /host-dashboard/i })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Promote mid-session. The token in localStorage is deliberately NOT refreshed.
|
||||||
|
await api.setRole(adminToken, g.userId, 'host');
|
||||||
|
const claim = JSON.parse(Buffer.from(g.jwt.split('.')[1], 'base64').toString());
|
||||||
|
expect(
|
||||||
|
claim.role,
|
||||||
|
'the token must still carry the stale claim for this to prove anything'
|
||||||
|
).toBe('guest');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: /host-dashboard/i }),
|
||||||
|
'the live role from /me/context must win over the frozen JWT claim'
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a host can remove the comment of a guest they have already banned', async ({
|
||||||
|
page,
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
|
||||||
|
// on the author gets 403 on their own delete, so if the host has no removal affordance
|
||||||
|
// the comment is stuck on screen forever.
|
||||||
|
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
|
||||||
|
// uploads, so if the comment sat on their own photo the whole card would vanish and
|
||||||
|
// there would be nothing left to moderate.
|
||||||
|
const victim = await guest('PhotoOwner');
|
||||||
|
const uploadId = await seedUpload(victim.jwt);
|
||||||
|
const author = await guest('CommentOffender');
|
||||||
|
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
|
||||||
|
await api.banUser(host.jwt, author.userId);
|
||||||
|
|
||||||
|
// Confirm the deadlock really exists — the author cannot retract it themselves.
|
||||||
|
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${author.jwt}` },
|
||||||
|
});
|
||||||
|
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
|
||||||
|
|
||||||
|
await signIn(page, host);
|
||||||
|
await page.goto('/feed');
|
||||||
|
|
||||||
|
const card = page.locator('article').filter({ hasText: victim.displayName }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||||
|
|
||||||
|
const comment = page.getByText('unangebrachter Kommentar');
|
||||||
|
await expect(comment).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
|
||||||
|
await expect(comment).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
102
e2e/specs/04-host/role-identity-reset.spec.ts
Normal file
102
e2e/specs/04-host/role-identity-reset.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — the role must follow the identity, not the tab.
|
||||||
|
*
|
||||||
|
* The `role` store is a module-level singleton seeded ONCE at import. `goto()` is a
|
||||||
|
* client-side navigation, so leaving and re-joining in the same tab re-imports nothing and
|
||||||
|
* re-runs no `onMount` — the previous user's role simply stayed. A host who left and a
|
||||||
|
* guest who then joined kept `isStaff === true` and were offered "🚫 Beitrag entfernen" on
|
||||||
|
* other people's photos. The backend 403s the delete, so it was a false affordance rather
|
||||||
|
* than a privilege escalation, but `/feed` never fetched `/me/context`, so it never
|
||||||
|
* self-corrected either — it survived until a hard reload.
|
||||||
|
*
|
||||||
|
* The mirror case matters just as much and is easier to forget: a guest who recovers into a
|
||||||
|
* host account must GAIN the affordance without a reload.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { JoinPage } from '../../page-objects';
|
||||||
|
|
||||||
|
const REMOVE = /beitrag entfernen/i;
|
||||||
|
|
||||||
|
test.describe('Role — follows the identity across a same-tab switch', () => {
|
||||||
|
test('a guest joining after a host leaves does NOT inherit host actions', async ({
|
||||||
|
page,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// Someone else's photo — the only kind the removal action is offered on.
|
||||||
|
const author = await guest('RoleAuthor');
|
||||||
|
await seedUpload(author.jwt);
|
||||||
|
|
||||||
|
// 1. Host is signed in and DOES see the moderation action. Establishing this first is
|
||||||
|
// what makes the negative assertion below meaningful.
|
||||||
|
await signIn(page, host);
|
||||||
|
await page.goto('/feed');
|
||||||
|
const card = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: REMOVE })).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// 2. Host leaves, in-app — no reload. This is the path "Event verlassen" takes.
|
||||||
|
await page.goto('/account');
|
||||||
|
await page.getByRole('button', { name: /event verlassen/i }).click();
|
||||||
|
const confirm = page.getByTestId('confirm-sheet-confirm');
|
||||||
|
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||||
|
await page.waitForURL('**/join', { timeout: 10_000 });
|
||||||
|
|
||||||
|
// 3. A brand-new guest joins in the same tab — the real flow, PIN modal and all.
|
||||||
|
const join = new JoinPage(page);
|
||||||
|
await join.joinAs(`Nachzuegler${Date.now() % 100000}`);
|
||||||
|
await join.continueToFeed();
|
||||||
|
await expect(page).toHaveURL(/\/feed$/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// 4. They must NOT be offered moderation on someone else's photo.
|
||||||
|
const card2 = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||||
|
await expect(card2).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card2.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: REMOVE }),
|
||||||
|
'a fresh guest must not inherit the previous user’s role'
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a guest who recovers into a host account GAINS host actions without a reload', async ({
|
||||||
|
page,
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// The mirror. If the fix only cleared the role it would pass the test above and still
|
||||||
|
// leave a real host with no moderation until they reloaded.
|
||||||
|
const author = await guest('RoleAuthor2');
|
||||||
|
await seedUpload(author.jwt);
|
||||||
|
|
||||||
|
const futureHost = await guest('WillBeHost');
|
||||||
|
await signIn(page, futureHost);
|
||||||
|
await page.goto('/feed');
|
||||||
|
const card = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||||
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: REMOVE })).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Promote them server-side. Their resident JWT still claims `role: guest`.
|
||||||
|
await api.setRole(adminToken, futureHost.userId, 'host');
|
||||||
|
const claim = JSON.parse(Buffer.from(futureHost.jwt.split('.')[1], 'base64').toString());
|
||||||
|
expect(claim.role, 'the token must still be stale for this to prove anything').toBe('guest');
|
||||||
|
|
||||||
|
// A plain in-app navigation back to the feed must pick up the live role.
|
||||||
|
await page.goto('/account');
|
||||||
|
await page.goto('/feed');
|
||||||
|
const card2 = page.locator('article').filter({ hasText: author.displayName }).first();
|
||||||
|
await expect(card2).toBeVisible({ timeout: 15_000 });
|
||||||
|
await card2.getByRole('button', { name: 'Mehr Aktionen' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: REMOVE }),
|
||||||
|
'the live role from /me/context must reach the feed'
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
103
e2e/specs/06-export/archive-permissions.spec.ts
Normal file
103
e2e/specs/06-export/archive-permissions.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — the keepsake extracts to files a guest can actually open.
|
||||||
|
*
|
||||||
|
* `ZipEntryBuilder::new` leaves the external file attribute at zero, and async_zip's host
|
||||||
|
* compatibility defaults to Unix — so every entry in BOTH archives was written with a stored mode
|
||||||
|
* of 0000. `unzip -Z` showed `?---------` on every line.
|
||||||
|
*
|
||||||
|
* Windows Explorer ignores Unix modes, which is exactly why this survived. On Linux and macOS,
|
||||||
|
* `unzip` faithfully applies what the archive asks for, and the guest gets a folder of photos none
|
||||||
|
* of which they can open — plus an index.html the browser refuses with ERR_ACCESS_DENIED.
|
||||||
|
*
|
||||||
|
* Unconditional: it affected every keepsake ever produced, no hostile input required. And it is
|
||||||
|
* invisible server-side — the export succeeds, the ZIP is well-formed, the job writes `done`,
|
||||||
|
* /export/status is green. The only way to see it is to extract the real artifact and try to read
|
||||||
|
* it, which is what this does.
|
||||||
|
*
|
||||||
|
* Found while chasing an unrelated ERR_ACCESS_DENIED that looked like a Playwright quirk.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync, statSync, readdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
/** Walk every file under `dir`, ignoring the archives we dropped there ourselves. */
|
||||||
|
function walk(dir: string, skip: string[] = []): string[] {
|
||||||
|
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||||
|
const p = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) return walk(p, skip);
|
||||||
|
return skip.includes(e.name) ? [] : [p];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Export — the archives extract to readable files', () => {
|
||||||
|
test('every entry in both keepsake archives is owner-readable', async ({ host, guest, db }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||||
|
|
||||||
|
const g = await guest('Archivist');
|
||||||
|
const id = await seedUpload(g.jwt, { caption: 'ein Foto' });
|
||||||
|
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }))
|
||||||
|
.status
|
||||||
|
).toBe(204);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||||||
|
const s = await res.json();
|
||||||
|
return s.zip?.status === 'done' && s.html?.status === 'done';
|
||||||
|
},
|
||||||
|
{ timeout: 90_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
for (const kind of ['zip', 'html'] as const) {
|
||||||
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
|
const { ticket } = (await ticketRes.json()) as { ticket: string };
|
||||||
|
const dl = await fetch(`${BASE}/api/v1/export/${kind}?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
expect(dl.status, `downloading the ${kind} archive`).toBe(200);
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), `eventsnap-perms-${kind}-`));
|
||||||
|
try {
|
||||||
|
const zipPath = join(dir, 'archive.zip');
|
||||||
|
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
|
||||||
|
|
||||||
|
// The mode as STORED in the archive — this is what a guest's unzip will apply. Reading it
|
||||||
|
// from the central directory catches the defect even on a filesystem that would mask it.
|
||||||
|
const listing = execFileSync('unzip', ['-Z', zipPath], { encoding: 'utf8' });
|
||||||
|
const modes = listing
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => /^[?d-][rwx-]{9}\s/.test(l))
|
||||||
|
.map((l) => l.slice(0, 10));
|
||||||
|
expect(modes.length, `${kind}: no entries listed`).toBeGreaterThan(0);
|
||||||
|
for (const m of modes) {
|
||||||
|
expect(m, `${kind}: an entry is stored mode ${m} — the guest cannot open it`).toMatch(
|
||||||
|
/^.r[w-]-/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And extraction really does produce readable files.
|
||||||
|
execFileSync('unzip', ['-qo', zipPath, '-d', dir]);
|
||||||
|
const files = walk(dir, ['archive.zip']);
|
||||||
|
expect(files.length, `${kind}: nothing extracted`).toBeGreaterThan(0);
|
||||||
|
for (const f of files) {
|
||||||
|
expect(statSync(f).mode & 0o400, `${f} is not owner-readable`).toBeTruthy();
|
||||||
|
// The assertion that matters to a guest: the bytes actually come out.
|
||||||
|
expect(() => readFileSync(f)).not.toThrow();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
103
e2e/specs/06-export/download-iframe.spec.ts
Normal file
103
e2e/specs/06-export/download-iframe.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — the keepsake download must actually download, in WebKit.
|
||||||
|
*
|
||||||
|
* The bug this exists to catch: `/export` streams the archive by pointing a HIDDEN,
|
||||||
|
* SAME-ORIGIN iframe at `/api/v1/export/zip` (deliberately — a top-level navigation to a
|
||||||
|
* 404/429 would unload the PWA). Caddy stamped a site-wide `X-Frame-Options: DENY` that
|
||||||
|
* also covered `/api/*`. Blink hands a `Content-Disposition: attachment` response to the
|
||||||
|
* download manager at the network layer, so Chromium never noticed; WebKit enforces XFO on
|
||||||
|
* the frame navigation FIRST and aborts the load. Result: on iOS Safari — the app's primary
|
||||||
|
* platform — tapping Download did nothing, silently, with no error anywhere.
|
||||||
|
*
|
||||||
|
* Why the old suite was structurally blind to it:
|
||||||
|
* - `06-export` ran on `chromium-desktop` only (webkit-iphone's testMatch excluded it),
|
||||||
|
* - and no test in the entire suite ever CLICKED a download button; every archive
|
||||||
|
* assertion used Node `fetch`, which has no frame and therefore no XFO enforcement.
|
||||||
|
*
|
||||||
|
* So this spec must keep both properties to be worth anything: a real click, in WebKit.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { ExportPage } from '../../page-objects';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
const SLUG = 'e2e-test-event';
|
||||||
|
|
||||||
|
function post(path: string, jwt: string) {
|
||||||
|
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real export job runs image processing; give it head-room over the tiny fixtures. */
|
||||||
|
async function releaseAndWait(jwt: string) {
|
||||||
|
expect((await post('/api/v1/host/gallery/release', jwt)).status).toBe(204);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(BASE + '/api/v1/export/status', {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
const s = await res.json();
|
||||||
|
return s.released === true && s.zip?.status === 'done';
|
||||||
|
},
|
||||||
|
{ timeout: 60_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Export — the download actually fires in the browser', () => {
|
||||||
|
test.slow();
|
||||||
|
|
||||||
|
test('clicking Download triggers a real download event', async ({ page, host, signIn, db }) => {
|
||||||
|
await db.setExportReleased(SLUG, false);
|
||||||
|
await releaseAndWait(host.jwt);
|
||||||
|
|
||||||
|
await signIn(page, host);
|
||||||
|
const exportPage = new ExportPage(page);
|
||||||
|
await exportPage.goto();
|
||||||
|
await expect(exportPage.zipDownloadButton).toBeEnabled({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Capture the frame-level refusal that XFO produces, so a failure reports the CAUSE
|
||||||
|
// rather than just a timeout. WebKit logs "Refused to display ... in a frame because it
|
||||||
|
// set 'X-Frame-Options'"; Chromium logs nothing here, which is the whole problem.
|
||||||
|
const refusals: string[] = [];
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (/X-Frame-Options|Refused to display/i.test(m.text())) refusals.push(m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 });
|
||||||
|
await exportPage.zipDownloadButton.click();
|
||||||
|
|
||||||
|
const download = await downloadPromise.catch((err) => {
|
||||||
|
throw new Error(
|
||||||
|
`No download event fired after clicking the ZIP button.` +
|
||||||
|
(refusals.length
|
||||||
|
? ` The browser refused the iframe navigation: ${refusals.join(' | ')}`
|
||||||
|
: ' No X-Frame-Options refusal was logged; check the ticket/readiness path.') +
|
||||||
|
`\n${err}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toMatch(/\.zip$/i);
|
||||||
|
// The stream must produce real bytes, not a zero-length placeholder.
|
||||||
|
const path = await download.path();
|
||||||
|
expect(path).toBeTruthy();
|
||||||
|
expect(refusals, 'no frame should have been refused').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the export endpoints are framable same-origin; everything else stays DENY', async () => {
|
||||||
|
// Locks the Caddy carve-out itself, independently of any browser. Cheap, and it fails
|
||||||
|
// loudly at the exact layer that regressed if someone reinstates a blanket DENY.
|
||||||
|
for (const path of ['/api/v1/export/zip', '/api/v1/export/html']) {
|
||||||
|
const res = await fetch(BASE + path);
|
||||||
|
expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must be framable`).toBe(
|
||||||
|
'SAMEORIGIN'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of ['/', '/api/v1/feed', '/api/v1/event']) {
|
||||||
|
const res = await fetch(BASE + path);
|
||||||
|
expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must stay DENY`).toBe(
|
||||||
|
'DENY'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
117
e2e/specs/06-export/exif-orientation.spec.ts
Normal file
117
e2e/specs/06-export/exif-orientation.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — the keepsake must not be sideways.
|
||||||
|
*
|
||||||
|
* Round 1 taught the compression worker to apply EXIF orientation, which fixed the live app
|
||||||
|
* (feed preview + diashow display). The export worker was missed: it does NOT reuse those
|
||||||
|
* derivatives — it re-decodes the originals itself with `image::open`, which ignores the
|
||||||
|
* orientation tag — and then re-encodes to JPEG, which drops the tag, so the viewer has no
|
||||||
|
* way to recover it.
|
||||||
|
*
|
||||||
|
* The resulting damage was oddly shaped, which is what made it read as a viewer bug:
|
||||||
|
* - Gallery.zip originals → correct (byte-copied, EXIF intact)
|
||||||
|
* - Memories viewer grid thumbnails → ALWAYS sideways
|
||||||
|
* - Memories viewer full image >5 MB → sideways (re-encoded at 2000px)
|
||||||
|
* - Memories viewer full image ≤5 MB → correct (streamed byte-for-byte)
|
||||||
|
*
|
||||||
|
* So clicking a small photo silently "fixed" it and a large one didn't. This pins the
|
||||||
|
* thumbnail, which is the path every photo takes.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"), so anything
|
||||||
|
// that honours the tag emits a PORTRAIT derivative.
|
||||||
|
const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
|
||||||
|
|
||||||
|
/** Pixel dimensions from a JPEG's SOF marker — avoids an image dep for one assertion. */
|
||||||
|
function jpegSize(buf: Buffer): { width: number; height: number } {
|
||||||
|
let i = 2;
|
||||||
|
while (i < buf.length) {
|
||||||
|
if (buf[i] !== 0xff) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const marker = buf[i + 1];
|
||||||
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
||||||
|
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
||||||
|
}
|
||||||
|
i += 2 + buf.readUInt16BE(i + 2);
|
||||||
|
}
|
||||||
|
throw new Error('no SOF marker found — not a JPEG?');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Export — EXIF orientation in the keepsake', () => {
|
||||||
|
test('the Memories viewer thumbnail of a rotated photo is upright', async ({ host, db }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||||
|
|
||||||
|
const src = readFileSync(EXIF_FIXTURE);
|
||||||
|
// Sanity: the SOURCE really is stored landscape, or this test proves nothing.
|
||||||
|
const srcSize = jpegSize(src);
|
||||||
|
expect(srcSize.width).toBeGreaterThan(srcSize.height);
|
||||||
|
|
||||||
|
const up = await uploadRaw(host.jwt, src, {
|
||||||
|
filename: 'hochkant.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
caption: 'hochkant',
|
||||||
|
});
|
||||||
|
expect(up.status).toBe(201);
|
||||||
|
const { id } = (await up.json()) as { id: string };
|
||||||
|
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||||
|
|
||||||
|
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
|
expect(rel.status).toBe(204);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||||||
|
return (await res.json()).html?.status;
|
||||||
|
},
|
||||||
|
{ timeout: 60_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe('done');
|
||||||
|
|
||||||
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
|
const { ticket } = (await ticketRes.json()) as { ticket: string };
|
||||||
|
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
expect(dl.status).toBe(200);
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-exif-'));
|
||||||
|
try {
|
||||||
|
const zipPath = join(dir, 'Memories.zip');
|
||||||
|
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
|
||||||
|
|
||||||
|
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean);
|
||||||
|
const thumbEntry = entries.find((e) => e.includes(`${id}_thumb`));
|
||||||
|
expect(thumbEntry, `no thumbnail for ${id} in Memories.zip`).toBeTruthy();
|
||||||
|
|
||||||
|
// `-p` streams the entry to stdout. Extracting to disk instead fails with EACCES:
|
||||||
|
// the archive preserves the container's file mode, which the test user can't read.
|
||||||
|
const thumb = execFileSync('unzip', ['-p', zipPath, thumbEntry!], {
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const { width, height } = jpegSize(thumb);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
height,
|
||||||
|
`the keepsake grid thumbnail is ${width}x${height} — EXIF orientation was not applied`
|
||||||
|
).toBeGreaterThan(width);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
95
e2e/specs/06-export/failure-reason-visible.spec.ts
Normal file
95
e2e/specs/06-export/failure-reason-visible.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — when the keepsake fails to build, the HOST must be told why.
|
||||||
|
*
|
||||||
|
* `/export/status` reported `{status, progress_pct}` and nothing else, so the host dashboard could
|
||||||
|
* only ever render "Keepsake-Erstellung fehlgeschlagen." next to an "Erneut versuchen" button. The
|
||||||
|
* reason WAS being written — `mark_failed` stores it on the job row — but it surfaced solely in the
|
||||||
|
* admin dashboard's job list. The host is the person who releases the gallery, owns the retry
|
||||||
|
* button, and is standing at the venue; the admin may be someone else entirely, or the same person
|
||||||
|
* without the password to hand.
|
||||||
|
*
|
||||||
|
* That matters most for the failure this shipped alongside: the export disk preflight. Its message
|
||||||
|
* names the two numbers that decide what to do ("benötigt ca. X GB, frei sind Y GB"), and without
|
||||||
|
* it "Erneut versuchen" fails identically, forever, with no hint that the answer is free some space.
|
||||||
|
*
|
||||||
|
* These drive the real UI and the real endpoint — the plumbing is four hops (SQL → handler JSON →
|
||||||
|
* store type → Svelte branch) and any one of them dropping the field restores the silent version.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
const SLUG = 'e2e-test-event';
|
||||||
|
const DISK_REASON =
|
||||||
|
'Nicht genug Speicherplatz für das Keepsake: benötigt ca. 42.0 GB, frei sind 3.0 GB. ' +
|
||||||
|
'Bitte Speicher freigeben und das Keepsake anschließend neu erstellen.';
|
||||||
|
|
||||||
|
test.describe('Export — a failed keepsake explains itself to the host', () => {
|
||||||
|
test('the failure reason reaches /export/status', async ({ host, db }) => {
|
||||||
|
await db.setExportReleased(SLUG, true);
|
||||||
|
await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON);
|
||||||
|
await db.fakeExportJob(SLUG, 'html', 'failed', DISK_REASON);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/api/v1/export/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
zip: { status: string; error_message: string | null };
|
||||||
|
html: { status: string; error_message: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.zip.status).toBe('failed');
|
||||||
|
expect(
|
||||||
|
body.zip.error_message,
|
||||||
|
'the reason must travel with the status, not live only in the admin job list'
|
||||||
|
).toBe(DISK_REASON);
|
||||||
|
expect(body.html.error_message).toBe(DISK_REASON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a succeeding export carries no stale reason', async ({ host, db }) => {
|
||||||
|
// The mirror that keeps the above honest: a handler that returned `error_message`
|
||||||
|
// unconditionally would pass the first test while showing an error next to a green
|
||||||
|
// "Keepsake ist bereit." Rows keep their last message until they are re-armed, so this is a
|
||||||
|
// real state, not a hypothetical one.
|
||||||
|
await db.setExportReleased(SLUG, true);
|
||||||
|
await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON);
|
||||||
|
await db.fakeExportJob(SLUG, 'zip', 'done', DISK_REASON);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/api/v1/export/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as { zip: { status: string; error_message: string | null } };
|
||||||
|
expect(body.zip.status).toBe('done');
|
||||||
|
expect(
|
||||||
|
body.zip.error_message,
|
||||||
|
'a message left on a row that has since succeeded must not be shown'
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the host dashboard renders the reason under the failure', async ({
|
||||||
|
page,
|
||||||
|
host,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
await db.setExportReleased(SLUG, true);
|
||||||
|
await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON);
|
||||||
|
await db.fakeExportJob(SLUG, 'html', 'failed', DISK_REASON);
|
||||||
|
|
||||||
|
await signIn(page, host);
|
||||||
|
await page.goto('/host');
|
||||||
|
|
||||||
|
await expect(page.getByText(/Keepsake-Erstellung fehlgeschlagen/i)).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
// The actionable half — the numbers, not just the verdict.
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Nicht genug Speicherplatz/i),
|
||||||
|
'the host must see WHY, next to the only button they have'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText(/3\.0 GB/)).toBeVisible();
|
||||||
|
|
||||||
|
// And the retry button is still mounted — it is deliberately outside the status branches.
|
||||||
|
await expect(page.getByTestId('export-rebuild')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
125
e2e/specs/06-export/viewer-caption-injection.spec.ts
Normal file
125
e2e/specs/06-export/viewer-caption-injection.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard — a guest-authored caption cannot brick the offline keepsake.
|
||||||
|
*
|
||||||
|
* The viewer's data is inlined as `<script>window.__EXPORT_DATA__={…};</script>` (it must be:
|
||||||
|
* guests open index.html over file://, where a cross-origin fetch of a sibling data.json is
|
||||||
|
* blocked). Captions and comments are guest text and land in that payload.
|
||||||
|
*
|
||||||
|
* The escape used to be `</` → `<\/`. Against XSS that holds — `</script><img src=x onerror=…>`
|
||||||
|
* round-trips inert. It does NOT stop the caption steering the HTML TOKENIZER: `<!--<script` with
|
||||||
|
* no later `-->` drives the parser into script-data-double-escaped state, where the template's own
|
||||||
|
* `</script>` steps back to script-data-escaped instead of closing the element. Everything after —
|
||||||
|
* including the viewer bundle — is swallowed as script data. Nothing executes and nothing leaks;
|
||||||
|
* `__EXPORT_DATA__` is never assigned and the keepsake renders blank.
|
||||||
|
*
|
||||||
|
* What makes it worth a browser-level test rather than a unit test alone: the failure is SILENT and
|
||||||
|
* POST-DISTRIBUTION. The export succeeds, the ZIP is well-formed, the job writes `done`,
|
||||||
|
* /export/status is green, and the host hands out a file that only fails when a guest
|
||||||
|
* double-clicks it — in every copy, unfixably. It is not visible by reading the escape. It is only
|
||||||
|
* visible by running a real parser over the real artifact, which is what this does: release, pull
|
||||||
|
* the actual Memories.zip, extract index.html, open it over file:// in Chromium, and assert the
|
||||||
|
* viewer actually booted.
|
||||||
|
*
|
||||||
|
* The near-miss worth recording: `<!--<script>alert(1)</script>-->` comes back CLEAN, because the
|
||||||
|
* trailing `-->` returns the parser to script-data state. A probe using the terminated form
|
||||||
|
* quietly repairs the very thing it is testing for. Only the unterminated variant exposes it.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
/** Unterminated on purpose — see the header. The terminated form self-repairs. */
|
||||||
|
const TOKENIZER_PAYLOAD = '<!--<script';
|
||||||
|
/** The classic break-out. Already handled, kept so the fix can never regress on it. */
|
||||||
|
const BREAKOUT_PAYLOAD = '</script><img src=x onerror=window.__XSS__=1>';
|
||||||
|
|
||||||
|
test.describe('Export — a caption cannot brick the keepsake viewer', () => {
|
||||||
|
test('the exported viewer boots with a tokenizer-hostile caption in it', async ({
|
||||||
|
page,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||||
|
|
||||||
|
const g = await guest('Trickster');
|
||||||
|
const a = await seedUpload(g.jwt, { caption: TOKENIZER_PAYLOAD });
|
||||||
|
const b = await seedUpload(g.jwt, { caption: BREAKOUT_PAYLOAD });
|
||||||
|
for (const id of [a, b]) {
|
||||||
|
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }))
|
||||||
|
.status
|
||||||
|
).toBe(204);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||||||
|
return (await res.json()).html?.status;
|
||||||
|
},
|
||||||
|
{ timeout: 90_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe('done');
|
||||||
|
|
||||||
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
|
const { ticket } = (await ticketRes.json()) as { ticket: string };
|
||||||
|
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
expect(dl.status).toBe(200);
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-viewer-'));
|
||||||
|
try {
|
||||||
|
const zipPath = join(dir, 'Memories.zip');
|
||||||
|
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
|
||||||
|
// Extract the WHOLE archive: index.html pulls in the viewer's own JS/CSS, and the point of
|
||||||
|
// this test is that those later resources are still reachable by the parser.
|
||||||
|
execFileSync('unzip', ['-qo', zipPath, '-d', dir]);
|
||||||
|
|
||||||
|
// file://, not http://. That is how a guest actually opens the keepsake, and it is the
|
||||||
|
// whole reason the data is inlined rather than fetched from a sibling data.json.
|
||||||
|
let xss = false;
|
||||||
|
page.on('dialog', (d) => {
|
||||||
|
xss = true;
|
||||||
|
void d.dismiss();
|
||||||
|
});
|
||||||
|
await page.goto('file://' + join(dir, 'index.html'));
|
||||||
|
|
||||||
|
// 1. The payload was assigned at all. This is the assertion that fails on the old escape —
|
||||||
|
// the second script block is never reached, so the global stays undefined.
|
||||||
|
const captions = await page.evaluate(() => {
|
||||||
|
const d = (window as unknown as { __EXPORT_DATA__?: { posts?: { caption?: string }[] } })
|
||||||
|
.__EXPORT_DATA__;
|
||||||
|
return d?.posts?.map((p) => p.caption ?? '') ?? null;
|
||||||
|
});
|
||||||
|
expect(captions, '__EXPORT_DATA__ was never assigned — the viewer is bricked').not.toBeNull();
|
||||||
|
|
||||||
|
// 2. The captions survived verbatim. The escape is a transport encoding, not a sanitiser:
|
||||||
|
// a guest's text has to come back exactly, or we have silently rewritten their words.
|
||||||
|
expect(captions).toContain(TOKENIZER_PAYLOAD);
|
||||||
|
expect(captions).toContain(BREAKOUT_PAYLOAD);
|
||||||
|
|
||||||
|
// 3. And nothing executed.
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => (window as unknown as { __XSS__?: number }).__XSS__ === 1),
|
||||||
|
'the caption must be inert, not merely non-fatal'
|
||||||
|
).toBe(false);
|
||||||
|
expect(xss).toBe(false);
|
||||||
|
|
||||||
|
// 4. The viewer actually rendered — the whole document parsed, not just the head. If the
|
||||||
|
// tokenizer had swallowed the bundle, the body would be empty of viewer output.
|
||||||
|
await expect(page.locator('body')).not.toBeEmpty();
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,15 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' });
|
await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('20 parallel /join from one IP — rate limiter catches the excess', async () => {
|
test('a /join flood from one IP is caught by the per-IP ceiling', async ({ api, adminToken }) => {
|
||||||
|
// This used to assert that 20 joins from one IP produced 429s under a 5/min per-IP
|
||||||
|
// bucket. That "protection" was the bug: at a venue every guest shares one public IP,
|
||||||
|
// so it turned real arriving guests away (see 01-auth/rate-limit-shared-nat). The
|
||||||
|
// anti-spam bucket is now per (ip, name); what remains per-IP is a loose ceiling whose
|
||||||
|
// job is only to bound raw volume. Squeeze the ceiling so a flood is reproducible here
|
||||||
|
// without firing 60+ requests.
|
||||||
|
await api.patchConfig(adminToken, { join_ip_rate_per_min: '5' });
|
||||||
|
|
||||||
const requests = Array.from({ length: 20 }, (_, i) =>
|
const requests = Array.from({ length: 20 }, (_, i) =>
|
||||||
fetch(`${BASE}/api/v1/join`, {
|
fetch(`${BASE}/api/v1/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -24,7 +32,7 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
const statuses = (await Promise.all(requests)).map((r) => r.status);
|
const statuses = (await Promise.all(requests)).map((r) => r.status);
|
||||||
// 5/min limit → at least some should be 429.
|
// Ceiling of 5 → the excess must be shed.
|
||||||
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
|
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
|
||||||
// Server stays up — at least one succeeded.
|
// Server stays up — at least one succeeded.
|
||||||
expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);
|
expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
|||||||
const direct = await fetch(`${BASE}/media/previews/${id}.jpg`);
|
const direct = await fetch(`${BASE}/media/previews/${id}.jpg`);
|
||||||
expect(direct.status, 'direct /media/previews must be blocked').toBe(404);
|
expect(direct.status, 'direct /media/previews must be blocked').toBe(404);
|
||||||
|
|
||||||
|
// …and it must stay blocked under percent-encoding. The block used to be four
|
||||||
|
// `nest_service("/media/previews", 404)` route matches sitting above a `/media`
|
||||||
|
// ServeDir. axum routes on the RAW path while ServeDir percent-decodes afterwards, so
|
||||||
|
// ONE escaped byte (`%70` = `p`) missed every blocker, fell through to the ServeDir,
|
||||||
|
// and was decoded back to `previews/` on disk — serving the bytes unauthenticated.
|
||||||
|
// Asserting only the literal spelling is what let that sit here undetected.
|
||||||
|
for (const variant of [
|
||||||
|
`/media/%70reviews/${id}.jpg`, // p
|
||||||
|
`/media/p%72eviews/${id}.jpg`, // r — any position works
|
||||||
|
`/media/%64isplays/${id}.jpg`, // d
|
||||||
|
`/media/%74humbnails/${id}.jpg`, // t
|
||||||
|
`/media/%6Friginals/${id}.jpg`, // o
|
||||||
|
]) {
|
||||||
|
const res = await fetch(`${BASE}${variant}`, { redirect: 'manual' });
|
||||||
|
expect(res.status, `${variant} must not bypass the media block`).toBe(404);
|
||||||
|
}
|
||||||
|
|
||||||
// Host deletes the upload → the preview must stop being served.
|
// Host deletes the upload → the preview must stop being served.
|
||||||
const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, {
|
const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
12
frontend/export-viewer/index.html
Normal file
12
frontend/export-viewer/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>EventSnap — Galerie</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/standalone.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
101
frontend/export-viewer/package-lock.json
generated
101
frontend/export-viewer/package-lock.json
generated
@@ -15,7 +15,8 @@
|
|||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-singlefile": "^2.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -1318,6 +1319,19 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1457,6 +1471,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1479,6 +1506,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
@@ -1787,6 +1824,33 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -2021,6 +2085,19 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -2122,6 +2199,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-singlefile": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromatch": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^4.59.0",
|
||||||
|
"vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitefu": {
|
"node_modules/vitefu": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build --config vite.standalone.config.js",
|
||||||
|
"build:sveltekit": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''"
|
"prepare": "svelte-kit sync || echo ''"
|
||||||
},
|
},
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-singlefile": "^2.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export interface ViewerData {
|
|||||||
event: {
|
event: {
|
||||||
name: string;
|
name: string;
|
||||||
exported_at: string;
|
exported_at: string;
|
||||||
|
// Mirrors the live COMMENTS_ENABLED flag. Older exports predate this field, so
|
||||||
|
// treat a missing value as enabled (`?? true`) to preserve their comment UI.
|
||||||
|
comments_enabled?: boolean;
|
||||||
};
|
};
|
||||||
posts: ViewerPost[];
|
posts: ViewerPost[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
|
|
||||||
let posts = $derived(data?.posts ?? []);
|
let posts = $derived(data?.posts ?? []);
|
||||||
|
|
||||||
|
// Mirror the live COMMENTS_ENABLED flag. Missing on older exports → treat as enabled.
|
||||||
|
let commentsEnabled = $derived(data?.event.comments_enabled ?? true);
|
||||||
|
|
||||||
let allTags = $derived.by(() => {
|
let allTags = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state.
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state.
|
||||||
const freq = new Map<string, number>();
|
const freq = new Map<string, number>();
|
||||||
@@ -99,9 +102,18 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('./data.json');
|
// The keepsake is opened by double-clicking index.html (file://), where a
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
// cross-origin `fetch()` is blocked. The export injects the data as a global
|
||||||
data = await res.json();
|
// so it's available without any network request; fall back to fetching
|
||||||
|
// data.json only when the viewer is actually served over http(s).
|
||||||
|
const injected = (globalThis as { __EXPORT_DATA__?: ViewerData }).__EXPORT_DATA__;
|
||||||
|
if (injected) {
|
||||||
|
data = injected;
|
||||||
|
} else {
|
||||||
|
const res = await fetch('./data.json');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
error =
|
error =
|
||||||
'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
|
'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
|
||||||
@@ -541,22 +553,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{post.likes}
|
{post.likes}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
{#if commentsEnabled}
|
||||||
<svg
|
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
||||||
class="h-5 w-5"
|
<svg
|
||||||
fill="none"
|
class="h-5 w-5"
|
||||||
viewBox="0 0 24 24"
|
fill="none"
|
||||||
stroke="currentColor"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
>
|
stroke-width="2"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
stroke-linejoin="round"
|
||||||
/>
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
</svg>
|
/>
|
||||||
{post.comments.length}
|
</svg>
|
||||||
</span>
|
{post.comments.length}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Caption -->
|
<!-- Caption -->
|
||||||
@@ -632,17 +646,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{post.likes}
|
{post.likes}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-0.5">
|
{#if commentsEnabled}
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<span class="flex items-center gap-0.5">
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="h-3.5 w-3.5"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
stroke-width="2"
|
viewBox="0 0 24 24"
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
stroke="currentColor"
|
||||||
/>
|
>
|
||||||
</svg>
|
<path
|
||||||
{post.comments.length}
|
stroke-linecap="round"
|
||||||
</span>
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{post.comments.length}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -775,23 +796,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comments list -->
|
<!-- Comments list -->
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
{#if commentsEnabled}
|
||||||
{#if selectedPost.comments.length === 0}
|
<div class="flex-1 overflow-y-auto p-3">
|
||||||
<p class="text-center text-sm text-gray-400">Keine Kommentare.</p>
|
{#if selectedPost.comments.length === 0}
|
||||||
{:else}
|
<p class="text-center text-sm text-gray-400">Keine Kommentare.</p>
|
||||||
<div class="space-y-3">
|
{:else}
|
||||||
{#each selectedPost.comments as comment, i (i)}
|
<div class="space-y-3">
|
||||||
<div>
|
{#each selectedPost.comments as comment, i (i)}
|
||||||
<span class="text-sm font-medium text-gray-900">{comment.author}</span>
|
<div>
|
||||||
<span class="ml-1 text-sm text-gray-700">{comment.text}</span>
|
<span class="text-sm font-medium text-gray-900">{comment.author}</span>
|
||||||
<div class="mt-0.5 text-xs text-gray-400">
|
<span class="ml-1 text-sm text-gray-700">{comment.text}</span>
|
||||||
{formatShortDate(comment.timestamp)}
|
<div class="mt-0.5 text-xs text-gray-400">
|
||||||
|
{formatShortDate(comment.timestamp)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
frontend/export-viewer/src/standalone.ts
Normal file
8
frontend/export-viewer/src/standalone.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Standalone (non-SvelteKit) entry for the offline keepsake. Mounts the plain
|
||||||
|
// Svelte viewer component into a single, self-contained index.html so it works
|
||||||
|
// when opened by double-clicking from an extracted ZIP (file://).
|
||||||
|
import './app.css';
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './routes/+page.svelte';
|
||||||
|
|
||||||
|
mount(App, { target: document.getElementById('app')! });
|
||||||
10
frontend/export-viewer/vite.config.js
Normal file
10
frontend/export-viewer/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// NOTE: the production keepsake is built with `vite.standalone.config.js`
|
||||||
|
// (see `npm run build`), which inlines everything into a single, file://-safe
|
||||||
|
// index.html. This SvelteKit config is kept only for `npm run dev`/`preview`.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()]
|
||||||
|
});
|
||||||
25
frontend/export-viewer/vite.standalone.config.js
Normal file
25
frontend/export-viewer/vite.standalone.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// Builds the keepsake viewer as ONE self-contained index.html (all JS + CSS
|
||||||
|
// inlined) so it renders when opened via file://. Uses the plain Svelte plugin
|
||||||
|
// (not SvelteKit) because SvelteKit emits multiple module entry points, which
|
||||||
|
// cannot be inlined into a single file.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
svelte({ configFile: false, preprocess: vitePreprocess(), compilerOptions: { runes: true } }),
|
||||||
|
viteSingleFile()
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) }
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: fileURLToPath(new URL('../../backend/static/export-viewer', import.meta.url)),
|
||||||
|
emptyOutDir: true,
|
||||||
|
target: 'es2020'
|
||||||
|
}
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user