Compare commits
70 Commits
fix/video-
...
fix/produc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9759c7c669 | ||
|
|
cfc8bd0016 | ||
|
|
ee70eec094 | ||
|
|
9bae5d77ed | ||
|
|
20c15c3500 | ||
|
|
06e0bea0e9 | ||
|
|
e6aeaa0a8b | ||
|
|
ac04e27e34 | ||
|
|
137b892480 | ||
|
|
b5a1580368 | ||
|
|
6475199670 | ||
|
|
55b57fc037 | ||
|
|
19b59d6fee | ||
|
|
010bcc0e3c | ||
|
|
8af8c4fab7 | ||
|
|
301e6636a5 | ||
|
|
4916eed436 | ||
|
|
182e712a0e | ||
|
|
f403222200 | ||
|
|
4b61f4552b | ||
|
|
5aa2b2e886 | ||
|
|
9f239882ac | ||
|
|
a2b3cb0e8d | ||
|
|
a428fe6957 | ||
|
|
6afb33e5b6 | ||
|
|
9b38d31f97 | ||
|
|
1b3ca46f8a | ||
|
|
0c0d5d5981 | ||
|
|
32dfe6874a | ||
|
|
a53729a704 | ||
|
|
f5c55d6f92 | ||
|
|
8720571beb | ||
|
|
c9a4d4a9c0 | ||
|
|
7154b3a810 | ||
|
|
ec7c7f18ca | ||
|
|
963f6449a1 | ||
|
|
ef6d3a077a | ||
|
|
214f9e3062 | ||
|
|
253878e027 | ||
|
|
5b705317ef | ||
|
|
05063694d2 | ||
|
|
23e2f485dd | ||
|
|
eb0e405562 | ||
|
|
1d9fb11c7b | ||
|
|
61119be817 | ||
|
|
e1c689d1a7 | ||
|
|
d5b4bf0ac1 | ||
|
|
0ae5a64e77 | ||
|
|
edc5f1f62c | ||
|
|
46bb2e5174 | ||
|
|
2b1500e624 | ||
|
|
89ca819529 | ||
|
|
fffa2d556c | ||
|
|
51e55b1ace | ||
|
|
87d01a8a26 | ||
|
|
496dba5a1f | ||
|
|
1d0df3ebf6 | ||
|
|
faea555967 | ||
|
|
157499d493 | ||
|
|
2f952494c2 | ||
|
|
43d37269b6 | ||
|
|
9b90929269 | ||
|
|
117e67fa80 | ||
|
|
f275de5c8f | ||
|
|
3f0f9c098b | ||
|
|
e52b2f1cd1 | ||
|
|
5969ec74ea | ||
|
|
a4bac03628 | ||
|
|
6fd75adb27 | ||
|
|
7d0334bf22 |
163
.env.example
163
.env.example
@@ -1,7 +1,28 @@
|
|||||||
# ── Domain ────────────────────────────────────────────────────────────────────
|
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
# Public domain Caddy will serve and obtain a TLS certificate for.
|
# Public domain Caddy will serve and obtain a TLS certificate for.
|
||||||
|
#
|
||||||
|
# The DNS A record must already point at this server BEFORE the first `up -d`: Caddy
|
||||||
|
# requests a certificate on boot, and Let's Encrypt allows only 5 failed validations per
|
||||||
|
# hostname per hour. Never delete the caddy_data volume — it holds the certificate and
|
||||||
|
# the ACME account key.
|
||||||
DOMAIN=my-event.example.com
|
DOMAIN=my-event.example.com
|
||||||
|
|
||||||
|
# ── Image version ─────────────────────────────────────────────────────────────
|
||||||
|
# Tag pulled for the `app` and `frontend` services (docker-compose.yml). Production runs
|
||||||
|
# prebuilt images from the registry and never compiles — see DEPLOYMENT_RUNBOOK.md.
|
||||||
|
# Always an immutable tag, never `latest`: rollback is `EVENTSNAP_VERSION=<previous>`
|
||||||
|
# + `docker compose up -d`, which works offline if that image is still resident locally.
|
||||||
|
#
|
||||||
|
# ⚠ THIS TAG DOES NOT EXIST YET. The newest git tag is v0.12.0; v0.13.0 is the release you
|
||||||
|
# cut for the event. Build and push it (plus its identical rollback twin v0.13.0-a) BEFORE
|
||||||
|
# the first `docker compose up -d` — see DEPLOYMENT_RUNBOOK.md §6 (build) and §9 (rollback).
|
||||||
|
# Copying this file and starting the stack without that step fails with `manifest unknown`.
|
||||||
|
#
|
||||||
|
# Do NOT "fix" this by dropping back to v0.12.0: no image was ever built for it, and a
|
||||||
|
# 6-migration tree booting against a 31-migration database returns VersionMissing and
|
||||||
|
# crash-loops forever behind a live Caddy. §9 covers this in full.
|
||||||
|
EVENTSNAP_VERSION=v0.13.0
|
||||||
|
|
||||||
# ── App server ────────────────────────────────────────────────────────────────
|
# ── App server ────────────────────────────────────────────────────────────────
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
# Set to `production` in real deployments. This activates the secret guard that
|
# Set to `production` in real deployments. This activates the secret guard that
|
||||||
@@ -24,13 +45,31 @@ 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
|
# Connection pool size. The code default is 15 (DEFAULT_MAX_CONNECTIONS in backend/src/db.rs),
|
||||||
# + SSE + uploads at once) raise to ~30 so requests don't queue on a pool permit.
|
# and docker-compose.yml pins this value in `app.environment` so an edit here cannot reach the
|
||||||
# PAIRED WITH THE DB CONTAINER'S MEMORY LIMIT: 30 backends plus Postgres 16's default
|
# container. That pin is deliberate: since the value became boot-FATAL when unparseable — so an
|
||||||
# shared_buffers is already snug in the 1G that docker-compose.yml allots the `db`
|
# operator tuning a knob that never took effect gets told, instead of silently staying on the
|
||||||
# service. If you raise this, raise `db.deploy.resources.limits.memory` with it — an
|
# default — a stray quote or a trailing inline comment in `.env` would crash-loop the app behind
|
||||||
# OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
# a live Caddy. Change the pin in compose, not this line.
|
||||||
DATABASE_MAX_CONNECTIONS=30
|
#
|
||||||
|
# SIZE IT TO THE CORES, NOT TO THE GUESTS. The earlier advice here was ~30, reasoned from
|
||||||
|
# "~100 guests polling the feed at once" back when a feed page cost ~449 ms and connections
|
||||||
|
# were spent waiting. Migration 024 replaced the feed view's GROUP BY with scalar subqueries
|
||||||
|
# and a page now costs well under a millisecond, so concurrency is no longer where the time
|
||||||
|
# goes. On a 2 vCPU box 30 simultaneous queries cannot run — they queue on the CPU instead of
|
||||||
|
# on the pool, which is the same wait wearing a different hat, and 30 Postgres backends plus
|
||||||
|
# shared_buffers is snug in the 1G that docker-compose.yml allots `db`.
|
||||||
|
#
|
||||||
|
# 15 on 2 vCPU / 4 GB. Raise toward 30 only alongside more cores AND a bigger `db` memory
|
||||||
|
# limit — an OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
||||||
|
DATABASE_MAX_CONNECTIONS=15
|
||||||
|
|
||||||
|
# Log level: see the "Logging" section near the bottom of this file.
|
||||||
|
#
|
||||||
|
# Defined THERE and nowhere else, deliberately. This file used to assign RUST_LOG twice —
|
||||||
|
# once here and once there — and Compose takes the LAST assignment, so editing this line to
|
||||||
|
# `debug` to chase a problem during the event changed nothing at all, silently. A key that
|
||||||
|
# appears twice in a .env is a trap regardless of which value is better.
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
# Generate with: openssl rand -hex 64
|
# Generate with: openssl rand -hex 64
|
||||||
@@ -48,7 +87,15 @@ SESSION_EXPIRY_DAYS=30
|
|||||||
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
|
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
|
||||||
|
|
||||||
# ── Event ─────────────────────────────────────────────────────────────────────
|
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||||
EVENT_NAME=Max & Maria's Wedding
|
# DOUBLE-QUOTED, and it matters. Compose's env_file parser reads `Max & Maria's Wedding`
|
||||||
|
# unquoted just fine — but the runbook also tells you to `set -a; . ./.env; set +a` in a plain
|
||||||
|
# shell, and POSIX `sh` aborts on the apostrophe with "Unterminated quoted string" (rc=2).
|
||||||
|
# Everything defined BELOW this line is then left unset, silently: the hourly pg_dump cron in
|
||||||
|
# §10.2 does exactly this, so it would exit before ever writing a backup, every hour, into a log
|
||||||
|
# nobody reads. Double quotes are read identically by both parsers (verified) — keep them, and
|
||||||
|
# keep them double, since single quotes would make a literal `$` in a name survive but are what
|
||||||
|
# `ADMIN_PASSWORD_HASH` above needs for the opposite reason.
|
||||||
|
EVENT_NAME="Max & Maria's Wedding"
|
||||||
EVENT_SLUG=max-maria-2026
|
EVENT_SLUG=max-maria-2026
|
||||||
|
|
||||||
# ── Storage ───────────────────────────────────────────────────────────────────
|
# ── Storage ───────────────────────────────────────────────────────────────────
|
||||||
@@ -74,26 +121,96 @@ EXPORT_PATH=/exports
|
|||||||
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
|
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
|
||||||
# which anything warns you:
|
# which anything warns you:
|
||||||
#
|
#
|
||||||
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
|
# divisor = max(active_uploaders, estimated_guest_count, 1)
|
||||||
|
# per_user_limit = max(floor(free_disk * quota_tolerance / divisor), 500 MiB)
|
||||||
#
|
#
|
||||||
# It is recomputed against LIVE free space on every upload, so it self-throttles: guests
|
# estimated_guest_count is a FLOOR ON THE DIVISOR, not decoration — it is a live knob
|
||||||
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
|
# (upload::quota_limit_bytes). Earlier drafts of this file and the runbook both omitted
|
||||||
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
|
# it and told operators it was inert; it is not.
|
||||||
#
|
#
|
||||||
# Raising it therefore AUTHORISES GUESTS TO FILL MORE OF THE DISK. Setting 0.95 in the
|
# It is recomputed against LIVE free space on every upload, so in principle it self-
|
||||||
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
|
# throttles: guests converge on a fixed point at tolerance/(1+tolerance) of the free space
|
||||||
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
|
# you started with — 43% at 0.75.
|
||||||
# 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.
|
# ON THIS BOX THAT FIXED POINT NEVER BINDS, and it is worth knowing which knob actually
|
||||||
|
# stops the disk filling. The arithmetic above used to be quoted as "~30 GB of a fresh
|
||||||
|
# 70 GB", which is an 80 GB CX33; this deploys to a CX22 with 40 GB. At ~28 GB free and
|
||||||
|
# estimated_guest_count = 100 flooring the divisor, the formula yields ~210 MB per guest —
|
||||||
|
# BELOW the 500 MiB floor — so every guest is granted the floor and the per-user quota
|
||||||
|
# stops bounding aggregate growth at all.
|
||||||
|
#
|
||||||
|
# What actually bounds it is the keepsake preflight in upload.rs: uploads are refused once
|
||||||
|
# free < media x 1.1 x 2 + 10 GB, which on 40 GB lands at ~8 GB of media (README, "Sizing
|
||||||
|
# the disk"). So if a guest reports being blocked, the number to look at is total media,
|
||||||
|
# not this one.
|
||||||
|
#
|
||||||
|
# Raising this still AUTHORISES GUESTS TO FILL MORE OF THE DISK on a larger box, and it
|
||||||
|
# still eats the headroom the keepsake needs — 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
|
# 0.75 is the tested default. Lower it if the box is tight; raise it only if you have
|
||||||
# provisioned export headroom separately.
|
# provisioned export headroom separately.
|
||||||
|
|
||||||
# ── Workers ───────────────────────────────────────────────────────────────────
|
# ── Workers ───────────────────────────────────────────────────────────────────
|
||||||
# Number of parallel image/video compression workers. Default 2. This is the main
|
# Number of parallel media compression workers. Default 2. Boot-time only.
|
||||||
# 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
|
# CORRECTION TO EARLIER GUIDANCE: this used to say "each worker can run an ffmpeg
|
||||||
# ffmpeg transcode, so if you raise this ALSO raise the app container's memory limit
|
# transcode, so raise the app memory limit to ~2G if you set 4". There is NO video
|
||||||
# in docker-compose.yml (`app.deploy.resources.limits.memory`) from 1G to ~2G, or a
|
# transcode anywhere in this codebase — services/video.rs runs
|
||||||
# burst of large videos can OOM the box and take Postgres down with it.
|
# `ffmpeg -ss <t> -i <src> -vframes 1 -vf scale=...`, a single poster frame, and video
|
||||||
|
# originals are stored and served byte-for-byte. Poster extraction costs ~150-250 MB
|
||||||
|
# for a moment; it is not the constraint.
|
||||||
|
#
|
||||||
|
# The real memory consumer is the IMAGE path. `image` 0.25's resize builds an Rgba32F
|
||||||
|
# intermediate at 16 BYTES PER PIXEL, sized (source_width x target_height) — which the
|
||||||
|
# 256 MiB decode guard in imaging.rs does NOT cover. Peak per photo, decode + the 2048px
|
||||||
|
# display resize: ~145 MB at 12 MP, ~223 MB at 24 MP, ~354 MB at 48 MP.
|
||||||
|
#
|
||||||
|
# So on a 2 vCPU / 4 GB box (e.g. Hetzner CX22) KEEP THIS AT 2:
|
||||||
|
# * concurrency 4 would put two giants at ~1.5 GB against the 1G app limit — OOM.
|
||||||
|
# * and app=2G + db=1G + frontend/caddy 256M each + ~370 MB of OS/Docker exceeds the
|
||||||
|
# ~3910 MiB a "4 GB" VM actually reports. Raising the limit oversubscribes the host.
|
||||||
|
# 4 is only reasonable on the 4 vCPU / 8 GB box README.md documents.
|
||||||
|
#
|
||||||
|
# The "two 48 MP photos at once" worst case this number used to be sized against is no
|
||||||
|
# longer reachable: compression.rs takes an EXCLUSIVE `heavy` permit for any job whose
|
||||||
|
# estimated peak exceeds HEAVY_IMAGE_BYTES (150 MiB), so two giants serialise no matter what
|
||||||
|
# this is set to. What concurrency 2 now buys is two ORDINARY phone photos in parallel
|
||||||
|
# (~145 MB peak each), which is both memory-safe and short enough not to starve the two
|
||||||
|
# tokio worker threads a 2 vCPU box gets.
|
||||||
|
#
|
||||||
|
# Do NOT drop this to 1 hoping to protect the CPU. It halves throughput on the common light
|
||||||
|
# path for a heavy path that is already serialised, and a longer compression backlog means
|
||||||
|
# more feed tiles served from full-size originals (VirtualFeed falls back to /original while
|
||||||
|
# derivatives are pending) — trading a little CPU for a lot of venue-wifi bandwidth.
|
||||||
|
#
|
||||||
|
# Throughput at 2 is not the bottleneck anyone thinks it is: ~2.5s per 12 MP photo, so
|
||||||
|
# 100 photos is ~250 CPU-seconds spread over an entire evening.
|
||||||
COMPRESSION_WORKER_CONCURRENCY=2
|
COMPRESSION_WORKER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# ── Comments ──────────────────────────────────────────────────────────────────
|
||||||
|
# Master switch for the comment feature. Boot-time only (NOT in the admin UI), so it
|
||||||
|
# needs a `docker compose up -d` to apply. Anything other than false/0/no/off is on.
|
||||||
|
#
|
||||||
|
# When false the backend rejects NEW comments with 403 and the frontend hides the whole
|
||||||
|
# comment UI, including in the offline keepsake viewer. Likes and captions are entirely
|
||||||
|
# separate features and are unaffected. Existing comments stay in the database (hidden),
|
||||||
|
# so flipping it back restores them.
|
||||||
|
#
|
||||||
|
# Note it gates POSTING only: GET /upload/{id}/comments still serves already-existing
|
||||||
|
# comments, and the keepsake's data.json still embeds their text. Irrelevant if the flag
|
||||||
|
# is off from the first boot, since no comment can ever have been written.
|
||||||
|
# NOTE for the current deployment: `docker-compose.yml` PINS this to "false" on the app
|
||||||
|
# service, and `environment` overrides `env_file` — so changing it here has no effect in
|
||||||
|
# production. Remove that line from the compose file first if you want comments back.
|
||||||
|
COMMENTS_ENABLED=true
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||||
|
# SET THIS IN PRODUCTION. Without it the app falls back to
|
||||||
|
# `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a
|
||||||
|
# debug line per HTTP request — including every preview and thumbnail fetch. Combined with
|
||||||
|
# Docker's json-file driver it writes to the same filesystem as the database and the media.
|
||||||
|
# docker-compose.yml caps each service's logs at 30 MB; this keeps the volume sane in the
|
||||||
|
# first place. The e2e stack has always used exactly this value.
|
||||||
|
RUST_LOG=eventsnap_backend=info,tower_http=warn
|
||||||
|
|||||||
38
.github/workflows/checks.yml
vendored
38
.github/workflows/checks.yml
vendored
@@ -102,6 +102,44 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
|
|
||||||
|
export-viewer:
|
||||||
|
name: Keepsake viewer — builds, self-contained, committed artifact in sync
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: 'frontend/export-viewer/package-lock.json'
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ./frontend/export-viewer
|
||||||
|
run: npm ci || npm install
|
||||||
|
|
||||||
|
# Two things nothing else in CI covered, both of which ship a broken keepsake silently.
|
||||||
|
#
|
||||||
|
# 1. The build's own self-contained guard (`inlineThemeFonts`) is the only thing standing
|
||||||
|
# between an added theme asset and a viewer that reaches for files on the guest's disk.
|
||||||
|
# It is a build-time `this.error`, so it only fires when somebody runs this build — and
|
||||||
|
# no workflow, Dockerfile or script did. It could sit disarmed indefinitely.
|
||||||
|
#
|
||||||
|
# 2. `backend/static/export-viewer/index.html` is COMMITTED and compiled into the binary with
|
||||||
|
# `include_dir!`. A viewer source change merged without a manual rebuild ships the stale
|
||||||
|
# artifact, and nothing anywhere would say so. `git diff --exit-code` is the check.
|
||||||
|
- name: Build the standalone viewer
|
||||||
|
working-directory: ./frontend/export-viewer
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Committed artifact matches a clean rebuild
|
||||||
|
run: |
|
||||||
|
if ! git diff --exit-code -- backend/static/export-viewer/; then
|
||||||
|
echo "::error::backend/static/export-viewer/ is out of date with frontend/export-viewer/."
|
||||||
|
echo "Run 'npm run build' in frontend/export-viewer and commit the result."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
e2e-typecheck:
|
e2e-typecheck:
|
||||||
name: E2E — typecheck + lint
|
name: E2E — typecheck + lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
108
Caddyfile
108
Caddyfile
@@ -1,3 +1,33 @@
|
|||||||
|
{
|
||||||
|
servers {
|
||||||
|
timeouts {
|
||||||
|
# Slowloris defence, at the layer that can actually apply it.
|
||||||
|
#
|
||||||
|
# There was no read or write timeout anywhere, so a client could open a POST, send one
|
||||||
|
# byte a minute, and hold a connection, a tokio task and a `.tmp` file indefinitely —
|
||||||
|
# and the upload sweeper is keyed on mtime precisely so a live upload never ages out,
|
||||||
|
# so ten such connections consumed disk the upload gate could not see.
|
||||||
|
#
|
||||||
|
# read_header is tight: a legitimate client sends its headers in one go.
|
||||||
|
read_header 10s
|
||||||
|
# read_body is GENEROUS but present. It was omitted on the reasoning that "a slow body
|
||||||
|
# still has to actually send bytes" — which is an argument about disk, and disk is not
|
||||||
|
# the scarce resource here. `upload_admission` budgets concurrent bodies at 4096 MiB and
|
||||||
|
# reserves the DECLARED cap, so a `video/*` upload reserves 500 MiB: eight connections
|
||||||
|
# that stall mid-body hold the entire budget, every other guest waits 20s and gets a
|
||||||
|
# 503, and it never recovers on its own because the permit is held until the handler
|
||||||
|
# returns. That needs no attacker — eight guests starting real videos and then walking
|
||||||
|
# out of AP range does it, and TCP will not reap those sockets for hours.
|
||||||
|
#
|
||||||
|
# 30m carries a 500 MB video at ~2.2 Mbit/s sustained, which is well under venue wifi
|
||||||
|
# and under most cellular, so it does not fail the uploads this product exists to
|
||||||
|
# collect. It does bound the leak to something that drains.
|
||||||
|
read_body 30m
|
||||||
|
idle 5m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{$DOMAIN} {
|
{$DOMAIN} {
|
||||||
# Compress everything EXCEPT the SSE stream — gzip buffering delays
|
# Compress everything EXCEPT the SSE stream — gzip buffering delays
|
||||||
# "real-time" likes/comments until the ~30s keep-alive tick.
|
# "real-time" likes/comments until the ~30s keep-alive tick.
|
||||||
@@ -31,20 +61,27 @@
|
|||||||
@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 served by the app through a visibility-checked
|
# Preview/thumbnail/display images. These are served by the app through a
|
||||||
# alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation can revoke access;
|
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail,display}) so
|
||||||
# the app serves no /media route at all, so there is no direct path to the bytes.
|
# moderation can revoke access; the app serves no /media route at all, so there is no
|
||||||
# Privately cacheable for a short window (the app sets the same header; this is the
|
# direct path to the bytes. Privately cacheable for a short window (the app sets the
|
||||||
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
# same header; this is the edge carve-out from the blanket no-store below). Kept short
|
||||||
# stops being served to a direct-URL holder promptly.
|
# so a moderated image stops being served to a direct-URL holder promptly.
|
||||||
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
#
|
||||||
|
# `display` was missing here while the backend set `private, max-age=300` on it, and
|
||||||
|
# because `header` REPLACES, the blanket no-store below silently won. That route is the
|
||||||
|
# ~2048px derivative the diashow uses exclusively, so a projector left running all
|
||||||
|
# evening re-fetched a full-size JPEG for every slide — roughly 2-4 GB pulled through
|
||||||
|
# the app over 8 hours, on the same venue uplink 100 guests are uploading over, and a
|
||||||
|
# blank frame on every network hiccup.
|
||||||
|
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail /api/v1/upload/*/display
|
||||||
header @media_api Cache-Control "private, max-age=300"
|
header @media_api Cache-Control "private, max-age=300"
|
||||||
|
|
||||||
# API and health — never cache, EXCEPT the gated image routes above. A cached health
|
# 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.
|
# response would report the last known state rather than the current one.
|
||||||
@api {
|
@api {
|
||||||
path /api/* /health
|
path /api/* /health
|
||||||
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail /api/v1/upload/*/display
|
||||||
}
|
}
|
||||||
header @api Cache-Control "no-store"
|
header @api Cache-Control "no-store"
|
||||||
|
|
||||||
@@ -68,4 +105,59 @@
|
|||||||
|
|
||||||
# Everything else goes to SvelteKit frontend
|
# Everything else goes to SvelteKit frontend
|
||||||
reverse_proxy frontend:3001
|
reverse_proxy frontend:3001
|
||||||
|
|
||||||
|
# Last-resort page for when Caddy itself cannot reach an upstream — the app or frontend
|
||||||
|
# container down, restarting, or still warming up after a host reboot. Without it a guest
|
||||||
|
# gets Caddy's bodiless 502: a completely blank page, which reads as "the whole thing is
|
||||||
|
# gone" rather than "try again in a moment".
|
||||||
|
#
|
||||||
|
# THIS DOES NOT TOUCH APPLICATION ERRORS. `handle_errors` fires only on errors CADDY
|
||||||
|
# generates; a status the app returns through `reverse_proxy` is written back verbatim and
|
||||||
|
# never reaches here. That distinction is load-bearing rather than incidental: the keepsake
|
||||||
|
# download navigates a HIDDEN IFRAME and depends on a real 404/429 arriving from the app
|
||||||
|
# (frontend/src/routes/export/+page.svelte), and every API route answers 403/404/429 as
|
||||||
|
# ordinary JSON that the client parses. Swallowing those into an HTML page would be a far
|
||||||
|
# worse regression than the blank 502 this fixes. Verified against this exact config: an
|
||||||
|
# upstream 404 through `reverse_proxy` still arrives as `Content-Type: application/json`
|
||||||
|
# with its body intact, while only a dial failure renders the page below.
|
||||||
|
#
|
||||||
|
# Scoped to 5xx so a hypothetical future Caddy-generated 4xx (there is none today) still
|
||||||
|
# returns plainly instead of claiming the server is restarting.
|
||||||
|
#
|
||||||
|
# The body is inline because the caddy service mounts ONLY ./Caddyfile and caddy_data —
|
||||||
|
# there is no volume to ship an HTML file through and the image has no build step, so a
|
||||||
|
# static file would mean changing the deployed stack's compose definition. No external
|
||||||
|
# font, stylesheet or image is referenced: the app may be exactly what is down.
|
||||||
|
#
|
||||||
|
# `handle_errors` has NO position in the directive order — Caddy hoists it into a separate
|
||||||
|
# `errors` route list — so it cannot disturb the "first `header` directive wins" hazard
|
||||||
|
# documented at the top of this file. The site-wide security headers still apply to it.
|
||||||
|
handle_errors 5xx {
|
||||||
|
header Content-Type "text/html; charset=utf-8"
|
||||||
|
header Cache-Control "no-store"
|
||||||
|
# {err.status_code} preserves the real status. Hardcoding 503 would mislabel a genuine
|
||||||
|
# 502 for anything watching from outside.
|
||||||
|
respond `<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Gleich zurück</title>
|
||||||
|
<style>
|
||||||
|
html{background:#faf9f7;color:#1a1918;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||||
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center}
|
||||||
|
h1{font-family:Georgia,"Times New Roman",serif;font-weight:600;font-size:1.5rem;margin:0 0 .75rem}
|
||||||
|
p{margin:0;color:#545350;line-height:1.5}
|
||||||
|
@media (prefers-color-scheme:dark){html{background:#100f0f;color:#f5f4f2}p{color:#a6a4a1}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Wir sind gleich zurück</h1>
|
||||||
|
<p>Die Seite wird gerade neu gestartet.<br>Bitte lade in einem Moment neu — deine Fotos bleiben gespeichert.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
` {err.status_code}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
974
DEPLOYMENT_RUNBOOK.md
Normal file
974
DEPLOYMENT_RUNBOOK.md
Normal file
@@ -0,0 +1,974 @@
|
|||||||
|
# EventSnap — Production Deployment Runbook
|
||||||
|
|
||||||
|
Target: **Hetzner CX22** (2 vCPU, 4 GB RAM, 40 GB disk), single host, docker compose.
|
||||||
|
Event profile: ~100 guests, ~100 photos + a few videos, one evening, **operator attending and unavailable to troubleshoot**.
|
||||||
|
|
||||||
|
Everything here is written for that last constraint. Where a choice trades throughput for
|
||||||
|
"cannot need attention on the night", it takes the stable option.
|
||||||
|
|
||||||
|
> **Note on this repo's own docs.** `README.md:62` and `PROJECT.md:359` specify a **CX33
|
||||||
|
> (4 vCPU / 8 GB / 80 GB)**. You are deploying to half of that on every axis. Two pieces of
|
||||||
|
> tuning advice in `.env.example` are calibrated for the CX33 and are actively wrong for a
|
||||||
|
> CX22 — they are called out in §3. Trust this file over `.env.example` for sizing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Timeline — the single most important control
|
||||||
|
|
||||||
|
### Step zero: verify the deployment files are committed, before you build anything
|
||||||
|
|
||||||
|
Everything the server clones must be in git — §7 tells you to `git clone` onto the box, so
|
||||||
|
anything living only in your working tree is not part of the deployment. Two failure modes if it
|
||||||
|
is not:
|
||||||
|
|
||||||
|
- If the committed `docker-compose.yml` still carried `build:` keys and no `image:` keys, then on
|
||||||
|
that clone `docker compose pull` would skip both services and `docker compose up -d` would start
|
||||||
|
**a fat-LTO release build of 427 crates on the CX22** — the exact scenario §1 rules out as an
|
||||||
|
expected OOM.
|
||||||
|
- `sqlx::migrate!()` embeds `./migrations` **at compile time**. An image built from a working tree
|
||||||
|
with uncommitted migrations bakes them in and applies them on first boot; any later rebuild from
|
||||||
|
a clean clone produces an image that lacks them and crash-loops with `VersionMissing` against its
|
||||||
|
own database.
|
||||||
|
|
||||||
|
**As of this writing all of these are committed and the check below passes.** Run it anyway — it
|
||||||
|
costs a second and it is the difference between finding this now and finding it at T‑5.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Every deployment file must be tracked. Prints nothing and exits 0 when correct;
|
||||||
|
# names the offender and exits non-zero otherwise.
|
||||||
|
git ls-files --error-unmatch \
|
||||||
|
docker-compose.yml docker-compose.dev.yml docker-compose.build.yml .env.example \
|
||||||
|
backend/.dockerignore frontend/.dockerignore frontend/Dockerfile \
|
||||||
|
DEPLOYMENT_RUNBOOK.md Caddyfile >/dev/null
|
||||||
|
|
||||||
|
# No uncommitted edits to them.
|
||||||
|
git status --porcelain -- docker-compose.yml .env.example Caddyfile DEPLOYMENT_RUNBOOK.md
|
||||||
|
|
||||||
|
# The COMMITTED compose must pull, not build: 4 `image:` lines, zero `build:` lines.
|
||||||
|
git show HEAD:docker-compose.yml | grep -cE '^[[:space:]]*image:' # must be 4
|
||||||
|
git show HEAD:docker-compose.yml | grep -cE '^[[:space:]]*build:' # must be 0
|
||||||
|
|
||||||
|
# Every migration in the tree is committed — a build from a dirty tree bakes in extras.
|
||||||
|
git status --porcelain -- backend/migrations/ # must print nothing
|
||||||
|
|
||||||
|
# The Caddyfile PARSES. Nothing else checks it: the e2e stack mounts `e2e/Caddyfile.test`,
|
||||||
|
# so the production file is never executed until the real deploy — and a syntax error there
|
||||||
|
# is total. Caddy exits, `restart: unless-stopped` loops, 443 is dead for the whole event,
|
||||||
|
# and `docker compose up -d --force-recreate caddy` still exits 0 while it crash-loops.
|
||||||
|
docker run --rm -v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" -e DOMAIN=example.com \
|
||||||
|
caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile # must end "Valid configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
| When | What |
|
||||||
|
|---|---|
|
||||||
|
| **T‑7 days** | Commit and push everything above. Registry + DNS pre-flight (§5). Build and push images (§6). |
|
||||||
|
| **T‑5 days** | First deploy to the server (§7). Verify admin login. Leave it running. |
|
||||||
|
| **T‑5 days** | ⚠ **Enable Hetzner automated snapshots** (§10.1) and **point an uptime monitor at `/health`** (§10.4). Two console checkboxes, ~10 minutes total. Without them a failure during the event is both total and unnoticed. |
|
||||||
|
| **T‑3 days** | **Freeze migrations.** No further code deploys unless something is broken. |
|
||||||
|
| **T‑2 days** | Pre-pull current *and* previous image tags (§9). Install the hourly DB dump and prove it runs (§10.2). Run the backup rehearsal (§10.3). |
|
||||||
|
| **Event day** | Change nothing. Configuration tweaks via the admin dashboard only (§4). |
|
||||||
|
|
||||||
|
**Why the freeze matters more than anything else here.** Migrations run automatically at boot
|
||||||
|
(`backend/src/db.rs`, `create_pool`) and `sqlx::migrate!()` is used *without* `set_ignore_missing`. An older
|
||||||
|
image booted against a newer schema does not degrade — it **crash-loops** with `VersionMissing`.
|
||||||
|
The repo documents this itself in `backend/migrations/014_export_epoch.up.sql:3-8`. Once the
|
||||||
|
migration set is frozen, rollback is a one-line `.env` edit; before it is frozen, rollback means
|
||||||
|
hand-running down-SQL under pressure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Deployment strategy — build on the Mac, push, pull
|
||||||
|
|
||||||
|
**Decision: build `linux/amd64` images on the M3 Pro, push to `registry.mc02.dev`, pull on the server.**
|
||||||
|
|
||||||
|
Rejected alternatives, with the reason each loses:
|
||||||
|
|
||||||
|
| Option | Why not |
|
||||||
|
|---|---|
|
||||||
|
| **Build on the CX22** | `backend/Cargo.toml` sets `lto = true` + `codegen-units = 1` over **427 crates**. Fat LTO links the whole program in one single-threaded process; peak RSS is estimated at 2.5–4 GB against ~1.5–2.5 GB free with the stack running. OOM is the expected outcome, not a tail risk. And *rollback would also be a build* — 35–60 min under pressure. |
|
||||||
|
| **CI (Gitea on Pi 5)** | Ruled out by you, and correct: a Pi 5 is strictly worse than the CX22 for a fat-LTO link. |
|
||||||
|
| **True cross-compilation** (`--target x86_64-unknown-linux-musl`) | The dependency graph has **four C-compiling crates** — `ring` (per-arch assembly), `zstd-sys`, `libdeflate-sys`, `libsqlite3-sys` — so it needs a real musl cross-toolchain. Alpine has no such package for an arm64 host; the working answer is `cargo-zigbuild`, which means a new base image and a new toolchain days before an unrepeatable event. **Slow but certain beats fast but novel.** |
|
||||||
|
|
||||||
|
Emulated build is the right call because the cost lands where it is free: on your laptop, days
|
||||||
|
early, with nothing depending on it. Estimated wall-clock for the backend is **25–60 min with
|
||||||
|
Rosetta enabled** (hours without it) — start it and walk away.
|
||||||
|
|
||||||
|
> **Escape hatch if emulation is unbearable:** spin up a temporary Hetzner CPX41 (8 vCPU/16 GB) in
|
||||||
|
> the same account, build natively, push, destroy it. Costs cents, same commands, no repo change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Repo changes — ALREADY APPLIED
|
||||||
|
|
||||||
|
> **Status: these are done, in the working tree.** They are documented here so you know what
|
||||||
|
> changed and why, not as work to repeat. Run `git diff` to review before committing.
|
||||||
|
|
||||||
|
### 2.1 `.dockerignore` files (were absent)
|
||||||
|
|
||||||
|
`backend/.dockerignore`:
|
||||||
|
```
|
||||||
|
target/
|
||||||
|
```
|
||||||
|
|
||||||
|
`frontend/.dockerignore`:
|
||||||
|
```
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, the first time you run `cargo build` or `npm install` locally, every image build
|
||||||
|
ships a multi-GB context to an *emulated* builder. Worse: `frontend/Dockerfile:8` does `COPY . .`
|
||||||
|
**after** `npm ci`, so a macOS `node_modules/` would be merged over the container's Linux one.
|
||||||
|
|
||||||
|
### 2.2 Switch compose from `build:` to `image:`
|
||||||
|
|
||||||
|
In `docker-compose.yml`, replace the `build:` block on `app` and `frontend`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
image: registry.mc02.dev/eventsnap/app:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||||
|
```
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
image: registry.mc02.dev/eventsnap/frontend:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two deliberate properties:
|
||||||
|
- The `:?` form **fails loudly** on an unset variable instead of resolving to an empty tag.
|
||||||
|
- **Removing `build:` entirely is a safety feature.** With no `build:` key on the server, a wrong
|
||||||
|
tag is an instant `manifest unknown` — never a surprise 45-minute compile on the production box.
|
||||||
|
|
||||||
|
New `docker-compose.build.yml` (opt-in, Mac only — follows the convention stated in
|
||||||
|
the header of `docker-compose.dev.yml` that overlays are never auto-loaded):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Build overlay. NOT loaded automatically. Used only where images are BUILT — never on the
|
||||||
|
# production server, which pulls.
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.build.yml build
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: { context: ./backend, dockerfile: Dockerfile }
|
||||||
|
frontend:
|
||||||
|
build: { context: ./frontend, dockerfile: Dockerfile }
|
||||||
|
```
|
||||||
|
|
||||||
|
`e2e/docker-compose.test.yml` keeps its own `build:` blocks, so the e2e gate is unaffected.
|
||||||
|
|
||||||
|
### 2.3 Add log rotation to every service
|
||||||
|
|
||||||
|
`docker-compose.yml` currently sets **no logging config**, so the default `json-file` driver keeps
|
||||||
|
logs forever, on the same filesystem as Postgres and the media. Add to each of the four services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: { max-size: "10m", max-file: "3" }
|
||||||
|
```
|
||||||
|
|
||||||
|
(Equivalently, set it once in `/etc/docker/daemon.json` — but that restarts the Docker daemon,
|
||||||
|
so do it *before* the stack is live, not after.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `.env` — production values
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── Identity ──────────────────────────────────────────────────────────────
|
||||||
|
DOMAIN=<your domain>
|
||||||
|
EVENT_NAME=<...>
|
||||||
|
EVENT_SLUG=<...>
|
||||||
|
|
||||||
|
# ── Image version (NEW — drives the image: tags in docker-compose.yml) ────
|
||||||
|
EVENTSNAP_VERSION=v0.13.0
|
||||||
|
|
||||||
|
# ── Secrets — ALL of them, before the first `up -d` ───────────────────────
|
||||||
|
JWT_SECRET=<openssl rand -hex 64>
|
||||||
|
# These two are NOT optional and have no defaults. docker-compose.yml interpolates them into
|
||||||
|
# `environment:`, which overrides `env_file`, so leaving them out does not fall back — it creates
|
||||||
|
# a Postgres role and database named "" while DATABASE_URL still says `eventsnap`. The result is
|
||||||
|
# a permanent crash loop whose only clean exit is `down -v`. Compose now refuses to start without
|
||||||
|
# them, but write them here anyway: the three values below must agree with each other.
|
||||||
|
POSTGRES_USER=eventsnap
|
||||||
|
POSTGRES_DB=eventsnap
|
||||||
|
POSTGRES_PASSWORD=<openssl rand -hex 24>
|
||||||
|
DATABASE_URL=postgres://eventsnap:<SAME PASSWORD>@db:5432/eventsnap
|
||||||
|
ADMIN_PASSWORD_HASH='<docker run --rm caddy:2-alpine caddy hash-password --plaintext "pw">'
|
||||||
|
|
||||||
|
# ── Paths — must match the volume mounts ──────────────────────────────────
|
||||||
|
# All four of these are PINNED in docker-compose.yml under `app.environment`, which overrides
|
||||||
|
# `env_file`. Keep them consistent here for readability, but understand that editing them in
|
||||||
|
# `.env` changes nothing — the pin is what the container gets. Change the pin.
|
||||||
|
MEDIA_PATH=/media
|
||||||
|
EXPORT_PATH=/exports
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# ── Sizing (see the two corrections below) ────────────────────────────────
|
||||||
|
# 15, matching .env.example, the `db` sizing comment in docker-compose.yml and the code
|
||||||
|
# default. An earlier draft of this runbook said 30: that does not fit the 1G memory limit
|
||||||
|
# compose allots `db`, and 30 simultaneous queries cannot run on 2 vCPU anyway — they queue
|
||||||
|
# on the CPU instead of on the pool. Raise it only alongside more cores AND a bigger limit.
|
||||||
|
#
|
||||||
|
# ALSO PINNED IN COMPOSE (see above), and pinned for a sharper reason than the paths: an
|
||||||
|
# unparseable value here is boot-FATAL rather than falling back to the default, so a stray
|
||||||
|
# quote or a trailing inline comment in `.env` would crash-loop the app behind a live Caddy.
|
||||||
|
DATABASE_MAX_CONNECTIONS=15
|
||||||
|
COMPRESSION_WORKER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# ── Comments off, likes + captions on ─────────────────────────────────────
|
||||||
|
COMMENTS_ENABLED=false
|
||||||
|
|
||||||
|
# ── Logging (NEW — production currently defaults to DEBUG) ────────────────
|
||||||
|
RUST_LOG=eventsnap_backend=info,tower_http=warn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two sizing decisions worth understanding before you touch them
|
||||||
|
|
||||||
|
`.env.example` now agrees with this section on both — it carries the same reasoning inline and
|
||||||
|
self-corrects the old advice. Kept here because these are the two knobs an operator is most
|
||||||
|
tempted to raise under pressure.
|
||||||
|
|
||||||
|
**`COMPRESSION_WORKER_CONCURRENCY`: keep `2`. Do NOT raise to 4, and do NOT raise the app memory
|
||||||
|
limit to 2G.** An earlier draft justified both on the premise that "each worker can run an
|
||||||
|
ffmpeg transcode". **There is no transcode anywhere in this codebase.** `services/video.rs::run_ffmpeg`
|
||||||
|
runs `ffmpeg -ss <t> -i <src> -vframes 1 -vf scale=…` — a single poster frame. Video originals are
|
||||||
|
stored and served byte-for-byte.
|
||||||
|
|
||||||
|
The real memory consumer is the **image** path: `image` 0.25's `resize` builds an `Rgba32F`
|
||||||
|
intermediate at **16 bytes/px**, sized `source_width × target_height`, which the 256 MiB decode
|
||||||
|
guard in `imaging::decode_limits` does not cover. Estimated peak per photo:
|
||||||
|
|
||||||
|
| Source | Peak (decode + display resize) |
|
||||||
|
|---|---|
|
||||||
|
| 12 MP (typical phone) | ~145 MB |
|
||||||
|
| 24 MP (iPhone Pro default) | ~223 MB |
|
||||||
|
| 48 MP ("Max" mode) | ~354 MB |
|
||||||
|
|
||||||
|
Those are per-photo peaks, and the "two 48 MP photos at once" pair this limit used to be sized
|
||||||
|
against **is no longer reachable**: `compression.rs` takes an EXCLUSIVE `heavy` permit for a large
|
||||||
|
decode, so two giants serialise no matter what `COMPRESSION_WORKER_CONCURRENCY` is set to (see
|
||||||
|
`.env.example`, which makes the same point). The binding case is now one giant (~354 MB) plus the
|
||||||
|
ordinary working set against the 1 GiB cap, which is comfortable.
|
||||||
|
|
||||||
|
What has not changed is the reason to keep concurrency at 2 and `app` at 1G: at concurrency 4 the
|
||||||
|
memory arithmetic stops working (app=2G + db=1G + 256M + 256M + ~370 MB OS/Docker ≈ 3954 MiB
|
||||||
|
against ~3910 MiB MemTotal — oversubscribed before a single photo arrives).
|
||||||
|
|
||||||
|
**`quota_tolerance`: keep `0.75`. Raising it does not make anything more generous for a real guest.**
|
||||||
|
See §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin dashboard configuration
|
||||||
|
|
||||||
|
These live in the DB `config` table, not `.env`. Changes take effect on the **next request** —
|
||||||
|
`patch_config` invalidates the cache synchronously (`admin::patch_config`). No restart needed. Set them
|
||||||
|
after the first deploy, before the event.
|
||||||
|
|
||||||
|
| Key | Default | **Set to** | Why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `upload_rate_per_hour` | 100 | **1000** | A guest multi-selecting 100 photos hits exactly 100. Client backs off and resumes, but this makes it a non-issue. |
|
||||||
|
| `feed_rate_per_min` | 60 | **240** | Headroom for reconnect bursts. |
|
||||||
|
| `social_rate_per_min` | 120 | **600** | This is the **likes** limiter — the interaction you're keeping. |
|
||||||
|
| `export_rate_per_day` | 3 | **20** | **One shared bucket across both archives** — `enforce_export_rate` keys on `export:{user_id}` regardless of which archive is being fetched. Downloading Gallery.zip + Memories.zip costs 2 of 3; one retry locks a guest out for 24 h. The limit is now charged when the download *ticket* is minted, so being over it produces a visible German error instead of a tap that silently does nothing. |
|
||||||
|
| `max_video_size_mb` | 500 | **leave at 500** | See below. |
|
||||||
|
| `quota_tolerance` | 0.75 | **leave at 0.75** | See below. |
|
||||||
|
| `quota_enabled`, `storage_quota_enabled`, `rate_limits_enabled` | true | **leave on** | This is the only disk-full safety net. |
|
||||||
|
|
||||||
|
> **Correction (was wrong in an earlier draft).** This section used to say "**Ignore
|
||||||
|
> `estimated_guest_count`** … read by no code at all". That is **false** — it is a live tuning
|
||||||
|
> knob and it is the dominant term in the quota divisor for a normal event. An operator who
|
||||||
|
> believed the old text and changed it would have moved every guest's ceiling. `upload_count_quota_enabled`
|
||||||
|
> genuinely is inert.
|
||||||
|
|
||||||
|
### Why quotas are already as generous as you want
|
||||||
|
|
||||||
|
```
|
||||||
|
divisor = max(active_uploaders, estimated_guest_count, 1)
|
||||||
|
per_user_limit = max(floor(free_disk × quota_tolerance / divisor), 500 MiB)
|
||||||
|
```
|
||||||
|
(`upload::quota_limit_bytes`. The 500 MiB floor applies only when the whole budget can back it —
|
||||||
|
below that the divided value stands, so the quota cannot promise space the disk does not have.)
|
||||||
|
|
||||||
|
`active_uploaders` is `SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL` —
|
||||||
|
**people who actually uploaded**, not guests who joined. But it is a `max`, not the sole divisor:
|
||||||
|
`estimated_guest_count` (default **100**) acts as a **floor on the divisor**, so the ceiling settles
|
||||||
|
at its final value early instead of sliding down all evening as guests arrive. It also blunts the
|
||||||
|
abuse case where the divisor was attacker-controlled — ~1000 throwaway accounts once drove every
|
||||||
|
real guest's ceiling to ~52 MB.
|
||||||
|
|
||||||
|
With ~28 GB free, `quota_tolerance` 0.75 and a realistic 30 people actually uploading, the divisor
|
||||||
|
is **100** (not 30, because `estimated_guest_count` floors it), giving 28 GB × 0.75 / 100 ≈ 210 MB —
|
||||||
|
which is below the floor, so **every guest is granted the 500 MiB minimum**. Against an expected
|
||||||
|
~1.25 GB for the *entire event*, nobody will be blocked. An earlier draft computed "~700 MB each"
|
||||||
|
by dividing by 30; that ignored the floor on the divisor and was wrong.
|
||||||
|
|
||||||
|
Raising `quota_tolerance` would only raise the **saturation ceiling** (media converges to
|
||||||
|
`t/(1+t)` of free space: 43% at 0.75, 50% at 1.0). It does nothing for a real guest at your volume,
|
||||||
|
and it eats the headroom the keepsake needs — the export preflight reserves `media × 1.10 × 2`
|
||||||
|
because `Gallery.zip` and `Memories.zip` each store media **uncompressed** (`export.rs` — both archives store media uncompressed).
|
||||||
|
|
||||||
|
**Why `max_video_size_mb` stays at 500.** An earlier draft of this runbook said to lower it to 250,
|
||||||
|
on the grounds that there is no client-side size check. That was wrong on both halves:
|
||||||
|
|
||||||
|
- A client guard **does** exist — `frontend/src/routes/upload/+page.svelte` rejects anything over
|
||||||
|
`HARD_MAX_UPLOAD_BYTES` (576 MiB) before a byte leaves the phone, alongside the HEIC reject.
|
||||||
|
- That guard is a **compile-time constant**, not the DB value. Lowering `max_video_size_mb` therefore
|
||||||
|
changes nothing on the client: the picker still accepts the clip, the phone still starts sending
|
||||||
|
it, and the *server* aborts it partway (`stream_field_to_file` does stop at the cap, so the AP is
|
||||||
|
not saturated for the full file — but the guest still gets a mid-upload failure where 500 MB would
|
||||||
|
simply have worked).
|
||||||
|
|
||||||
|
So lowering it is a pure capacity decision with no UX upside, and at your volume there is no capacity
|
||||||
|
problem to solve. Leave it. If you ever do want a smaller ceiling to bite on the phone, the constant
|
||||||
|
above has to move with it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pre-flight — T‑7 days (do NOT leave this to event week)
|
||||||
|
|
||||||
|
### Registry
|
||||||
|
|
||||||
|
From **both** the Mac and the server, as **the user who will deploy** (root's `docker login` does
|
||||||
|
not help a non-root deploy — credentials go to `~/.docker/config.json`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
getent hosts registry.mc02.dev # DNS resolves from the server
|
||||||
|
curl -fsSI https://registry.mc02.dev/v2/ # TLS must be PUBLICLY trusted; Docker rejects
|
||||||
|
# self-signed without extra daemon config
|
||||||
|
docker login registry.mc02.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Also confirm: the `eventsnap` repository/namespace exists if your registry requires pre-creation
|
||||||
|
(Harbor does; plain `registry:2` does not), and the registry host has ~500 MB free per release.
|
||||||
|
|
||||||
|
### DNS and TLS — get this wrong and you are locked out for an hour
|
||||||
|
|
||||||
|
The DNS **A record must point at the CX22 before the first `docker compose up -d`**, because Caddy
|
||||||
|
attempts certificate issuance on boot. Let's Encrypt allows only **5 failed validations per hostname
|
||||||
|
per hour** (refilling 1 per 12 min). A misconfigured DNS record plus a few impatient restarts will
|
||||||
|
rate-limit you out of getting a certificate at all.
|
||||||
|
|
||||||
|
- Deploy days early so issuance happens with no time pressure.
|
||||||
|
- **Never delete the `caddy_data` volume** — it holds the certificate and the ACME account key.
|
||||||
|
- Never run `docker compose down -v`. It destroys `postgres_data`, `media_data`, `exports_data`
|
||||||
|
*and* `caddy_data`.
|
||||||
|
|
||||||
|
### Host preparation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose version # must be v2.x — see below, this one is not optional
|
||||||
|
free -h && swapon --show # Hetzner images ship no swap
|
||||||
|
df -h /var/lib/docker # want ≥ 25 GB free
|
||||||
|
```
|
||||||
|
|
||||||
|
> **If `docker compose version` reports v1 (or `docker-compose` is a separate Python binary), STOP
|
||||||
|
> and install the v2 plugin before deploying.** This check previously had no failure action, which
|
||||||
|
> made it decorative — and it is the single check that the whole sizing argument rests on.
|
||||||
|
>
|
||||||
|
> On Compose v1, `deploy.resources.limits` is **silently ignored** outside Swarm: no warning, no
|
||||||
|
> error, `up -d` exits 0. Every memory and CPU limit in `docker-compose.yml` evaporates, and §1's
|
||||||
|
> arithmetic (`app` 1G + `db` 1G + 256M + 256M inside ~3910 MiB) becomes fiction — the first 48 MP
|
||||||
|
> photo takes the box out via the OOM killer instead of being bounded. On v2 the limits are real
|
||||||
|
> (verified empirically: `memory: 1G` produces `HostConfig.Memory=1073741824`).
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> # Debian/Ubuntu, with Docker's official repo already configured:
|
||||||
|
> apt-get update && apt-get install -y docker-compose-plugin
|
||||||
|
> docker compose version # must now print v2.x
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Verify the limits actually landed, once the stack is up — this is the check that matters, not the
|
||||||
|
> version string:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker inspect eventsnap-app-1 --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'
|
||||||
|
> # Must print two NON-ZERO numbers. `0 0` means the limits were dropped.
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**Add 2 GB of swap** as an OOM cushion — a compression spike that would otherwise kill the container
|
||||||
|
instead swaps out cold pages and merely runs slowly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
|
||||||
|
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||||
|
sysctl -w vm.swappiness=10 && echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Already handled — do not hand-edit compose.** Compose sets each container's `Memory` limit but
|
||||||
|
> leaves `MemorySwap` unset, and Docker then allows swap equal to the memory limit, so adding host
|
||||||
|
> swap would silently **double** every container ceiling (to ~5 GiB of ceilings on a 3.82 GiB box).
|
||||||
|
> `docker-compose.yml` now ships `memswap_limit` on all four services — 1152m on `app` and `db`,
|
||||||
|
> 320m on `frontend` and `caddy` — so this step is safe as written.
|
||||||
|
>
|
||||||
|
> This used to say "add it yourself", which also broke §0's own gate that
|
||||||
|
> `git status --porcelain -- docker-compose.yml` must print nothing. Confirm it is still there:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker inspect eventsnap-app-1 --format '{{.HostConfig.Memory}} {{.HostConfig.MemorySwap}}'
|
||||||
|
> # 1073741824 1207959552 — the second number MUST be larger than the first but not double it.
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Build and push — on the Mac
|
||||||
|
|
||||||
|
Enable **Docker Desktop → Settings → General → "Use Rosetta for x86_64/amd64 emulation"**, and set
|
||||||
|
**Resources → Memory ≥ 8 GB** (the fat-LTO step will OOM inside the VM otherwise, even with 18 GB on
|
||||||
|
the host).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --platform linux/amd64 alpine uname -m # must print x86_64
|
||||||
|
|
||||||
|
cd /Users/fabianhammprivat/Projects/EventSnap
|
||||||
|
VERSION=v0.13.0 # latest existing tag is v0.12.0 — see §9 before reusing it
|
||||||
|
ROLLBACK=v0.13.0-a # the SAME source, tagged twice; §9 explains why
|
||||||
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
docker buildx create --name eventsnap --use 2>/dev/null || docker buildx use eventsnap
|
||||||
|
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$VERSION \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$ROLLBACK \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$SHA \
|
||||||
|
--push ./backend
|
||||||
|
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t registry.mc02.dev/eventsnap/frontend:$VERSION \
|
||||||
|
-t registry.mc02.dev/eventsnap/frontend:$ROLLBACK \
|
||||||
|
-t registry.mc02.dev/eventsnap/frontend:$SHA \
|
||||||
|
--push ./frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify the architecture — never skip this
|
||||||
|
|
||||||
|
An arm64 image pulls fine and then dies with `exec format error`. Catch it here, not on the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx imagetools inspect registry.mc02.dev/eventsnap/app:$VERSION
|
||||||
|
docker buildx imagetools inspect registry.mc02.dev/eventsnap/frontend:$VERSION
|
||||||
|
# Both MUST report Platform: linux/amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
Then prove the binary actually executes. `AppConfig::from_env()` runs before any DB connection
|
||||||
|
(`main.rs` builds `AppConfig` before touching the pool), so this needs no database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --platform linux/amd64 \
|
||||||
|
-e APP_ENV=production -e JWT_SECRET=x -e DATABASE_URL=x -e EVENT_SLUG=x \
|
||||||
|
registry.mc02.dev/eventsnap/app:$VERSION
|
||||||
|
```
|
||||||
|
Expect the "Refusing to start in production … placeholder" message. **That message means the amd64
|
||||||
|
binary ran.** `exec format error` means the architecture is wrong.
|
||||||
|
|
||||||
|
**Tag policy: never `latest` in production.** With `latest` you cannot tell what is running,
|
||||||
|
rollback becomes a registry re-push (impossible if the registry is down — exactly when you need it),
|
||||||
|
and `restart: unless-stopped` after a reboot is ambiguous. Two immutable tags per build: semver and
|
||||||
|
short SHA. Deploy by semver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. First deploy — on the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Directory name matters: volumes are prefixed with it, and README's backup commands
|
||||||
|
# hardcode the `eventsnap_` prefix.
|
||||||
|
git clone <repo> eventsnap && cd eventsnap
|
||||||
|
cp .env.example .env && nano .env # every value from §3
|
||||||
|
docker login registry.mc02.dev
|
||||||
|
docker compose pull # must fully succeed before anything starts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.1 The secret pre-flight — run this before the first `up -d`, always
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm --no-deps app
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-deps` means `db` never starts, so **no Postgres data directory is initialised**.
|
||||||
|
`AppConfig::from_env()` runs first and reports *every* placeholder at once (`config::validate_secrets`).
|
||||||
|
When the only remaining complaint is a database *connection* failure, the secrets are good.
|
||||||
|
|
||||||
|
**Why this step exists.** `POSTGRES_PASSWORD` is applied **only at initdb**. `docker-compose.yml`
|
||||||
|
starts `db` in the same command as `app`, so a single `up -d` with a placeholder bakes the wrong
|
||||||
|
password in permanently — the app then loops on `password authentication failed`, and the only exits
|
||||||
|
are `ALTER ROLE` or `down -v`, which deletes the database, the media and the exports. The repo
|
||||||
|
describes this trap at `backend/src/db.rs` (`explain_auth_failure`); this command is what avoids it.
|
||||||
|
|
||||||
|
### 7.2 Bring it up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# `.env` is consumed by docker compose, not by your shell — read $DOMAIN out of it first.
|
||||||
|
# Reads that ONE variable rather than sourcing the file: `.env` legitimately holds values with
|
||||||
|
# apostrophes (EVENT_NAME), and `. ./.env` aborts on one with "Unterminated quoted string".
|
||||||
|
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'")
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f app # wait for "database connected and migrations applied"
|
||||||
|
curl -fsS https://$DOMAIN/health # → ok (503 means the app is up but the DB is not)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Verify what the container actually received
|
||||||
|
|
||||||
|
`MEDIA_PATH`, `EXPORT_PATH` and `APP_PORT` are all **pinned** on the `app` service in
|
||||||
|
`docker-compose.yml`, exactly as §3 says — editing them in `.env` changes nothing. This step is
|
||||||
|
not about whether they are pinned; it is about confirming the container got the values you think
|
||||||
|
it did, including the two that genuinely do come from `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app printenv DATABASE_URL EXPORT_PATH ADMIN_PASSWORD_HASH
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **`DATABASE_URL`** (from `.env`) must contain `@db:5432`. A dev `.env` points it at
|
||||||
|
`@localhost`, which inside the container is the app itself.
|
||||||
|
2. **`EXPORT_PATH`** (pinned) must read `/exports`. If it does not, the pin has been edited —
|
||||||
|
anywhere else and the keepsake archives are written to the container's writable layer and
|
||||||
|
**vanish on the next `up -d`**, including on a rollback.
|
||||||
|
3. **`ADMIN_PASSWORD_HASH`** (from `.env`) must match `.env` **byte for byte.**
|
||||||
|
|
||||||
|
**Then actually log in to `/admin` with the real password.** This is not optional politeness:
|
||||||
|
|
||||||
|
- The production secret guard only rejects *placeholders* (`config::looks_placeholder`). A hash **corrupted**
|
||||||
|
by shell or Compose escaping is not a placeholder — the app boots green, `/health` says `ok`, and
|
||||||
|
every admin login 401s.
|
||||||
|
- The Admin user row is created **by a successful admin login** (`auth::handlers::admin_login`), and
|
||||||
|
promoting anyone to Host requires an Admin or Host (`auth::middleware`'s role guard). **No admin login
|
||||||
|
⇒ no host, ever** ⇒ you cannot close the event, release the gallery, ban anyone, reset a PIN, or
|
||||||
|
change any limit — for the whole event.
|
||||||
|
|
||||||
|
Per the Compose spec, single-quoted `env_file` values *are* used literally and the quotes are
|
||||||
|
stripped, so the single-quote form in `.env.example` is correct. The comment in
|
||||||
|
`docker-compose.dev.yml` claiming production has the same bug is **stale**. Verify anyway —
|
||||||
|
the cost of checking is 10 seconds; the cost of being wrong is the whole event.
|
||||||
|
|
||||||
|
**Promote a second person to Host** once you are in, so a single lost session is not fatal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Post-deploy verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'") # $DOMAIN comes from .env, not your shell
|
||||||
|
|
||||||
|
docker compose ps # db, app, frontend healthy; caddy has
|
||||||
|
# no healthcheck and shows only "running"
|
||||||
|
docker inspect -f '{{.HostConfig.Memory}}' eventsnap-app-1 # must be 1073741824, not 0
|
||||||
|
curl -fsS https://$DOMAIN/health # ok — now a real DB check, not a constant
|
||||||
|
docker compose exec app printenv COMMENTS_ENABLED RUST_LOG
|
||||||
|
docker builder prune -af && docker image prune -f # reclaim build cache
|
||||||
|
df -h /var/lib/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from a phone on cellular (not the office wifi):
|
||||||
|
|
||||||
|
- [ ] Join as a guest with a PIN
|
||||||
|
- [ ] Upload a photo → appears in the feed within a few seconds
|
||||||
|
- [ ] Upload a video → plays back (iOS Safari range requests)
|
||||||
|
- [ ] Add a caption, add a like — **no comment UI anywhere**
|
||||||
|
- [ ] Admin login works; host dashboard reachable
|
||||||
|
- [ ] Release the gallery on a test event and download both archives
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Rollback
|
||||||
|
|
||||||
|
### There is no older image you can roll back to. Build the rollback target yourself.
|
||||||
|
|
||||||
|
Read this before the event, not during it. The obvious move — drop `EVENTSNAP_VERSION` back to the
|
||||||
|
previous released tag — **takes the app down permanently** and looks like a crash loop with no
|
||||||
|
explanation:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git ls-tree --name-only v0.12.0 backend/migrations/ | wc -l
|
||||||
|
12 # 6 migrations. HEAD has 31.
|
||||||
|
$ git rev-list --count v0.12.0..HEAD
|
||||||
|
217
|
||||||
|
```
|
||||||
|
|
||||||
|
`db.rs` runs `sqlx::migrate!()` with no `set_ignore_missing`, so an image built from a 6-migration
|
||||||
|
tree, booting against a database that already carries versions 007–031, returns `VersionMissing`.
|
||||||
|
`create_pool` errors, `main` exits 1, and `restart: unless-stopped` restarts it forever — with Caddy
|
||||||
|
still routing traffic to it. (`014_export_epoch.up.sql` documents this failure mode; §0 restates it.)
|
||||||
|
No `v0.12.0` image was ever built or pushed either, so the pre-pull would fail with
|
||||||
|
`manifest unknown` before you ever got that far.
|
||||||
|
|
||||||
|
**So: at build time, tag the SAME frozen commit twice.** Two identical images, two names. The
|
||||||
|
rollback then swaps to a binary that is bit-for-bit what you tested and carries the identical
|
||||||
|
migration set, which makes it a genuine no-op rather than a gamble:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In §6, push both tags from the one build:
|
||||||
|
VERSION=v0.13.0
|
||||||
|
ROLLBACK=v0.13.0-a # same source, different name — the rollback target
|
||||||
|
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$VERSION \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$ROLLBACK \
|
||||||
|
--push ./backend
|
||||||
|
# ...and the same two tags for ./frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rolling back — ~30 seconds, no network:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env
|
||||||
|
docker compose up -d app frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
This only works offline if both are already resident. **Pre-pull all four at T‑2:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull registry.mc02.dev/eventsnap/app:v0.13.0
|
||||||
|
docker pull registry.mc02.dev/eventsnap/frontend:v0.13.0
|
||||||
|
docker pull registry.mc02.dev/eventsnap/app:v0.13.0-a
|
||||||
|
docker pull registry.mc02.dev/eventsnap/frontend:v0.13.0-a
|
||||||
|
docker image ls | grep eventsnap # confirm all four
|
||||||
|
```
|
||||||
|
|
||||||
|
Once resident, `up -d`, reboots, restarts and rollbacks need **zero** registry contact. That single
|
||||||
|
step makes a registry outage on event day irrelevant.
|
||||||
|
|
||||||
|
Be clear-eyed about what this buys you: an identical image cannot undo a bad *release*, only an
|
||||||
|
image that got corrupted or a container that wedged — and `docker compose restart app` already
|
||||||
|
covers both. It exists so that the rollback line in the emergency card is safe to run rather than
|
||||||
|
catastrophic. **If you genuinely need to undo a code change during the event, you cannot; freeze
|
||||||
|
early enough that you never have to.**
|
||||||
|
|
||||||
|
**Across a migration boundary — avoid by freezing.** If you must: all 31 migrations have paired
|
||||||
|
`.down.sql` files, but **none of them removes its own `_sqlx_migrations` row**, so that second step
|
||||||
|
is mandatory and undocumented:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop app
|
||||||
|
# `sh -c` so the CONTAINER expands the credentials. They live in the container's environment
|
||||||
|
# and in .env — not in your shell — so an unwrapped `-U "$POSTGRES_USER"` sends `-U ""` and
|
||||||
|
# psql answers `FATAL: role "" does not exist`.
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' \
|
||||||
|
< backend/migrations/0NN_x.down.sql
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM _sqlx_migrations WHERE version = NN;"'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **A down migration is only valid PAIRED WITH A CODE ROLLBACK — it is not a standalone repair.**
|
||||||
|
> `Upload::create` sends an `ON CONFLICT ... WHERE` predicate that must match the live partial
|
||||||
|
> index exactly, and these queries are not compile-checked. Run **026**'s or **031**'s down against
|
||||||
|
> the current binary and every upload carrying a `client_upload_id` — i.e. every upload from the
|
||||||
|
> shipped client — becomes a runtime 500. Roll the image back first, then the migration.
|
||||||
|
>
|
||||||
|
> **026's down can also fail outright, and that is expected.** It restores a wider unique index, so
|
||||||
|
> it aborts with `could not create unique index ... is duplicated` on any database where a guest
|
||||||
|
> ever deleted a photo and re-uploaded it. The transaction rolls back cleanly and the narrow index
|
||||||
|
> survives intact — no half-state — but you cannot go below 026 on a database that has seen real
|
||||||
|
> use. Verified against a live Postgres.
|
||||||
|
|
||||||
|
Migration **014** is the only destructive one on the way up, and the only one with a rehearsal
|
||||||
|
harness — `backend/scripts/rehearse-014.sh`. Run it once against a real dump before the event.
|
||||||
|
|
||||||
|
**Registry-down transport fallback** (layers are already compressed — do not add gzip):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker save registry.mc02.dev/eventsnap/app:v0.13.0 \
|
||||||
|
registry.mc02.dev/eventsnap/frontend:v0.13.0 | ssh root@SERVER 'docker load'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Backup
|
||||||
|
|
||||||
|
Full commands are in README's **`## Backup`** and **`## Restore`** sections — read `## Restore` to
|
||||||
|
its END (through the media *and* exports restore, and the `chown` that follows), not just the
|
||||||
|
database step. Referenced by heading, not by line number: the previous pointer named a line range
|
||||||
|
that had drifted to the middle of an unrelated section and stopped mid-way through restore step 2,
|
||||||
|
which would have restored the database and no media. They are correct — `pg_dump --clean --if-exists`, plus
|
||||||
|
`alpine tar` out of `eventsnap_media_data` and `eventsnap_exports_data`, mounted at `/src` (not
|
||||||
|
`/media`), with `chown -R 100:101` on restore because the app runs non-root and BusyBox tar has no
|
||||||
|
`--same-owner`. There is deliberately no script.
|
||||||
|
|
||||||
|
Three gaps the README does not cover:
|
||||||
|
|
||||||
|
1. **Nothing backs up `.env`**, which holds the only copy of `POSTGRES_PASSWORD`. A dump you cannot
|
||||||
|
authenticate against is not a backup. Copy `.env` off the box, encrypted, once it is final.
|
||||||
|
2. **The final dump is not a backup — it is an archive.** Taking it *after* locking uploads gives
|
||||||
|
you a consistent pair, and that is the right way to archive the finished event. But it means
|
||||||
|
that until the host locks uploads there is **no copy of anything anywhere**. All four volumes
|
||||||
|
sit on the same 40 GB filesystem, on one VPS, with no redundancy. A disk or host failure at
|
||||||
|
23:00 — the fullest the gallery will ever be — loses **100% of the event**, permanently, with
|
||||||
|
the guests still in the room. Both of the mitigations below are required.
|
||||||
|
3. **Nothing is watching.** See §10.2.
|
||||||
|
|
||||||
|
### 10.1 Snapshots — do this once, before the event
|
||||||
|
|
||||||
|
> ⚠ **ACTION REQUIRED — Hetzner Cloud console, ~5 minutes, one checkbox.**
|
||||||
|
> Server → **Backups** → enable. Costs ~20% of the server price and needs no operator action
|
||||||
|
> ever again.
|
||||||
|
|
||||||
|
This is the single highest-value item in this runbook. It converts "total, permanent loss" into
|
||||||
|
"lose at most the hours since the last snapshot", automatically, with nobody awake. It covers the
|
||||||
|
whole volume set at once — database, media, exports and `.env` — which the `pg_dump` path does not.
|
||||||
|
|
||||||
|
It does **not** replace §10's archive: snapshots are whole-disk and crash-consistent, so restoring
|
||||||
|
one gives you the box back, not a portable copy of the photos. Do both.
|
||||||
|
|
||||||
|
### 10.2 A mid-event database dump — cheap, and the only thing cron should do
|
||||||
|
|
||||||
|
The database is small (a few MB — it holds rows, not pixels) and it is the part that cannot be
|
||||||
|
reconstructed: media files on disk without their `upload` rows are anonymous UUIDs with no
|
||||||
|
uploader, caption, hashtag or timestamp. Dumping it hourly costs essentially nothing and is safe
|
||||||
|
while uploads are live, because a `pg_dump` is transactionally consistent on its own.
|
||||||
|
|
||||||
|
Media is the bulk and *is* recoverable from guests' phones in the worst case, so it stays on the
|
||||||
|
event-night schedule below.
|
||||||
|
|
||||||
|
Install this as **the same user you deployed as** (§5) — not root. `docker compose` needs that
|
||||||
|
user's docker group membership and its compose project, and a root crontab has neither.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the server, before the event. Hourly DB-only dump, keeping the last 48.
|
||||||
|
mkdir -p ~/eventsnap-dumps
|
||||||
|
cat >~/eventsnap-dump.sh <<'SH'
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
# Must match your deploy directory from §5. cron starts in $HOME, so this cannot be relative.
|
||||||
|
cd "$HOME/eventsnap"
|
||||||
|
# NOTE: deliberately does NOT source .env. Nothing here reads it — POSTGRES_USER and POSTGRES_DB
|
||||||
|
# are expanded INSIDE the db container by the single-quoted sh -c below, using the values compose
|
||||||
|
# already injected. Sourcing it was actively harmful: `.env` legitimately contains values with
|
||||||
|
# apostrophes (EVENT_NAME="Max & Maria's Wedding"), and POSIX sh aborts on one with
|
||||||
|
# "Unterminated quoted string". Under `set -eu` this script would exit before pg_dump — every
|
||||||
|
# hour, silently, leaving the only automated backup of the irreplaceable table permanently empty.
|
||||||
|
OUT="$HOME/eventsnap-dumps/db-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'pg_dump --clean --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip >"$OUT.tmp"
|
||||||
|
mv "$OUT.tmp" "$OUT" # atomic: never leave a truncated dump looking complete
|
||||||
|
ls -1t "$HOME"/eventsnap-dumps/db-*.sql.gz | tail -n +49 | xargs -r rm
|
||||||
|
SH
|
||||||
|
chmod +x ~/eventsnap-dump.sh
|
||||||
|
( crontab -l 2>/dev/null; echo "17 * * * * $HOME/eventsnap-dump.sh >>$HOME/eventsnap-dumps/dump.log 2>&1" ) | crontab -
|
||||||
|
|
||||||
|
# Prove it works NOW, not at 23:00 — and prove it produced a NON-EMPTY dump, since the failure
|
||||||
|
# this replaces produced a zero-byte file and a clean exit code.
|
||||||
|
~/eventsnap-dump.sh && ls -lh ~/eventsnap-dumps/
|
||||||
|
gzip -t ~/eventsnap-dumps/db-*.sql.gz && echo "dump is a valid gzip"
|
||||||
|
zcat ~/eventsnap-dumps/db-*.sql.gz | grep -c 'CREATE TABLE' # must be > 0, not just "a file exists"
|
||||||
|
```
|
||||||
|
|
||||||
|
These land on the same filesystem, so they do **not** survive a disk loss — that is what §10.1 is
|
||||||
|
for. They protect against the far more likely failure: a bad migration, an accidental host action,
|
||||||
|
or a corrupted table.
|
||||||
|
|
||||||
|
### 10.3 The event-night archive — unchanged
|
||||||
|
|
||||||
|
Take the dump and the media tarball back-to-back **the night of the event, after locking uploads
|
||||||
|
from the host dashboard**, so the pair is consistent. Copy both off the box before you sleep.
|
||||||
|
|
||||||
|
### 10.4 Monitoring — something has to be able to wake you
|
||||||
|
|
||||||
|
> ⚠ **ACTION REQUIRED — external uptime monitor, ~5 minutes.**
|
||||||
|
> Point any free monitor (UptimeRobot, Better Stack, Healthchecks.io — all have free tiers with
|
||||||
|
> SMS or push) at `https://$DOMAIN/health`, 1–5 minute interval, **alerting to a phone that will
|
||||||
|
> be on you during the event.**
|
||||||
|
|
||||||
|
There is otherwise **no** metrics collection, no alerting, no log shipping and no external check
|
||||||
|
anywhere in this deployment. Without this step, none of the following reaches a human: a crash
|
||||||
|
loop, a full disk, a dead database, an expired certificate, or the box being off. The host is at
|
||||||
|
a party and is not watching a dashboard.
|
||||||
|
|
||||||
|
`/health` is already built for exactly this and nothing currently consumes it:
|
||||||
|
|
||||||
|
| Response | Meaning | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `200 ok` | App **and** database are answering | — |
|
||||||
|
| `503 database timeout` / `database unavailable` | App is up, Postgres is not | §13 emergency card |
|
||||||
|
| Connection refused / TLS error | App container or Caddy is down | `docker compose ps`, then §13 |
|
||||||
|
| Timeout | Box is gone, or the disk is full enough to wedge it | §10.1 snapshot restore |
|
||||||
|
|
||||||
|
It runs a real `SELECT 1` against the pool with a 2 s timeout — a green check means the request
|
||||||
|
path guests use is genuinely working, not merely that a process is listening.
|
||||||
|
|
||||||
|
**The one signal this does not give you is disk.** The low-disk banner on `/host` requires the
|
||||||
|
host to open a dashboard during their own party and does not refresh without a manual reload, so
|
||||||
|
treat it as a pre-event check, not an alert. Before the event, confirm headroom with §11's
|
||||||
|
numbers; the export preflight and the upload quota are the automated backstops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Disk — the numbers for 40 GB
|
||||||
|
|
||||||
|
Expected event (100 photos @ ~4 MB, 5 videos @ ~150 MB). Originals are **always kept** and never
|
||||||
|
lossily recompressed; derivatives are a ≤800 px preview and a ≤2048 px display JPEG.
|
||||||
|
|
||||||
|
```
|
||||||
|
media (originals + derivatives) ~1.25 GB
|
||||||
|
peak during keepsake build (+ Gallery + Memories) ~3.5 GB
|
||||||
|
OS + Docker images + Postgres + logs ~4.5 GB
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
total ~11–13 GB of ~36 GB usable
|
||||||
|
```
|
||||||
|
|
||||||
|
**40 GB fits with roughly 3× headroom**, provided you build elsewhere (a server-side build adds
|
||||||
|
3–5 GB of cache that permanently shrinks the guest quota, because the quota is recomputed against
|
||||||
|
*live* free space on every upload).
|
||||||
|
|
||||||
|
The "ENOSPC" projection in README's **`### Sizing the disk`** discussion models guests
|
||||||
|
**saturating the quota** (~12 GB of media), not 100 photos. That scenario needs ~10× your expected
|
||||||
|
volume — and it degrades gracefully: the export preflight refuses up front rather than hitting
|
||||||
|
ENOSPC mid-write, and the host dashboard warns while free space is still **1.25× above the level at
|
||||||
|
which uploads stop** (`handlers::host::disk_is_low`). Note that is the only trigger: the separate
|
||||||
|
10 GB absolute floor this used to describe was removed as unreachable, because the derived
|
||||||
|
threshold is always higher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Known issues you are shipping with
|
||||||
|
|
||||||
|
None of these has a fix in this runbook; they are listed so nothing is a surprise. Severity is
|
||||||
|
scored purely by "would this interrupt you during the event".
|
||||||
|
|
||||||
|
### Fixed in this pass
|
||||||
|
|
||||||
|
| Was | Fix |
|
||||||
|
|---|---|
|
||||||
|
| **Queued uploads never rehydrated** — `loadQueue()` had one call site, so a guest whose PWA was evicted mid-upload and reopened onto `/feed` had pending photos in IndexedDB that nothing ever read. Silent photo loss. | `loadQueue()` now runs on every authenticated boot in `+layout.svelte`. |
|
||||||
|
| **A corrupted `ADMIN_PASSWORD_HASH` booted green** and only failed at admin login — unrecoverable mid-event. | `config.rs` now validates the bcrypt *shape*, not just placeholder-ness, and refuses to start. |
|
||||||
|
| **SSE reconnect thundering herd** — flat 500 ms jitter regardless of backoff. | Jitter now scales with the delay; the feed's in-place refresh is spread over 800–2800 ms. |
|
||||||
|
| **No client-side size/HEIC pre-check** — a doomed 600 MB upload saturated the venue AP before being rejected. | Certain-rejects are refused before any bytes leave the phone. |
|
||||||
|
| **Upload-queue UI unreachable** — the badged FAB opened the picker, not the queue, so a failed upload had no retry button. | The upload sheet now shows a queue entry whenever the badge is non-zero. |
|
||||||
|
| **A mid-event 401 left a dead screen** with no nav and no URL bar. | `api.ts` now returns the guest to `/join` (skipping the auth routes themselves). |
|
||||||
|
| **Rate limiter could panic while holding its mutex** (`timestamps[0]` with `max == 0`), poisoning it process-wide. | Uses `first()` with a fail-open guard. |
|
||||||
|
| **Hashtag deadlock** — `edit_upload` and `add_comment` upserted tags in client/text order. | Both now sort + dedup on the normalised key, matching the upload path. |
|
||||||
|
| **Unbounded Docker logs + debug-level app logging.** | `logging:` caps every service at 30 MB; `RUST_LOG` documented in `.env.example`. |
|
||||||
|
|
||||||
|
### Fixed in the readiness pass — behaviour you should know about
|
||||||
|
|
||||||
|
These change what you will observe on the night, so they are listed separately from the table above.
|
||||||
|
|
||||||
|
| Was | Now |
|
||||||
|
|---|---|
|
||||||
|
| **Any ffmpeg-level failure DESTROYED the video.** A poster-frame error — ffmpeg hanging on a truncated `.mov`, an ENOSPC on `thumbnails/`, a DB blip — propagated into the give-up path, which soft-deleted the upload. Reproduced live: on a host with no ffmpeg the clip was gone ~6 s after its `201 Created`. | No failure in the video branch can fail the upload. The clip stays in the feed and plays from its original; only the poster is missing. |
|
||||||
|
| **A lost response duplicated the photo.** Every retry minted a fresh upload id, so a phone that lost the reply and re-sent — manually, or automatically on reconnect — stored the same photo two or three times and paid quota for each. | The client sends a `client_upload_id`; migration 022 makes it unique. A retry returns `200` with the original row. Verified live: three sends → one row, quota charged once. |
|
||||||
|
| **`/health` returned a constant `"ok"`.** The container reported healthy while every request 500'd. | Runs `SELECT 1` with a 2 s timeout: `200 ok` / `503`. Verified live: 200 → stop Postgres → 503 → start Postgres → 200, **with no app restart** (the pool revalidates on acquire). Note that Compose does not restart on an unhealthy probe — this is a diagnostic, deliberately not wired to automatic recovery, because a restart would truncate in-flight uploads to "fix" an outage that clears on its own. |
|
||||||
|
| **Abandoned `.tmp` uploads were never reclaimed** (see the old issue #2 below — it is now fixed, not deferred). | Swept at boot and hourly, keyed on modification time so a live upload can never age into it. |
|
||||||
|
| **A full disk made itself worse.** ENOSPC was retried three times, then refunded the quota, soft-deleted the row and *kept* the bytes — freeing nothing and inviting an immediate re-upload into the same full disk. | ENOSPC is classified separately: no retry, no refund, no delete. The row stays live and the photo is served from its original until there is room to compress it. |
|
||||||
|
| **The keepsake download failed invisibly.** The rate limit was enforced inside the iframe navigation, so an over-limit guest tapped and *nothing happened*, forever. | Charged when the ticket is minted — a normal `fetch` — so it surfaces as a German message naming the daily window. Raise `export_rate_per_day` per §4 anyway. |
|
||||||
|
| **`/host` and `/admin` subscribed to SSE but never opened the connection**, so the keepsake progress bar froze after a release. | Both connect on mount and disconnect on destroy, as `/export` already did. |
|
||||||
|
| **`?limit=-5` on the feed returned a 500.** | Clamped at both ends. |
|
||||||
|
| **All recurring hygiene lived in one unsupervised task** — one panic silently stopped session pruning, media reclamation and the temp sweep for the rest of the event. | Supervised and re-spawned, with an error log. |
|
||||||
|
| **No pool acquire or statement timeout.** A DB blip parked every request for 30 s. | 5 s acquire, `statement_timeout=15s`, `lock_timeout=5s`. |
|
||||||
|
|
||||||
|
### Still open — accepted for this event
|
||||||
|
|
||||||
|
| # | Issue | Impact |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Guest delete/caption-edit after release invalidates both keepsake archives** (`upload.rs`), forcing a rebuild with a 20 s debounce. **No-op before release**, so it cannot bite during the event. | Post-event only. Left alone because blocking guest deletes has real privacy downsides — that is a product call, not a bug fix. |
|
||||||
|
| 2 | **Video poster frames do not regenerate after a restart** mid-compression (the backfill filters `mime_type LIKE 'image/%'`). | Cosmetic — the video still plays; only its poster is missing. |
|
||||||
|
| 3 | **SSE keep-alives are sent as SSE comments** (`:ping`), which the browser's EventSource parser discards without dispatching. A client therefore cannot implement a pure silence timer to detect a half-open socket. | Worked around client-side: the feed runs a jittered 60–120 s `/feed/delta` backstop and reconnects when a poll returns content the stream never delivered. A cleaner fix is to emit keep-alives as a *named* event; that is a coordinated backend+frontend change, not worth making during a freeze. |
|
||||||
|
| 4 | **The lightbox stops at the end of the loaded page** — stepping past the last loaded photo does not fetch the next one. | The guest scrolls the feed (which does page) and re-opens. |
|
||||||
|
|
||||||
|
### Migration checksum mismatch — `VersionMissing` / "previously applied but has been modified"
|
||||||
|
|
||||||
|
`sqlx` compares **checksums**, so renaming or renumbering a migration file is indistinguishable
|
||||||
|
from editing one. If a box ever booted an image built from a branch that numbered migrations
|
||||||
|
differently, the next boot aborts with *"migration 21 was previously applied but has been
|
||||||
|
modified"*, `main` exits non-zero, and `restart: unless-stopped` makes it **permanent** — with
|
||||||
|
Caddy still routing traffic to the dead container.
|
||||||
|
|
||||||
|
Every main-line migration is byte-identical to what shipped, so a box that only ever ran tagged
|
||||||
|
releases is unaffected. **Verify rather than assume** — run this against the server before any
|
||||||
|
deploy.
|
||||||
|
|
||||||
|
Note the `sh -c` wrapping, for the same reason as §9: `POSTGRES_USER` and `POSTGRES_DB` live in
|
||||||
|
`.env`, which Compose reads and **your shell does not**. Unwrapped, `-U "$POSTGRES_USER"` sends
|
||||||
|
`-U ""` and psql answers `FATAL: role "" does not exist` — at 11pm, with the app crash-looping.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version, description, success FROM _sqlx_migrations ORDER BY version;"'
|
||||||
|
```
|
||||||
|
|
||||||
|
If the app is already crash-looping on a renumbered migration, and **only** if you have confirmed
|
||||||
|
the SQL in the new file is equivalent to what was actually applied. Take the version numbers from
|
||||||
|
the crash message and the query above — do **not** copy the ones below, which are an example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop app
|
||||||
|
# Replace 21,22,23 with the versions the boot error actually named.
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM _sqlx_migrations WHERE version IN (21,22,23);"'
|
||||||
|
docker compose start app # re-applies exactly those, then continues
|
||||||
|
```
|
||||||
|
|
||||||
|
This re-runs those migrations. They must be idempotent (`IF NOT EXISTS` / `IF EXISTS`) or this
|
||||||
|
fails differently. Take a `pg_dump` first — see §10.
|
||||||
|
|
||||||
|
### Schema changes are **not** compile-time checked
|
||||||
|
|
||||||
|
All ~120 queries use the runtime `sqlx::query()` API. There are no `query!` macros and no `.sqlx`
|
||||||
|
cache, and the backend **compiles with no `DATABASE_URL` at all**. Consequences:
|
||||||
|
|
||||||
|
- A migration that renames or drops a column **compiles clean** and fails in production as a
|
||||||
|
runtime 500 on whichever request path touches it first.
|
||||||
|
- `cargo build` succeeding tells you nothing about schema/query agreement. Only `cargo test`
|
||||||
|
(which runs against a real Postgres) and manual exercise of the affected route do.
|
||||||
|
|
||||||
|
So: after any migration that touches an existing column, exercise the routes that read it before
|
||||||
|
you consider the deploy done. README and PROJECT previously claimed compile-time checking; they
|
||||||
|
have been corrected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Event-day emergency card
|
||||||
|
|
||||||
|
Assume you have a phone and two minutes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/eventsnap
|
||||||
|
|
||||||
|
# FIRST LINE, ALWAYS. `.env` is read by docker compose, NOT by your shell — without this,
|
||||||
|
# every `$DOMAIN` below expands to nothing and `curl https:///health` reads like an outage
|
||||||
|
# when the site is fine. Reads the one variable instead of sourcing the file, because `.env`
|
||||||
|
# legitimately contains an apostrophe (EVENT_NAME) and `. ./.env` dies on it — which at 11pm
|
||||||
|
# looks exactly like the outage you came here to diagnose.
|
||||||
|
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'")
|
||||||
|
|
||||||
|
# Is it alive? (200 = app AND database are answering; 503 = the app is up, the DB is not)
|
||||||
|
curl -fsS https://$DOMAIN/health
|
||||||
|
|
||||||
|
# What is broken?
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=100 app
|
||||||
|
|
||||||
|
# Nuclear option that is SAFE (keeps all data):
|
||||||
|
docker compose restart app
|
||||||
|
|
||||||
|
# Roll back to the identically-built sibling image — see §9 for what this can and cannot fix.
|
||||||
|
# Do NOT substitute an older release tag here; it will crash-loop on the migration set.
|
||||||
|
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env && docker compose up -d app frontend
|
||||||
|
|
||||||
|
# Disk check
|
||||||
|
df -h /var/lib/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Der Speicher des Events ist fast voll" — guests cannot upload
|
||||||
|
|
||||||
|
**`df -h` will look fine, and that is not a contradiction.** The upload gate refuses long before the
|
||||||
|
disk fills: it reserves room for the keepsake, which is roughly a second copy of every original, plus
|
||||||
|
a 10 GB floor. Uploads stop at **~8 GB of media** on a 40 GB box, when `df` still shows ~20 GB free.
|
||||||
|
|
||||||
|
Check the number that actually binds, not free space:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_size_pretty(sum(original_size_bytes)) FROM upload WHERE deleted_at IS NULL;"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Mid-event, in order of preference: delete the largest videos from the host dashboard (each frees its
|
||||||
|
own bytes immediately), or move `exports_data` to a separate volume. Raising `quota_tolerance` will
|
||||||
|
**not** help — on this box every guest is already on the 500 MiB floor, so that knob is not what is
|
||||||
|
refusing them (see §4 and `.env.example`).
|
||||||
|
|
||||||
|
**NEVER** run `docker compose down -v`. It deletes the database, all media, all exports and the TLS
|
||||||
|
certificate. There is no undo.
|
||||||
|
|
||||||
|
Most limits are changeable from the **admin dashboard without a restart** — reach for that before
|
||||||
|
touching the shell.
|
||||||
@@ -344,7 +344,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
|||||||
| Styling | Tailwind CSS | Utility-first, mobile-first; zero runtime CSS overhead |
|
| Styling | Tailwind CSS | Utility-first, mobile-first; zero runtime CSS overhead |
|
||||||
| Backend | Rust + Axum | Developer preference; memory safety, single-binary deploy |
|
| Backend | Rust + Axum | Developer preference; memory safety, single-binary deploy |
|
||||||
| Async Runtime | Tokio | De-facto Rust async runtime; Axum is built on it |
|
| Async Runtime | Tokio | De-facto Rust async runtime; Axum is built on it |
|
||||||
| Database Driver | SQLx | Async PostgreSQL with compile-time query checking; automatic prepared statements |
|
| Database Driver | SQLx | Async PostgreSQL; automatic prepared statements. **Queries use the runtime `sqlx::query()` API, not the checked macros** — see the note under "Schema changes" |
|
||||||
| Database | PostgreSQL 16 | Robust, relational; straightforward to back up |
|
| Database | PostgreSQL 16 | Robust, relational; straightforward to back up |
|
||||||
| Auth | Custom JWT (`jsonwebtoken` crate) | No external service needed; name + PIN is the full auth model |
|
| Auth | Custom JWT (`jsonwebtoken` crate) | No external service needed; name + PIN is the full auth model |
|
||||||
| Image Compression | `image` crate + `oxipng` | Lossless PNG compression; JPEG preview generation |
|
| Image Compression | `image` crate + `oxipng` | Lossless PNG compression; JPEG preview generation |
|
||||||
@@ -1206,7 +1206,7 @@ of the media volume alone silently loses every generated keepsake.
|
|||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `axum` | Web framework |
|
| `axum` | Web framework |
|
||||||
| `tokio` | Async runtime |
|
| `tokio` | Async runtime |
|
||||||
| `sqlx` | Async PostgreSQL driver; compile-time query checking; prepared statements; migrations |
|
| `sqlx` | Async PostgreSQL driver; prepared statements; migrations embedded at compile time. Queries are runtime-checked (`sqlx::query()`), **not** macro-checked |
|
||||||
| `jsonwebtoken` | JWT sign / verify |
|
| `jsonwebtoken` | JWT sign / verify |
|
||||||
| `bcrypt` | PIN + admin password hashing |
|
| `bcrypt` | PIN + admin password hashing |
|
||||||
| `uuid` | UUID v7 (time-sortable) |
|
| `uuid` | UUID v7 (time-sortable) |
|
||||||
|
|||||||
128
README.md
128
README.md
@@ -49,7 +49,7 @@ A guest scans the QR code on their way in, types their name, and is immediately
|
|||||||
| Styling | Tailwind CSS v4 |
|
| Styling | Tailwind CSS v4 |
|
||||||
| Backend | Rust + Axum |
|
| Backend | Rust + Axum |
|
||||||
| Async | Tokio |
|
| Async | Tokio |
|
||||||
| Database | PostgreSQL 16 via SQLx (compile-time query checking) |
|
| Database | PostgreSQL 16 via SQLx (runtime query API; migrations embedded at compile time) |
|
||||||
| Auth | Custom JWT (`jsonwebtoken`) + bcrypt PINs |
|
| Auth | Custom JWT (`jsonwebtoken`) + bcrypt PINs |
|
||||||
| Image processing | `image` crate + `oxipng` (lossless compression) |
|
| Image processing | `image` crate + `oxipng` (lossless compression) |
|
||||||
| Video processing | ffmpeg via `tokio::process::Command` |
|
| Video processing | ffmpeg via `tokio::process::Command` |
|
||||||
@@ -150,45 +150,63 @@ Caddy automatically obtains a Let's Encrypt certificate on first start. The app
|
|||||||
|
|
||||||
### Updating an existing deployment
|
### Updating an existing deployment
|
||||||
|
|
||||||
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
|
> **The event server never compiles.** `app` and `frontend` have **no `build:` key** — they
|
||||||
> `build:` services with no published image tag, and Compose has no source-change detection:
|
> pull an immutable tag from the registry (the `app` service in `docker-compose.yml` says so explicitly, so that
|
||||||
> if an image with that name already exists it is reused. After a `git pull` the command
|
> a wrong tag fails instantly with `manifest unknown` instead of silently starting a 45-minute
|
||||||
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
|
> compile on the box guests are using). A `git pull` therefore deploys **nothing** on its own,
|
||||||
> nothing looks exactly like a successful one. `--build` is what makes it real.
|
> and `docker compose up -d --build` **errors** — there is nothing to build. Deploying means
|
||||||
|
> pushing a new tag from a workstation and pointing `EVENTSNAP_VERSION` at it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ── On your workstation: build and push the new tag ───────────────────────────
|
||||||
|
# Push the rollback twin at the same time, from the same source — see
|
||||||
|
# DEPLOYMENT_RUNBOOK.md §9 for why an identical second tag is the rollback target.
|
||||||
|
VERSION=v0.13.1
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$VERSION \
|
||||||
|
-t registry.mc02.dev/eventsnap/app:$VERSION-a --push ./backend
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t registry.mc02.dev/eventsnap/frontend:$VERSION \
|
||||||
|
-t registry.mc02.dev/eventsnap/frontend:$VERSION-a --push ./frontend
|
||||||
|
|
||||||
|
# ── On the server ─────────────────────────────────────────────────────────────
|
||||||
cd /path/to/eventsnap
|
cd /path/to/eventsnap
|
||||||
|
|
||||||
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
|
# 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.)
|
# (See "Backup" below; the database dump is the one that matters here.)
|
||||||
|
|
||||||
# 2. Fetch the new code.
|
# 2. Fetch the new compose/Caddyfile. This does NOT change which image runs.
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
# 3. Rebuild and restart the application services. --build is NOT optional.
|
# 3. Point the stack at the new tag.
|
||||||
docker compose up -d --build
|
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.1/' .env
|
||||||
|
|
||||||
# 4. Apply any Caddyfile change. Step 3 does NOT do this — see the warning below.
|
# 4. Pull explicitly, BEFORE restarting. A failure here (bad tag, registry down) leaves the
|
||||||
|
# running stack untouched; letting `up -d` discover it takes the app down first.
|
||||||
|
docker compose pull app frontend
|
||||||
|
|
||||||
|
# 5. Restart onto the new images.
|
||||||
|
docker compose up -d app frontend
|
||||||
|
|
||||||
|
# 6. Apply any Caddyfile change. Step 5 does NOT do this — see the warning below.
|
||||||
docker compose up -d --force-recreate caddy
|
docker compose up -d --force-recreate caddy
|
||||||
|
|
||||||
# 5. Confirm the app came back up. Anything other than "ok" means check the logs.
|
# 7. Confirm the app came back up. Anything other than "ok" means check the logs.
|
||||||
curl -fsS https://DOMAIN/health && echo
|
curl -fsS https://DOMAIN/health && echo
|
||||||
|
|
||||||
# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
|
# 8. Confirm the running containers are actually on the new tag.
|
||||||
# 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
|
docker compose images app frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrations are applied by the backend on startup, so step 3 covers them. If `app` stays
|
Migrations are applied by the backend on startup, so step 5 covers them. If `app` stays
|
||||||
unhealthy afterwards, `docker compose logs app` will name the failing migration — and note
|
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,
|
that a migration applied by a *newer* build is not removed by rolling the tag back, so
|
||||||
so rolling back code without restoring the database snapshot from step 1 leaves the schema
|
reverting `EVENTSNAP_VERSION` without restoring the database snapshot from step 1 leaves the
|
||||||
ahead of the binary and the app refusing to boot.
|
schema ahead of the binary and the app refusing to boot. **This is why the rollback target is
|
||||||
|
an identical twin tag rather than an older release** — see `DEPLOYMENT_RUNBOOK.md` §9.
|
||||||
|
|
||||||
> **Why step 4 exists.** `--build` only rebuilds services that have a `build:` section, and
|
> **Why step 6 exists.** Steps 4–5 only touch `app` and `frontend`; `caddy` is a separate
|
||||||
> `caddy` is a pinned upstream image. Compose decides whether to recreate a container from its
|
> pinned upstream image. Compose decides whether to recreate a container from its
|
||||||
> *config hash*, which covers the mount **specification** (`./Caddyfile:/etc/caddy/Caddyfile:ro`)
|
> *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
|
> 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
|
> delta, Compose reports `Running`, and Caddy keeps serving its old config indefinitely. Exit
|
||||||
@@ -196,7 +214,7 @@ ahead of the binary and the app refusing to boot.
|
|||||||
>
|
>
|
||||||
> That is not hypothetical: the fix that made the keepsake download work on iOS
|
> 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
|
> (`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
|
> production effect lives in that one file. Without step 6 you deploy it, watch both image IDs
|
||||||
> change, and iOS downloads stay broken.
|
> change, and iOS downloads stay broken.
|
||||||
>
|
>
|
||||||
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
|
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
|
||||||
@@ -280,35 +298,59 @@ so a host takedown or a ban actually revokes access to the bytes.
|
|||||||
degrade one subsystem — Postgres stops being able to write and the whole event goes
|
degrade one subsystem — Postgres stops being able to write and the whole event goes
|
||||||
down.
|
down.
|
||||||
|
|
||||||
Uploads are self-limiting. `per_user_limit = free_disk × quota_tolerance ÷
|
**`Gallery.zip` and `Memories.zip` are each roughly a second copy of every original.**
|
||||||
active_uploaders` is recomputed against live free space on every upload, so guests
|
Both write their media `Compression::Stored`, and `Memories.zip` streams the untouched
|
||||||
converge on a fixed point at `tolerance / (1 + tolerance)` of the free space you
|
original for every video and for every image at or under 5 MB. So a release wants room
|
||||||
started with — **43%** at the default 0.75. On an 80 GB box with ~70 GB free after
|
for **two more copies of the gallery** on top of the gallery itself — which is what
|
||||||
the OS and images, media settles at ~30 GB and stops.
|
`required_free_bytes` encodes as `media × 1.1 × 2`.
|
||||||
|
|
||||||
**The keepsake is what the 80 GB baseline does not cover.** `Gallery.zip` and
|
The per-user quota does **not** bound this. It is a fairness mechanism that divides
|
||||||
`Memories.zip` are built concurrently and each is roughly a second copy of every
|
free space between guests, and since it carries a floor (`MIN_QUOTA_LIMIT_BYTES`, so a
|
||||||
original: both write their media `Compression::Stored`, and `Memories.zip` streams the
|
guest's allowance stops shrinking as the party fills up) the aggregate ceiling it used
|
||||||
untouched original for every video and for every image at or under 5 MB. So a release
|
to imply is gone. What bounds the disk is the **global gate in the upload handler**,
|
||||||
wants room for **two more copies of the gallery** on top of the gallery itself.
|
which refuses any upload that would leave too little room to build the keepsake:
|
||||||
|
|
||||||
| Stage | Used | Free (80 GB box) |
|
```
|
||||||
|---|---|---|
|
free_after_upload < media_after × 1.1 × 2 + DISK_RESERVE_BYTES
|
||||||
| Fresh box (OS + images) | ~10 GB | ~70 GB |
|
+ UPLOAD_GATE_HEADROOM_BYTES → refused
|
||||||
| Guests reach the quota fixed point | ~40 GB | ~40 GB |
|
```
|
||||||
| Host releases → both archives | ~100 GB | **ENOSPC** |
|
|
||||||
|
|
||||||
Two ways to size for it:
|
That last term is what separates this gate from the export preflight, which bails at
|
||||||
|
`media × 1.1 × 2 + DISK_RESERVE_BYTES` — the same expression **minus** the headroom. The
|
||||||
|
two used to be identical, which meant the preflight was already sitting on its limit at
|
||||||
|
the exact moment uploads stopped: every byte written between the last refused upload and
|
||||||
|
the host tapping *Galerie freigeben* (Postgres WAL, container logs, the compression
|
||||||
|
backlog draining at precisely that hour) pushed it under, and the release commits before
|
||||||
|
the workers fail. The headroom buys 1.5 GB of slack so that cannot happen.
|
||||||
|
|
||||||
|
Solving the gate for the gallery size gives the real ceiling — the gate's equilibrium is
|
||||||
|
`3.2 × media`, so each GB of reserve or headroom costs ~0.31 GB of gallery. On the
|
||||||
|
**40 GB box this runs on**, with ~5 GB for the OS, Docker images (the runbook pre-pulls
|
||||||
|
the rollback tag too) and Postgres:
|
||||||
|
|
||||||
|
| Volume | Usable after baseline | Media ceiling | Free at release | Preflight needs |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 40 GB | ~35 GB | **~7.3 GB** | ~27.7 GB | ~26.2 GB → fits, 1.5 GB spare |
|
||||||
|
| 80 GB | ~70 GB | ~18.3 GB | ~51.7 GB | ~50.2 GB → fits, 1.5 GB spare |
|
||||||
|
|
||||||
|
**Uploads therefore stop at roughly 7 GB of media on a 40 GB box, not when the disk is
|
||||||
|
full.** That is deliberate. 1000 photos at ~3.5 MB is ~3.5 GB and fits comfortably;
|
||||||
|
video is what consumes the budget, so lower `max_video_size_mb` (seeded at 500) if you
|
||||||
|
expect a lot of it. Refusing the 1001st upload is a far better outcome than accepting it
|
||||||
|
and discovering at 01:00 that the archive can never be built.
|
||||||
|
|
||||||
|
Two ways to buy headroom:
|
||||||
|
|
||||||
- **Provision ~3× your expected media** on one volume (media + two archives), or
|
- **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
|
- **give `exports_data` its own volume** so a full export cannot reach Postgres, and
|
||||||
size that one at ~2× expected media.
|
size that one at ~2× expected media.
|
||||||
|
|
||||||
This is no longer silent. The export refuses up front with the two numbers rather than
|
None of this is silent. The upload gate refuses with a German message naming the cause,
|
||||||
hitting ENOSPC halfway through a multi-GB write, a rebuild reclaims the superseded
|
the export preflight refuses up front with both numbers rather than hitting ENOSPC
|
||||||
generation before it starts (so peak is one generation, not two), and the host
|
halfway through a multi-GB write, a rebuild only reclaims the superseded generation
|
||||||
dashboard warns as soon as the keepsake would not fit — which is the only point at
|
**after** the new one lands (so a failed rebuild can never leave you with no archive at
|
||||||
which anyone can still do something about it.
|
all), 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
backend/.dockerignore
Normal file
12
backend/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Build context exclusions.
|
||||||
|
#
|
||||||
|
# `target/` does not exist on a clean checkout, which is why builds have worked without this
|
||||||
|
# file — but the moment anyone runs `cargo build` locally it becomes multi-GB, and every
|
||||||
|
# `docker build` would ship all of it to the daemon for nothing (the Dockerfile only COPYs
|
||||||
|
# Cargo.toml, Cargo.lock, src, static and migrations). On an EMULATED amd64 builder that
|
||||||
|
# transfer is the slowest part of the build.
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Never let a real .env reach an image layer.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
196
backend/Cargo.lock
generated
196
backend/Cargo.lock
generated
@@ -65,56 +65,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-query",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle"
|
|
||||||
version = "1.0.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
|
||||||
dependencies = [
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-query"
|
|
||||||
version = "1.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-wincon"
|
|
||||||
version = "3.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"once_cell_polyfill",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -554,46 +504,12 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "clap"
|
|
||||||
version = "4.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
|
||||||
dependencies = [
|
|
||||||
"clap_builder",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "clap_builder"
|
|
||||||
version = "4.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"clap_lex",
|
|
||||||
"strsim",
|
|
||||||
"terminal_size",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "clap_lex"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color_quant"
|
name = "color_quant"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.37"
|
version = "0.4.37"
|
||||||
@@ -677,15 +593,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-channel"
|
|
||||||
version = "0.5.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -698,9 +605,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -816,27 +723,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equator"
|
name = "equator"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -1229,12 +1115,6 @@ dependencies = [
|
|||||||
"weezl",
|
"weezl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glob"
|
|
||||||
version = "0.3.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "governor"
|
name = "governor"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -1622,7 +1502,6 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"rayon",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -1656,12 +1535,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is_terminal_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1795,12 +1668,6 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2089,12 +1956,6 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oxipng"
|
name = "oxipng"
|
||||||
version = "9.1.5"
|
version = "9.1.5"
|
||||||
@@ -2102,18 +1963,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973"
|
checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitvec",
|
"bitvec",
|
||||||
"clap",
|
|
||||||
"crossbeam-channel",
|
|
||||||
"env_logger",
|
|
||||||
"filetime",
|
"filetime",
|
||||||
"glob",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"libdeflater",
|
"libdeflater",
|
||||||
"log",
|
"log",
|
||||||
"rayon",
|
|
||||||
"rgb",
|
"rgb",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"zopfli",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2607,19 +2462,6 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "1.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3060,12 +2902,6 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strsim"
|
|
||||||
version = "0.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -3120,16 +2956,6 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "terminal_size"
|
|
||||||
version = "0.4.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
|
||||||
dependencies = [
|
|
||||||
"rustix",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3505,12 +3331,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8parse"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
@@ -4187,18 +4007,6 @@ version = "1.0.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zopfli"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"crc32fast",
|
|
||||||
"log",
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd"
|
name = "zstd"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
|
|||||||
@@ -27,7 +27,17 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
oxipng = "9"
|
# default-features = false drops "parallel", which is what actually bounds oxipng's memory:
|
||||||
|
# with rayon it evaluates row filters concurrently, each trial holding its own full-size
|
||||||
|
# buffer, and there is no Options knob to cap that. Without the feature, lib.rs swaps in a
|
||||||
|
# sequential shim (oxipng's own supported path) so peak scales with ONE trial, not N.
|
||||||
|
# PNG optimisation gets slower; it is a background, best-effort, lossless size saving.
|
||||||
|
#
|
||||||
|
# "filetime" must be KEPT: without it OutFile::Path { preserve_attrs: true } silently no-ops.
|
||||||
|
# Dropping "binary" also removes clap/glob/env_logger — a CLI's dependencies that were being
|
||||||
|
# compiled into a server image — and "zopfli", which preset 2 does not use (it selects
|
||||||
|
# Deflaters::Libdeflater, which is not feature-gated).
|
||||||
|
oxipng = { version = "9", default-features = false, features = ["filetime"] }
|
||||||
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
infer = "0.15"
|
infer = "0.15"
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
|||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
# Copied WITH the sources, not with Cargo.toml above: cargo auto-detects `build.rs` by presence, so
|
||||||
|
# putting it in the dependency-cache layer would make the dummy build run it too and invalidate a
|
||||||
|
# layer that is otherwise stable. Copied at all because without it the image builds a subtly
|
||||||
|
# DIFFERENT package from the one developers build — no build script, hence none of the
|
||||||
|
# rerun-if-changed tracking for `static/export-viewer` and `migrations`. Harmless here (every image
|
||||||
|
# build is clean, so there is no stale cache to reuse) and confusing everywhere else.
|
||||||
|
COPY build.rs ./
|
||||||
RUN touch src/main.rs && cargo build --release
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
# --- Runtime stage ---
|
# --- Runtime stage ---
|
||||||
|
|||||||
25
backend/build.rs
Normal file
25
backend/build.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//! Tell cargo which non-Rust inputs are baked into the binary.
|
||||||
|
//!
|
||||||
|
//! `include_dir!` and `sqlx::migrate!()` both embed directory contents at COMPILE time, and neither
|
||||||
|
//! registers a rebuild dependency on its own. Cargo therefore reuses a cached binary when only
|
||||||
|
//! those directories changed — the source files are untouched, so as far as cargo is concerned
|
||||||
|
//! nothing happened.
|
||||||
|
//!
|
||||||
|
//! For the keepsake viewer that is a silent, shippable defect: run `npm run build` in
|
||||||
|
//! `frontend/export-viewer`, then `cargo build`, and the resulting binary still carries the
|
||||||
|
//! PREVIOUS `static/export-viewer/index.html`. The artifact on disk and the artifact in the binary
|
||||||
|
//! disagree, `git status` is clean, and every check passes — while `Memories.zip` ships a stale
|
||||||
|
//! viewer. Confirmed empirically: after replacing the file, the compiled-in copy did not change
|
||||||
|
//! until a source file was touched.
|
||||||
|
//!
|
||||||
|
//! Production is mostly insulated because images are built from a clean context (no cache to
|
||||||
|
//! reuse), but every incremental build — i.e. all local development and any test run that follows
|
||||||
|
//! a viewer rebuild — hits it, and that includes the test that asserts the viewer is present.
|
||||||
|
fn main() {
|
||||||
|
// The compiled-in keepsake viewer (services/export.rs: `include_dir!`).
|
||||||
|
println!("cargo:rerun-if-changed=static/export-viewer");
|
||||||
|
// The embedded migration set (db.rs: `sqlx::migrate!()`). Same mechanism, and the failure is
|
||||||
|
// worse: a binary built from a stale snapshot boots against a database that has already run a
|
||||||
|
// newer migration and crash-loops with VersionMissing.
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
}
|
||||||
13
backend/migrations/021_hashtag_counts_respect_bans.down.sql
Normal file
13
backend/migrations/021_hashtag_counts_respect_bans.down.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Restore the pre-021 definition (no ban/hide filtering) exactly as 004 created it.
|
||||||
|
DROP VIEW IF EXISTS v_hashtag_counts;
|
||||||
|
|
||||||
|
CREATE VIEW v_hashtag_counts AS
|
||||||
|
SELECT
|
||||||
|
h.event_id,
|
||||||
|
h.tag,
|
||||||
|
COUNT(uh.upload_id) AS upload_count
|
||||||
|
FROM hashtag h
|
||||||
|
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||||
|
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
|
||||||
|
GROUP BY h.event_id, h.id, h.tag
|
||||||
|
ORDER BY upload_count DESC;
|
||||||
31
backend/migrations/021_hashtag_counts_respect_bans.up.sql
Normal file
31
backend/migrations/021_hashtag_counts_respect_bans.up.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- v_hashtag_counts: apply the same visibility rules as v_feed.
|
||||||
|
--
|
||||||
|
-- The chip row and the grid's tag picker are both fed by this view, but it has only ever
|
||||||
|
-- filtered `u.deleted_at IS NULL`. Migration 011 added ban/hide filtering to the feed and
|
||||||
|
-- never reached here, so the two disagreed about which uploads exist:
|
||||||
|
--
|
||||||
|
-- * A host bans a guest who posted 3 of the 12 `#tanz` photos. The chip keeps reading
|
||||||
|
-- "#tanz 12"; tapping it returns 9. The count is presented as authoritative and is not.
|
||||||
|
-- * A tag used ONLY by a banned or hidden guest stays in the chip row and in the tag
|
||||||
|
-- picker as a selectable option that leads to an empty feed — a ghost filter that
|
||||||
|
-- cannot be cleared because there is nothing wrong with it to see.
|
||||||
|
--
|
||||||
|
-- Bans are exactly the moment a host is watching these numbers to confirm the moderation
|
||||||
|
-- took effect, so a stale count reads as "the ban didn't work".
|
||||||
|
--
|
||||||
|
-- Same predicate as v_feed (see 016_display_derivative.up.sql), joined through `user`.
|
||||||
|
DROP VIEW IF EXISTS v_hashtag_counts;
|
||||||
|
|
||||||
|
CREATE VIEW v_hashtag_counts AS
|
||||||
|
SELECT
|
||||||
|
h.event_id,
|
||||||
|
h.tag,
|
||||||
|
COUNT(uh.upload_id) AS upload_count
|
||||||
|
FROM hashtag h
|
||||||
|
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||||
|
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
|
||||||
|
JOIN "user" usr ON usr.id = u.user_id
|
||||||
|
WHERE usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE
|
||||||
|
GROUP BY h.event_id, h.id, h.tag
|
||||||
|
ORDER BY upload_count DESC;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
||||||
|
ALTER TABLE upload DROP COLUMN IF EXISTS client_upload_id;
|
||||||
25
backend/migrations/022_client_upload_idempotency.up.sql
Normal file
25
backend/migrations/022_client_upload_idempotency.up.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Idempotency key for uploads, supplied by the client.
|
||||||
|
--
|
||||||
|
-- The failure this closes is the ordinary one on a phone, not an exotic race: the server
|
||||||
|
-- receives the body, validates it, commits the row, and the response is lost on the way back
|
||||||
|
-- because the guest walked out of range or the AP dropped the connection. The client sees a
|
||||||
|
-- network error with the blob still in hand, marks the item retryable, and re-sends it — both
|
||||||
|
-- when the guest taps "Erneut" and automatically when the queue requeues on reconnect. Every
|
||||||
|
-- attempt minted a fresh `Uuid::new_v4()` server-side, so the same photo landed in the gallery
|
||||||
|
-- two or three times and was charged against the guest's storage quota each time.
|
||||||
|
--
|
||||||
|
-- The client already has a stable per-queue-item UUID, so it costs nothing to send. NULL is
|
||||||
|
-- allowed and unconstrained: uploads that predate this column, and any client that doesn't send
|
||||||
|
-- one, keep working exactly as before.
|
||||||
|
ALTER TABLE upload ADD COLUMN client_upload_id UUID;
|
||||||
|
|
||||||
|
-- Partial rather than a plain UNIQUE. Postgres would tolerate the NULLs either way, but indexing
|
||||||
|
-- only the rows that carry a key keeps it small and states the rule exactly: uniqueness applies
|
||||||
|
-- where a key exists, and nowhere else.
|
||||||
|
--
|
||||||
|
-- Scoped globally rather than per user or per event. The key is a client-generated v4 UUID, so a
|
||||||
|
-- collision between two different photos is not a real possibility, and a single-column index
|
||||||
|
-- means the uniqueness check cannot be wrong about which event or user a retry belongs to.
|
||||||
|
CREATE UNIQUE INDEX upload_client_upload_id_key
|
||||||
|
ON upload (client_upload_id)
|
||||||
|
WHERE client_upload_id IS NOT NULL;
|
||||||
3
backend/migrations/023_derivative_attempts.down.sql
Normal file
3
backend/migrations/023_derivative_attempts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_upload_derivative_backfill;
|
||||||
|
ALTER TABLE upload DROP COLUMN IF EXISTS derivative_last_error;
|
||||||
|
ALTER TABLE upload DROP COLUMN IF EXISTS derivative_attempts;
|
||||||
23
backend/migrations/023_derivative_attempts.up.sql
Normal file
23
backend/migrations/023_derivative_attempts.up.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- Bound how many times a permanently-failing upload can be re-processed.
|
||||||
|
--
|
||||||
|
-- Without this, one poisoned row is an outage. The upload row is committed BEFORE compression
|
||||||
|
-- starts, `derivatives_rev` defaults to 0, and `set_derivatives_rev` only runs on success — so
|
||||||
|
-- a row whose processing kills the container survives at rev 0, the unconditional startup
|
||||||
|
-- backfill re-selects it on the next boot, and `restart: unless-stopped` turns that into an
|
||||||
|
-- infinite kill loop. Every restart also drops every SSE stream and truncates every in-flight
|
||||||
|
-- upload. That was reachable via a single large PNG (see services/compression.rs), but the
|
||||||
|
-- shape is general: any input that can kill or hang the worker repeats forever.
|
||||||
|
--
|
||||||
|
-- The counter is incremented WRITE-AHEAD, before the work is attempted, because the failure
|
||||||
|
-- mode being defended against is a SIGKILL — no error is returned, no handler runs, no Drop
|
||||||
|
-- fires. A counter bumped in an error path increments zero times per crash and changes nothing.
|
||||||
|
ALTER TABLE upload ADD COLUMN IF NOT EXISTS derivative_attempts SMALLINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Last failure text, so a row that has given up can be diagnosed without reproducing it.
|
||||||
|
-- Nothing reads this in code; it exists for the operator.
|
||||||
|
ALTER TABLE upload ADD COLUMN IF NOT EXISTS derivative_last_error TEXT;
|
||||||
|
|
||||||
|
-- Serves the backfill selection, which now filters on both columns.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_upload_derivative_backfill
|
||||||
|
ON upload (derivatives_rev, derivative_attempts)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
26
backend/migrations/024_feed_scalar_counts.down.sql
Normal file
26
backend/migrations/024_feed_scalar_counts.down.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Restore the 016 definition verbatim.
|
||||||
|
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;
|
||||||
55
backend/migrations/024_feed_scalar_counts.up.sql
Normal file
55
backend/migrations/024_feed_scalar_counts.up.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- Make a feed page cost a page, not the whole event.
|
||||||
|
--
|
||||||
|
-- The previous definition (016) computed like_count/comment_count with LEFT JOINs and a
|
||||||
|
-- GROUP BY. Postgres CAN push the `event_id = $1` qual and the keyset predicate through the
|
||||||
|
-- view — verified with EXPLAIN, it uses idx_upload_event_created_id — but it CANNOT push
|
||||||
|
-- ORDER BY ... LIMIT across a GroupAggregate. So every feed request aggregated every upload in
|
||||||
|
-- the event (times its likes and comments) and only then sorted and took 21 rows. The cost
|
||||||
|
-- grew with the event, not with the page, and page 1 — the most expensive one — is exactly
|
||||||
|
-- what refreshFeedInPlace refetches on every completed upload, from every open feed in the
|
||||||
|
-- venue.
|
||||||
|
--
|
||||||
|
-- Correlated scalar subqueries move the counts ABOVE the Limit in the plan: they are evaluated
|
||||||
|
-- once per returned row, so 21 index lookups instead of a full aggregation.
|
||||||
|
--
|
||||||
|
-- The rewrite is EXACTLY equivalent, not merely close:
|
||||||
|
-- * "like" is keyed (upload_id, user_id), so COUNT(DISTINCT l.user_id) == count(*).
|
||||||
|
-- * comment.id is the primary key, so COUNT(DISTINCT c.id) == count(*).
|
||||||
|
-- * one row per upload either way — the GROUP BY was on u.id.
|
||||||
|
-- Column names, order and types are unchanged (count(*) and COUNT(DISTINCT ...) are both
|
||||||
|
-- bigint), so no Rust code changes.
|
||||||
|
--
|
||||||
|
-- No new index needed: idx_like_upload plus the (upload_id, user_id) PK serve the like
|
||||||
|
-- subquery, and idx_comment_upload ... WHERE deleted_at IS NULL matches the comment
|
||||||
|
-- subquery's predicate exactly.
|
||||||
|
--
|
||||||
|
-- One thing a future editor needs to know: the hashtag-filtered feed joins upload_hashtag
|
||||||
|
-- against this view. That was safe before only because the GROUP BY collapsed the join
|
||||||
|
-- fan-out; it is safe now because the view is one row per upload and upload_hashtag is keyed
|
||||||
|
-- (upload_id, hashtag_id) with a single tag filtered. Adding a second tag filter would need
|
||||||
|
-- fresh thought.
|
||||||
|
|
||||||
|
-- Not CASCADE: if something ever comes to depend on this view, the migration should fail
|
||||||
|
-- loudly rather than silently drop it.
|
||||||
|
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,
|
||||||
|
(SELECT count(*) FROM "like" l WHERE l.upload_id = u.id) AS like_count,
|
||||||
|
(SELECT count(*) FROM comment c WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE;
|
||||||
11
backend/migrations/025_reserved_names_and_pin_decay.down.sql
Normal file
11
backend/migrations/025_reserved_names_and_pin_decay.down.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- NOTE: the reserved-name rename in the up migration is NOT reversible. The original names
|
||||||
|
-- are not recorded anywhere, and reversing it would in any case re-create the state that
|
||||||
|
-- bricked admin login. Rolling back the schema does not roll back that data change.
|
||||||
|
DELETE FROM config WHERE key IN (
|
||||||
|
'recover_name_rate_per_15min',
|
||||||
|
'pin_reset_ip_rate_per_min',
|
||||||
|
'upload_edit_rate_per_min',
|
||||||
|
'upload_edit_rate_enabled'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "user" DROP COLUMN IF EXISTS last_failed_pin_at;
|
||||||
70
backend/migrations/025_reserved_names_and_pin_decay.up.sql
Normal file
70
backend/migrations/025_reserved_names_and_pin_decay.up.sql
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
-- Two independent auth defects that share a migration because they share a table.
|
||||||
|
|
||||||
|
-- 1. RESERVED NAMES — free any guest squatting on a name the admin path used to depend on.
|
||||||
|
--
|
||||||
|
-- Migration 007 made display_name unique per event case-insensitively, and `join` had no
|
||||||
|
-- reserved-name guard. So any guest could join as "admin"/"Admin"/"ADMIN" before the operator's
|
||||||
|
-- first admin login; admin_login then looked its user up BY NAME, missed (wrong role), fell
|
||||||
|
-- through to creating "Admin", violated that unique index, and returned a 500 — permanently,
|
||||||
|
-- with no in-app recovery. Moderation, config and gallery release all gone, fixed only by SQL.
|
||||||
|
--
|
||||||
|
-- The real fix is in code (look the admin up by role, never by name — see auth/handlers.rs).
|
||||||
|
-- This clears the state an already-deployed database may be carrying.
|
||||||
|
--
|
||||||
|
-- RENAMED, NEVER DELETED: the guest keeps their uploads, their PIN and their session. Only
|
||||||
|
-- non-admin rows are touched — a real admin row named "Admin" is the expected state.
|
||||||
|
-- Two guards that are not optional, because this statement runs INSIDE the migration
|
||||||
|
-- transaction on boot and a failure here exits the process — `restart: unless-stopped` then
|
||||||
|
-- turns it into a crash loop with no in-app recovery. That is a strictly worse version of the
|
||||||
|
-- lockout this migration exists to clean up after.
|
||||||
|
--
|
||||||
|
-- * role = 'guest', not role <> 'admin'. The enum also has 'host' (001), and hosts are
|
||||||
|
-- promoted from guests at runtime — so <> 'admin' renamed a legitimately promoted staff
|
||||||
|
-- member whose name happens to be "Host".
|
||||||
|
-- * NOT EXISTS. The target name is derived, not unique: `idx_user_event_name_ci` (007) is a
|
||||||
|
-- UNIQUE index on (event_id, lower(display_name)), and nothing stopped a second guest from
|
||||||
|
-- having already joined as exactly "Admin (a1b2c3d4)" — the old code had no reserved-name
|
||||||
|
-- guard and the join response hands each guest their own id. Rare, but the cost of losing
|
||||||
|
-- that bet is the whole event.
|
||||||
|
--
|
||||||
|
-- A row that collides is simply left alone: `create_admin_user` already handles a name clash by
|
||||||
|
-- falling back to `Admin-<8hex>`, and `admin_login` no longer resolves by name at all, so this
|
||||||
|
-- cleanup is convenience rather than load-bearing.
|
||||||
|
UPDATE "user" u
|
||||||
|
SET display_name = u.display_name || ' (' || left(u.id::text, 8) || ')'
|
||||||
|
WHERE u.role = 'guest'
|
||||||
|
AND lower(u.display_name) IN ('admin', 'administrator', 'host', 'eventsnap')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "user" x
|
||||||
|
WHERE x.event_id = u.event_id
|
||||||
|
AND lower(x.display_name) = lower(u.display_name || ' (' || left(u.id::text, 8) || ')')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. PIN LOCKOUT DECAY.
|
||||||
|
--
|
||||||
|
-- failed_pin_attempts only ever cleared on a successful recovery or after a lockout expired, so
|
||||||
|
-- honest typos accumulated across days: a guest who fat-fingered their PIN twice last night
|
||||||
|
-- arrives today already two-thirds of the way to being locked out. With the threshold now
|
||||||
|
-- raised (see below) a decay window is what keeps that raise safe rather than merely lenient.
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS last_failed_pin_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Rate-limit knobs introduced with this release.
|
||||||
|
--
|
||||||
|
-- recover_name_rate_per_15min (4, was a hardcoded 5): the per-(IP, name) ceiling. It MUST stay
|
||||||
|
-- below the account-lock threshold, which is the whole defect — at 5-per-IP against a 3-strike
|
||||||
|
-- lock, three requests from one IP locked any guest whose name is visible on the feed, every 15
|
||||||
|
-- minutes, forever. The lock threshold moves to 12 in code, so locking a victim now needs at
|
||||||
|
-- least three distinct sources while an honest guest never comes close.
|
||||||
|
--
|
||||||
|
-- pin_reset_ip_rate_per_min (30): /recover/request was the one unauthenticated endpoint with no
|
||||||
|
-- per-IP ceiling at all — /join got one in 017 and /recover in 019, and this third one was
|
||||||
|
-- simply missed. Its per-name key is attacker-chosen, so cycling names minted a fresh bucket
|
||||||
|
-- every time and the per-IP cost was unbounded.
|
||||||
|
--
|
||||||
|
-- upload_edit_rate_per_min (30): PATCH /upload/{id} had no rate limit of any kind.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
('recover_name_rate_per_15min', '4'),
|
||||||
|
('pin_reset_ip_rate_per_min', '30'),
|
||||||
|
('upload_edit_rate_per_min', '30'),
|
||||||
|
('upload_edit_rate_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
13
backend/migrations/026_idempotency_excludes_deleted.down.sql
Normal file
13
backend/migrations/026_idempotency_excludes_deleted.down.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Restore migration 022's wider index (which also covered soft-deleted rows).
|
||||||
|
--
|
||||||
|
-- Note this can FAIL where the up-migration succeeded: once retries-after-delete have been
|
||||||
|
-- allowed, two rows may legitimately share a `client_upload_id` (one deleted, one live), and
|
||||||
|
-- the wider unique index cannot be rebuilt over them. That is inherent to reverting this
|
||||||
|
-- direction, not a defect in the down-migration. If it fails, the live-only index is still
|
||||||
|
-- correct and should simply be kept.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX upload_client_upload_id_key
|
||||||
|
ON upload (client_upload_id)
|
||||||
|
WHERE client_upload_id IS NOT NULL;
|
||||||
35
backend/migrations/026_idempotency_excludes_deleted.up.sql
Normal file
35
backend/migrations/026_idempotency_excludes_deleted.up.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Narrow the client-upload idempotency index so it stops covering soft-deleted rows.
|
||||||
|
--
|
||||||
|
-- The bug (H9). Migration 022 created the index partial on `client_upload_id IS NOT NULL`
|
||||||
|
-- only, so a soft-deleted row kept occupying its key. But `find_by_client_upload_id` filters
|
||||||
|
-- `deleted_at IS NULL` — deliberately, and its doc comment says so: "if the guest deleted the
|
||||||
|
-- photo and their queue later retries, they should get a fresh upload rather than a
|
||||||
|
-- resurrection of a deleted one." The index and the lookup therefore disagreed, and the
|
||||||
|
-- disagreement is reachable by an ordinary guest:
|
||||||
|
--
|
||||||
|
-- 1. guest uploads a photo, then deletes it (soft delete — the row stays, `deleted_at` set)
|
||||||
|
-- 2. their queue retries the same item (reconnect requeue, or they tap "Erneut")
|
||||||
|
-- 3. the whole body is re-streamed and re-validated, then `ON CONFLICT DO NOTHING` matches
|
||||||
|
-- the DEAD row and inserts nothing
|
||||||
|
-- 4. the replay lookup filters that row out and finds nothing, so the handler returns 409
|
||||||
|
-- 5. the client classifies 409 as terminal and DELETES the blob from IndexedDB
|
||||||
|
--
|
||||||
|
-- The photo is now gone from the device with no row in the gallery, and there is no path back.
|
||||||
|
-- Re-selecting the same file from the camera roll mints a new `client_upload_id`, so that does
|
||||||
|
-- work — but the guest has no way to know that is what is required.
|
||||||
|
--
|
||||||
|
-- Adding `deleted_at IS NULL` makes the index agree with the lookup: a key is claimed only
|
||||||
|
-- while a LIVE row holds it, so step 3 inserts a fresh row and the retry succeeds.
|
||||||
|
--
|
||||||
|
-- Uniqueness among live rows is what the feature actually needs. The property migration 022
|
||||||
|
-- was protecting — "the same photo must not land in the gallery twice" — is about rows the
|
||||||
|
-- guest can see, and a soft-deleted row is not one of those.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
||||||
|
|
||||||
|
-- CONCURRENTLY is deliberately NOT used: sqlx runs each migration inside a transaction, and
|
||||||
|
-- CREATE INDEX CONCURRENTLY cannot run in one. The table is small (one event's uploads) and
|
||||||
|
-- this runs at boot before the server accepts requests, so the brief lock costs nothing.
|
||||||
|
CREATE UNIQUE INDEX upload_client_upload_id_key
|
||||||
|
ON upload (client_upload_id)
|
||||||
|
WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
2
backend/migrations/027_join_idempotency.down.sql
Normal file
2
backend/migrations/027_join_idempotency.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS user_client_join_id_key;
|
||||||
|
ALTER TABLE "user" DROP COLUMN IF EXISTS client_join_id;
|
||||||
34
backend/migrations/027_join_idempotency.up.sql
Normal file
34
backend/migrations/027_join_idempotency.up.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Idempotency key for /join, supplied by the client.
|
||||||
|
--
|
||||||
|
-- The failure this closes (H16) is the single most likely failure of the evening, on step one
|
||||||
|
-- of the product. `/join` commits the user row AND the bcrypt hash of the PIN, but the PLAINTEXT
|
||||||
|
-- PIN exists nowhere except the HTTP response body. So:
|
||||||
|
--
|
||||||
|
-- 1. guest scans the QR in the venue car park, taps "Beitreten"
|
||||||
|
-- 2. the server creates the account and hashes the PIN
|
||||||
|
-- 3. the response is lost on the way back — the 5G-to-nothing transition every wedding venue
|
||||||
|
-- has, or the AP handing off
|
||||||
|
-- 4. the client retries; the name is now taken, so it 409s
|
||||||
|
-- 5. the client shows a PIN entry form for a PIN THAT WAS NEVER DISPLAYED
|
||||||
|
--
|
||||||
|
-- The guest is locked out of their own brand-new account, and the only recovery is finding a
|
||||||
|
-- host with a dashboard open. `/upload` already solved exactly this with `client_upload_id`;
|
||||||
|
-- join never got the same treatment.
|
||||||
|
--
|
||||||
|
-- With a key, a retry is recognised as the same join and answered with a usable PIN. We do NOT
|
||||||
|
-- store the plaintext to replay it — see the handler: a retry ROTATES the PIN. That is sound
|
||||||
|
-- precisely because the original was never shown to anybody, so there is nothing to preserve,
|
||||||
|
-- and it keeps this table free of recoverable credentials.
|
||||||
|
|
||||||
|
ALTER TABLE "user" ADD COLUMN client_join_id UUID;
|
||||||
|
|
||||||
|
-- Partial, for the same reasons as `upload_client_upload_id_key`: index only the rows that
|
||||||
|
-- carry a key, and state the rule exactly. NULL is allowed and unconstrained, so any client
|
||||||
|
-- that does not send one (and every row that predates this column) behaves exactly as before.
|
||||||
|
--
|
||||||
|
-- Scoped per event as well as per key. The key is a client-generated v4 UUID so a cross-event
|
||||||
|
-- collision is not realistic, but a reused install genuinely has two events in one table and
|
||||||
|
-- "this join belongs to that event" is the property we actually mean.
|
||||||
|
CREATE UNIQUE INDEX user_client_join_id_key
|
||||||
|
ON "user" (event_id, client_join_id)
|
||||||
|
WHERE client_join_id IS NOT NULL;
|
||||||
22
backend/migrations/028_feed_counts_exclude_banned.down.sql
Normal file
22
backend/migrations/028_feed_counts_exclude_banned.down.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Restore migration 024's counts (which included banned users' likes and comments).
|
||||||
|
CREATE OR REPLACE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.display_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
(SELECT count(*) FROM "like" l WHERE l.upload_id = u.id) AS like_count,
|
||||||
|
(SELECT count(*) FROM comment c WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE;
|
||||||
44
backend/migrations/028_feed_counts_exclude_banned.up.sql
Normal file
44
backend/migrations/028_feed_counts_exclude_banned.up.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Exclude banned users' likes and comments from the feed's scalar counts (H11).
|
||||||
|
--
|
||||||
|
-- `v_feed` already excludes banned UPLOADERS (`usr.is_banned = FALSE` on the join), but the two
|
||||||
|
-- correlated subqueries added by migration 024 counted every like and every non-deleted comment
|
||||||
|
-- regardless of who wrote it. So after a ban:
|
||||||
|
--
|
||||||
|
-- * the banned guest's own photos disappear from the feed (correct), but
|
||||||
|
-- * their likes still inflate the counter on everyone else's photos, and
|
||||||
|
-- * their comments still contribute to `comment_count` — and, until the change to
|
||||||
|
-- `Comment::list_for_upload` that ships with this migration, were still RENDERED in the
|
||||||
|
-- lightbox on the most-viewed photo of the evening.
|
||||||
|
--
|
||||||
|
-- The host's mental model of "ban" is "this person's contributions are gone". Photos honoured it;
|
||||||
|
-- likes and comments did not. Migration 021 already applied exactly this reasoning to hashtag
|
||||||
|
-- counts, and the export query filters `is_banned` too — this brings the last read path in line.
|
||||||
|
--
|
||||||
|
-- Derived at read time, so `unban_user` restores the counts with no extra work, exactly as it
|
||||||
|
-- already restores the photos.
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.display_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
(SELECT count(*) FROM "like" l
|
||||||
|
JOIN "user" lu ON lu.id = l.user_id
|
||||||
|
WHERE l.upload_id = u.id AND NOT lu.is_banned) AS like_count,
|
||||||
|
(SELECT count(*) FROM comment c
|
||||||
|
JOIN "user" cu ON cu.id = c.user_id
|
||||||
|
WHERE c.upload_id = u.id AND c.deleted_at IS NULL AND NOT cu.is_banned) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE;
|
||||||
2
backend/migrations/029_host_action_audit.down.sql
Normal file
2
backend/migrations/029_host_action_audit.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS host_action_audit_event_created_idx;
|
||||||
|
DROP TABLE IF EXISTS host_action_audit;
|
||||||
41
backend/migrations/029_host_action_audit.up.sql
Normal file
41
backend/migrations/029_host_action_audit.up.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- An audit trail for privileged actions (H17).
|
||||||
|
--
|
||||||
|
-- What existed before: nothing. `grep -i audit` across `handlers/host.rs` and `handlers/admin.rs`
|
||||||
|
-- returned no hits. Individual actions logged a `tracing::info!` line, but config changes, gallery
|
||||||
|
-- release and event lock/unlock logged nothing at all — and the "audit trail" as a whole was a
|
||||||
|
-- 30 MB rotating Docker log that the runbook's own retention settings will discard.
|
||||||
|
--
|
||||||
|
-- Why it matters here specifically: a host is a promoted GUEST, and `reset_pin` overwrites another
|
||||||
|
-- guest's credential and returns the new PIN in the clear. So a host can take over any guest's
|
||||||
|
-- account and post as them, and nothing in the record showed it happened (only /recover FAILURES
|
||||||
|
-- were logged). At a wedding the people involved know each other; the point is not catching a
|
||||||
|
-- villain, it is being able to answer "what happened to my photo?" the next morning without
|
||||||
|
-- guessing.
|
||||||
|
--
|
||||||
|
-- Deliberately append-only in practice: no UPDATE or DELETE path is written for it anywhere. Small
|
||||||
|
-- (a few hundred rows for a real event), so no partitioning or retention job.
|
||||||
|
|
||||||
|
CREATE TABLE host_action_audit (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
||||||
|
-- The privileged caller. NOT a FK with ON DELETE CASCADE: the record must survive the actor's
|
||||||
|
-- account being removed, which is exactly when it is most likely to be wanted.
|
||||||
|
actor_id UUID,
|
||||||
|
actor_name TEXT,
|
||||||
|
actor_role TEXT NOT NULL,
|
||||||
|
-- Short stable slug: 'ban_user', 'unban_user', 'reset_pin', 'delete_upload',
|
||||||
|
-- 'delete_comment', 'release_gallery', 'lock_uploads', 'unlock_uploads', 'patch_config',
|
||||||
|
-- 'promote_user', 'demote_user', 'delete_user'.
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
-- The guest or object acted upon, when there is one.
|
||||||
|
target_id UUID,
|
||||||
|
target_name TEXT,
|
||||||
|
-- Free-form context: the config key and its old/new value, the caption that was removed, etc.
|
||||||
|
-- Never credentials — a reset PIN must not be recoverable from this table.
|
||||||
|
detail JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The only query shape this needs: "what happened at this event, newest first".
|
||||||
|
CREATE INDEX host_action_audit_event_created_idx
|
||||||
|
ON host_action_audit (event_id, created_at DESC);
|
||||||
3
backend/migrations/030_raise_join_ip_rate.down.sql
Normal file
3
backend/migrations/030_raise_join_ip_rate.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Revert the join ceiling to 60/min for installs still on the raised default
|
||||||
|
-- (preserves any explicit admin override at another value).
|
||||||
|
UPDATE config SET value = '60' WHERE key = 'join_ip_rate_per_min' AND value = '300';
|
||||||
15
backend/migrations/030_raise_join_ip_rate.up.sql
Normal file
15
backend/migrations/030_raise_join_ip_rate.up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Raise the per-IP join ceiling from 60/min to 300/min.
|
||||||
|
--
|
||||||
|
-- Rationale: every guest at the venue arrives through one NAT'd public address,
|
||||||
|
-- so `join_ip:{ip}` is not a per-guest limit at all — it is a ceiling on the
|
||||||
|
-- whole party. The QR code goes up once and is scanned in a burst: at 60/min,
|
||||||
|
-- guest 61 onwards is refused on the join screen, which is the one screen with
|
||||||
|
-- no auto-retry, and every manual retry spends another slot.
|
||||||
|
--
|
||||||
|
-- The code default was already raised to 300 (auth/handlers.rs), but a default
|
||||||
|
-- only applies when the key is ABSENT, and migration 017 seeds it. Without this
|
||||||
|
-- UPDATE the raise is dead code on every existing install.
|
||||||
|
--
|
||||||
|
-- Only bump installs still on the seeded default; an admin who deliberately set
|
||||||
|
-- a different value keeps it (migration 017 seeded 60; this UPDATE is scoped to '60').
|
||||||
|
UPDATE config SET value = '300' WHERE key = 'join_ip_rate_per_min' AND value = '60';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Restore migration 026's predicate, then drop the column it depended on.
|
||||||
|
--
|
||||||
|
-- Note the same pairing caveat 026's own down carries: this is only valid alongside a code
|
||||||
|
-- rollback. `Upload::create` sends an ON CONFLICT predicate that must match the live index, so
|
||||||
|
-- running this down against the current binary makes every keyed upload a runtime 500.
|
||||||
|
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
||||||
|
CREATE UNIQUE INDEX upload_client_upload_id_key ON upload (client_upload_id)
|
||||||
|
WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE upload DROP COLUMN IF EXISTS taken_down_by_host;
|
||||||
28
backend/migrations/031_takedown_holds_idempotency_key.up.sql
Normal file
28
backend/migrations/031_takedown_holds_idempotency_key.up.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Keep a client upload key CLAIMED when the deletion was a host takedown.
|
||||||
|
--
|
||||||
|
-- Migration 026 narrowed `upload_client_upload_id_key` to live rows so that a guest who deletes
|
||||||
|
-- their own photo and whose queue later retries gets a fresh upload instead of a permanent 409.
|
||||||
|
-- That rationale reasoned only about the GUEST deleting. `deleted_at` is also set by
|
||||||
|
-- `host_delete_upload`, and for that case the same rule undoes a moderation decision:
|
||||||
|
--
|
||||||
|
-- 1. Guest uploads. The row commits and the photo appears in the feed, but the response is lost
|
||||||
|
-- on the way back (the flaky-wifi case this whole feature exists for), so the phone keeps the
|
||||||
|
-- queue item.
|
||||||
|
-- 2. The host sees the photo and takes it down. `deleted_at` is stamped, the keepsake epoch is
|
||||||
|
-- bumped, and the archive is rebuilt without it.
|
||||||
|
-- 3. Ten minutes later the phone reconnects and retries. The key is no longer claimed, the
|
||||||
|
-- INSERT succeeds, and the photo is BACK — in the feed, in the next keepsake, under a NEW
|
||||||
|
-- uuid that matches nothing in the host's moderation history, with nothing logged to say a
|
||||||
|
-- takedown was undone.
|
||||||
|
--
|
||||||
|
-- So the key stays claimed for a host takedown and is released only for a guest's own delete. The
|
||||||
|
-- retry then resolves to the duplicate path and is refused, which is the correct answer: the photo
|
||||||
|
-- was deliberately removed, and re-sending the bytes must not bring it back.
|
||||||
|
ALTER TABLE upload ADD COLUMN taken_down_by_host BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- KEEP THE PREDICATE IN LOCKSTEP WITH `Upload::create`'s ON CONFLICT clause (models/upload.rs).
|
||||||
|
-- A drift between the two is not a compile error here — queries are checked at runtime — it is a
|
||||||
|
-- 500 on every upload that carries a key, i.e. on exactly the retries this index exists to serve.
|
||||||
|
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
||||||
|
CREATE UNIQUE INDEX upload_client_upload_id_key ON upload (client_upload_id)
|
||||||
|
WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,30 @@ fn looks_placeholder(s: &str) -> bool {
|
|||||||
|| lower.contains("placeholder")
|
|| lower.contains("placeholder")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bcrypt hash is exactly 60 characters and opens with `$2<variant>$<cost>$`.
|
||||||
|
///
|
||||||
|
/// Checking the SHAPE, not just placeholder-ness, is what catches a hash silently mangled in
|
||||||
|
/// transit. The `$` segments are variable-expansion bait for both shell quoting and Docker
|
||||||
|
/// Compose's `env_file` parsing, and a mangled hash is not a placeholder — so without this it
|
||||||
|
/// passes every other guard here, the app boots green, `/health` returns `ok`, and every admin
|
||||||
|
/// login 401s.
|
||||||
|
///
|
||||||
|
/// That failure is unrecoverable mid-event, which is why it is worth a hard fail at boot: the
|
||||||
|
/// Admin row is created BY a successful admin login (`auth/handlers.rs`), and only an Admin or
|
||||||
|
/// Host can promote a Host. No admin login therefore means no host at all — the event cannot be
|
||||||
|
/// closed, the gallery cannot be released, and nothing can be moderated.
|
||||||
|
fn looks_bcrypt(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
s.len() == 60
|
||||||
|
&& b[0] == b'$'
|
||||||
|
&& b[1] == b'2'
|
||||||
|
&& matches!(b[2], b'a' | b'b' | b'x' | b'y')
|
||||||
|
&& b[3] == b'$'
|
||||||
|
&& b[4].is_ascii_digit()
|
||||||
|
&& b[5].is_ascii_digit()
|
||||||
|
&& b[6] == b'$'
|
||||||
|
}
|
||||||
|
|
||||||
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
|
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
|
||||||
/// with a publicly-known signing key is worse than one that refuses to start.
|
/// with a publicly-known signing key is worse than one that refuses to start.
|
||||||
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
|
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
|
||||||
@@ -46,6 +70,18 @@ fn validate_secrets(
|
|||||||
"ADMIN_PASSWORD_HASH is unset or still the .env.example placeholder — generate one \
|
"ADMIN_PASSWORD_HASH is unset or still the .env.example placeholder — generate one \
|
||||||
(docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>').",
|
(docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>').",
|
||||||
);
|
);
|
||||||
|
} else if !looks_bcrypt(admin_password_hash) {
|
||||||
|
problems.push(
|
||||||
|
"ADMIN_PASSWORD_HASH is not a well-formed bcrypt hash: expected exactly 60 \
|
||||||
|
characters starting `$2b$12$…`. First look at the value that actually reached \
|
||||||
|
the app — `docker compose exec app printenv ADMIN_PASSWORD_HASH` — and compare \
|
||||||
|
it to .env character for character. In .env, SINGLE-QUOTE the hash \
|
||||||
|
('$2b$12$…'): Compose uses single-quoted env_file values literally, so the `$` \
|
||||||
|
segments survive. Double them to `$$` ONLY when setting the value under \
|
||||||
|
`environment:` in docker-compose.yml — doing that in .env corrupts a hash that \
|
||||||
|
would otherwise have worked. Regenerate with: \
|
||||||
|
docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>'",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The DATABASE_URL carries the Postgres password, so a placeholder here means the stack is
|
// The DATABASE_URL carries the Postgres password, so a placeholder here means the stack is
|
||||||
// running on `CHANGE_ME_use_a_strong_password` — a credential published in the repo. The
|
// running on `CHANGE_ME_use_a_strong_password` — a credential published in the repo. The
|
||||||
@@ -114,6 +150,49 @@ pub struct AppConfig {
|
|||||||
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
|
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
|
||||||
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
|
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
|
||||||
|
|
||||||
|
/// Upper bound on `SESSION_EXPIRY_DAYS`. ~10 years — absurdly generous for a one-evening event,
|
||||||
|
/// and low enough that `chrono::Duration::days` cannot overflow downstream.
|
||||||
|
const MAX_SESSION_EXPIRY_DAYS: i64 = 3650;
|
||||||
|
|
||||||
|
/// Parse and RANGE-CHECK `SESSION_EXPIRY_DAYS`. Refusing to boot is the whole point.
|
||||||
|
///
|
||||||
|
/// This was `.parse().context(...)` with no bounds, and both ends of the range were live faults
|
||||||
|
/// that a green health check hid completely (H7):
|
||||||
|
///
|
||||||
|
/// * A huge value made `chrono::Duration::days` PANIC on every `/join`, `/recover` and
|
||||||
|
/// `/admin/login`. There is no `CatchPanicLayer`, so the client got a connection reset with no
|
||||||
|
/// HTTP response at all — the app was up, healthy, and unable to authenticate anybody.
|
||||||
|
/// * Zero or negative created every session already-expired: `/join` returns 201 with a token,
|
||||||
|
/// and then every authenticated request 401s. A guest joins successfully and the app
|
||||||
|
/// immediately behaves as though they never did.
|
||||||
|
///
|
||||||
|
/// Both booted green because `/health` only probes the database. A bad value must stop the
|
||||||
|
/// container instead, where the operator sees it.
|
||||||
|
fn parse_session_expiry_days(raw: Option<&str>) -> Result<i64> {
|
||||||
|
let Some(raw) = raw else { return Ok(30) };
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(30);
|
||||||
|
}
|
||||||
|
let days: i64 = trimmed
|
||||||
|
.parse()
|
||||||
|
.with_context(|| format!("SESSION_EXPIRY_DAYS must be a whole number (got {trimmed:?})"))?;
|
||||||
|
if days < 1 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"SESSION_EXPIRY_DAYS must be at least 1 (got {days}). Zero or negative makes every \
|
||||||
|
session expire the moment it is created: /join succeeds and every request after it \
|
||||||
|
returns 401."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if days > MAX_SESSION_EXPIRY_DAYS {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"SESSION_EXPIRY_DAYS must be at most {MAX_SESSION_EXPIRY_DAYS} (got {days}). Larger \
|
||||||
|
values overflow the token-expiry arithmetic and panic on every auth request."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(days)
|
||||||
|
}
|
||||||
|
|
||||||
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());
|
||||||
@@ -128,10 +207,9 @@ impl AppConfig {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url,
|
database_url,
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
session_expiry_days: parse_session_expiry_days(
|
||||||
.unwrap_or_else(|_| "30".to_string())
|
std::env::var("SESSION_EXPIRY_DAYS").ok().as_deref(),
|
||||||
.parse()
|
)?,
|
||||||
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
|
||||||
admin_password_hash,
|
admin_password_hash,
|
||||||
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
|
event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
|
||||||
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
|
event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
|
||||||
@@ -173,7 +251,10 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
|
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
|
||||||
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
|
// A structurally valid bcrypt hash: exactly 60 chars, `$2y$12$` + 53 of salt/digest.
|
||||||
|
// The shape matters — `looks_bcrypt` enforces it, so a fixture of the wrong length
|
||||||
|
// would assert the opposite of what these tests claim.
|
||||||
|
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY01";
|
||||||
const REAL_DB_URL: &str = "postgres://eventsnap:7f3a9c1e5b2d8a4f@db:5432/eventsnap";
|
const REAL_DB_URL: &str = "postgres://eventsnap:7f3a9c1e5b2d8a4f@db:5432/eventsnap";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -198,6 +279,31 @@ mod tests {
|
|||||||
assert!(validate_secrets(true, "tooshort", REAL_HASH, REAL_DB_URL).is_err());
|
assert!(validate_secrets(true, "tooshort", REAL_HASH, REAL_DB_URL).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The failure this guards is silent and unrecoverable mid-event: a hash whose `$`
|
||||||
|
/// segments were eaten by shell or Compose interpolation is NOT a placeholder, so every
|
||||||
|
/// other guard passes, the app boots green and `/health` reports ok — and then every
|
||||||
|
/// admin login 401s, which (because the Admin row is created by a successful login, and
|
||||||
|
/// only an Admin/Host can promote a Host) means no host exists for the whole event.
|
||||||
|
#[test]
|
||||||
|
fn prod_rejects_mangled_admin_hash() {
|
||||||
|
// What `$2y$12$…` degrades to once `$2y`/`$12` are read as unset variables.
|
||||||
|
assert!(validate_secrets(true, REAL_SECRET, "abcdefghijklmnop", REAL_DB_URL).is_err());
|
||||||
|
// Right prefix, truncated body — still not a usable hash.
|
||||||
|
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$tooshort", REAL_DB_URL).is_err());
|
||||||
|
// Correct length but no bcrypt prefix at all.
|
||||||
|
assert!(validate_secrets(true, REAL_SECRET, &"x".repeat(60), REAL_DB_URL).is_err());
|
||||||
|
// All three shipped bcrypt variants stay acceptable.
|
||||||
|
let body = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY01";
|
||||||
|
for variant in ["2a", "2b", "2y"] {
|
||||||
|
let hash = format!("${variant}$12${body}");
|
||||||
|
assert_eq!(hash.len(), 60);
|
||||||
|
assert!(
|
||||||
|
validate_secrets(true, REAL_SECRET, &hash, REAL_DB_URL).is_ok(),
|
||||||
|
"bcrypt variant ${variant}$ must be accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prod_rejects_missing_or_placeholder_admin_hash() {
|
fn prod_rejects_missing_or_placeholder_admin_hash() {
|
||||||
assert!(validate_secrets(true, REAL_SECRET, "", REAL_DB_URL).is_err());
|
assert!(validate_secrets(true, REAL_SECRET, "", REAL_DB_URL).is_err());
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ use anyhow::{Context, Result};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
/// Keep in step with `.env.example` and the `db` sizing comment in `docker-compose.yml`.
|
||||||
|
/// These three drifted apart once (code 10 / `.env.example` 15 / runbook 30) and the runbook
|
||||||
|
/// presented its number as authoritative, so the contradiction was invisible at deploy time.
|
||||||
|
/// 15 is sized to 2 vCPU and the 1G `db` memory limit — raise it only alongside both.
|
||||||
|
const DEFAULT_MAX_CONNECTIONS: u32 = 15;
|
||||||
|
|
||||||
/// SQLSTATE for `invalid_password`.
|
/// SQLSTATE for `invalid_password`.
|
||||||
const PG_INVALID_PASSWORD: &str = "28P01";
|
const PG_INVALID_PASSWORD: &str = "28P01";
|
||||||
@@ -47,13 +51,51 @@ fn explain_auth_failure(err: &sqlx::Error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||||
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
// A malformed value must not silently become the default: an operator who typed
|
||||||
.ok()
|
// `DATABASE_MAX_CONNECTIONS=3O` (letter O) would otherwise get 15 with no indication,
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
// and would keep tuning a knob that never took effect.
|
||||||
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
let max_connections = match std::env::var("DATABASE_MAX_CONNECTIONS") {
|
||||||
|
Err(_) => DEFAULT_MAX_CONNECTIONS,
|
||||||
|
Ok(raw) => match raw.trim().parse::<u32>() {
|
||||||
|
Ok(0) => {
|
||||||
|
anyhow::bail!("DATABASE_MAX_CONNECTIONS must be at least 1 (got 0)");
|
||||||
|
}
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
anyhow::bail!(
|
||||||
|
"DATABASE_MAX_CONNECTIONS must be a positive integer (got {raw:?}): {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let pool = match PgPoolOptions::new()
|
let pool = match PgPoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
|
// Fail fast instead of parking. sqlx's default is 30s, which on a DB blip means every
|
||||||
|
// request AND all ~100 SSE session revalidations sit on the pool for half a minute
|
||||||
|
// before erroring — the app looks hung rather than degraded, and the backlog outlives
|
||||||
|
// the blip. Five seconds is far longer than a healthy acquire ever takes.
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||||
|
// Keep a couple of connections warm so the first request after an idle stretch (the gap
|
||||||
|
// between setting the venue up and the guests arriving) doesn't pay TCP + auth.
|
||||||
|
.min_connections(2)
|
||||||
|
// Bound every statement server-side. Without this a single pathological query holds a
|
||||||
|
// pool slot indefinitely and no client-side timeout can take it back — the slot is only
|
||||||
|
// released when Postgres finishes. `lock_timeout` covers the same hazard for a row lock
|
||||||
|
// contended by, say, a release running against an in-flight upload.
|
||||||
|
.after_connect(|conn, _meta| {
|
||||||
|
Box::pin(async move {
|
||||||
|
// Two statements, two round-trips, deliberately. `sqlx::query` uses the extended
|
||||||
|
// query protocol, which permits exactly ONE statement per call — sending them as
|
||||||
|
// `SET a; SET b` makes every new connection fail, which surfaces as the pool
|
||||||
|
// never opening one at all and `create_pool` reporting a connect timeout.
|
||||||
|
sqlx::query("SET statement_timeout = '15s'")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("SET lock_timeout = '5s'").execute(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
.connect(database_url)
|
.connect(database_url)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -64,10 +106,27 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Migrations run on their OWN connection, deliberately NOT from the pool.
|
||||||
|
//
|
||||||
|
// `after_connect` above puts `lock_timeout = 5s` on every pooled connection, and the migrator
|
||||||
|
// would inherit it. Migrations that take ACCESS EXCLUSIVE (026's index swap, 027's ADD COLUMN)
|
||||||
|
// then turn a short WAIT into a hard FAILURE: anything holding ACCESS SHARE on `upload` or
|
||||||
|
// `"user"` for more than five seconds — the hourly `pg_dump` the runbook installs in §10.2, or
|
||||||
|
// an operator's open `psql` transaction — aborts the migration, `create_pool` returns an
|
||||||
|
// error, `main` exits 1, and `restart: unless-stopped` crash-loops the app behind a live Caddy.
|
||||||
|
// The rollback is clean and a later retry succeeds, which is exactly what makes it a confusing
|
||||||
|
// intermittent outage rather than an obvious one.
|
||||||
|
//
|
||||||
|
// `statement_timeout` is left off here too: a migration on a real table can legitimately run
|
||||||
|
// longer than the 15s a request is allowed.
|
||||||
|
let mut migrator_conn = <sqlx::PgConnection as sqlx::Connection>::connect(database_url)
|
||||||
|
.await
|
||||||
|
.context("failed to open a connection for migrations")?;
|
||||||
sqlx::migrate!()
|
sqlx::migrate!()
|
||||||
.run(&pool)
|
.run(&mut migrator_conn)
|
||||||
.await
|
.await
|
||||||
.context("failed to run database migrations")?;
|
.context("failed to run database migrations")?;
|
||||||
|
let _ = sqlx::Connection::close(migrator_conn).await;
|
||||||
|
|
||||||
tracing::info!(max_connections, "database connected and migrations applied");
|
tracing::info!(max_connections, "database connected and migrations applied");
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ pub enum AppError {
|
|||||||
/// (banned user, quota): the queued blob is kept and retried if the host reopens,
|
/// (banned user, quota): the queued blob is kept and retried if the host reopens,
|
||||||
/// instead of being purged like a genuinely-terminal rejection.
|
/// instead of being purged like a genuinely-terminal rejection.
|
||||||
UploadsLocked(String),
|
UploadsLocked(String),
|
||||||
|
/// The gallery has been RELEASED — the keepsake was snapshotted, so a late upload could
|
||||||
|
/// never appear in it. Mechanically this is still reversible (a host reopen clears
|
||||||
|
/// `export_released_at` and bumps the epoch), which is why the blob must still be kept.
|
||||||
|
///
|
||||||
|
/// Distinct from `UploadsLocked` because the two differ in *expectation*, and the client's
|
||||||
|
/// retry policy has to differ with them. A closed event is a pause the host means to undo;
|
||||||
|
/// a released gallery is the end of the event, and nobody reopens it. Under one shared code
|
||||||
|
/// the queue kept auto-retrying a released event forever — re-streaming a multi-megabyte
|
||||||
|
/// photo over cellular on every budget refill, for a request whose answer will not change,
|
||||||
|
/// while telling the guest to tap a camera button that 403s. `gallery_released` lets the
|
||||||
|
/// client park the item visibly and wait for an actual `event-opened` instead of guessing.
|
||||||
|
GalleryReleased(String),
|
||||||
|
/// The uploader is banned. A 403 like `Forbidden`, but tagged `user_banned` so the client
|
||||||
|
/// keeps the queued blob instead of purging it.
|
||||||
|
///
|
||||||
|
/// A ban is reversible — `unban_user` exists, and the host's own confirm copy promises the
|
||||||
|
/// photos come back — but the client classified the generic `forbidden` code as permanent,
|
||||||
|
/// deleted the blob from IndexedDB, and moved the row to `blocked`, which has no retry
|
||||||
|
/// button. So an unban could restore everything except the photos that were in flight when
|
||||||
|
/// the ban landed, and a ban issued by mistake destroyed them with no way back.
|
||||||
|
UserBanned(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
/// Second field: optional retry-after seconds to include in the response.
|
/// Second field: optional retry-after seconds to include in the response.
|
||||||
@@ -19,6 +40,12 @@ pub enum AppError {
|
|||||||
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
|
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
|
||||||
/// retrying a permanently-failing upload forever.
|
/// retrying a permanently-failing upload forever.
|
||||||
QuotaExceeded(String),
|
QuotaExceeded(String),
|
||||||
|
/// The server is temporarily unable to serve this request — currently only pool
|
||||||
|
/// saturation. Distinct from `Internal` because it is TRANSIENT and the client should be
|
||||||
|
/// told so: a 500 reads as "this request is broken", while a 503 + Retry-After reads as
|
||||||
|
/// "come back shortly", which is what the upload queue's retry classifier needs to make
|
||||||
|
/// the right call. Second field: optional retry-after seconds.
|
||||||
|
ServiceUnavailable(String, Option<u64>),
|
||||||
Internal(anyhow::Error),
|
Internal(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +56,15 @@ impl AppError {
|
|||||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
|
Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"),
|
||||||
|
Self::GalleryReleased(_) => (StatusCode::FORBIDDEN, "gallery_released"),
|
||||||
|
Self::UserBanned(_) => (StatusCode::FORBIDDEN, "user_banned"),
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||||
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
|
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
|
||||||
|
Self::ServiceUnavailable(..) => {
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable")
|
||||||
|
}
|
||||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,9 +75,12 @@ impl AppError {
|
|||||||
| Self::Unauthorized(msg)
|
| Self::Unauthorized(msg)
|
||||||
| Self::Forbidden(msg)
|
| Self::Forbidden(msg)
|
||||||
| Self::UploadsLocked(msg)
|
| Self::UploadsLocked(msg)
|
||||||
|
| Self::GalleryReleased(msg)
|
||||||
|
| Self::UserBanned(msg)
|
||||||
| Self::NotFound(msg)
|
| Self::NotFound(msg)
|
||||||
| Self::Conflict(msg) => msg.clone(),
|
| Self::Conflict(msg) => msg.clone(),
|
||||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||||
|
Self::ServiceUnavailable(msg, _) => msg.clone(),
|
||||||
Self::QuotaExceeded(msg) => msg.clone(),
|
Self::QuotaExceeded(msg) => msg.clone(),
|
||||||
Self::Internal(err) => {
|
Self::Internal(err) => {
|
||||||
tracing::error!("internal error: {err:#}");
|
tracing::error!("internal error: {err:#}");
|
||||||
@@ -58,13 +93,61 @@ impl AppError {
|
|||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, code) = self.status_and_code();
|
let (status, code) = self.status_and_code();
|
||||||
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
|
// BOTH retry-carrying variants must be matched here. `message()` would fail to
|
||||||
Some(*secs)
|
// compile on a missing arm; this one would not — it would silently drop the header and
|
||||||
} else {
|
// the `retry_after_secs` body field, which is exactly the sort of omission that only
|
||||||
None
|
// shows up under the load the 503 exists for.
|
||||||
|
let retry_after_secs = match &self {
|
||||||
|
Self::TooManyRequests(_, secs) | Self::ServiceUnavailable(_, secs) => *secs,
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
let message = self.message();
|
let message = self.message();
|
||||||
|
|
||||||
|
// Log every 4xx. Until now they were invisible at ANY log level: tower_http's
|
||||||
|
// `ServerErrorsAsFailures` classifier counts a 4xx as a *success*, so it goes to
|
||||||
|
// `DefaultOnResponse` at DEBUG, and production runs at `info`. The consequence is that a
|
||||||
|
// misconfigured limit leaves no trace at all — if guests spend the evening hitting 429s
|
||||||
|
// on `upload_rate_per_hour`, or 413s on the storage quota, `docker compose logs` after
|
||||||
|
// the event contains nothing about it and the cause is unknowable.
|
||||||
|
//
|
||||||
|
// WARN rather than INFO because every variant here is a request that did not do what
|
||||||
|
// the guest asked. 5xx is excluded: `Internal` already logs with its full source chain
|
||||||
|
// in `message()` above, and the pool-exhaustion 503 logs at construction — logging again
|
||||||
|
// here would double every server-side failure.
|
||||||
|
//
|
||||||
|
// No request context is available: `into_response` receives only the error, so there is
|
||||||
|
// no path, method or user id to attach. Status + code + message is what can honestly be
|
||||||
|
// reported from here, and it is enough to see the SHAPE of a bad evening. Raising
|
||||||
|
// `tower_http` to DEBUG instead was considered and rejected — see the note in main.rs.
|
||||||
|
//
|
||||||
|
// `detail = ?message`, NOT `%message`. Two reasons, both learned the hard way:
|
||||||
|
//
|
||||||
|
// * `message` is tracing's own reserved field for an event's format literal, so `%message`
|
||||||
|
// printed unlabelled and would collide under a JSON layer.
|
||||||
|
// * Debug formatting QUOTES AND ESCAPES the string, and several 4xx messages interpolate
|
||||||
|
// attacker-chosen text — the guest's name in `Der Name "X" ist bereits vergeben.`, and
|
||||||
|
// multipart/parse errors that echo their input. With Display formatting, a value
|
||||||
|
// carrying a newline plus a plausible log prefix lets two unauthenticated requests
|
||||||
|
// forge lines in the only forensic record an unattended event has.
|
||||||
|
// `validate_display_name` now rejects control characters, so the name route is closed
|
||||||
|
// at the source as well — but that is ONE input, and this line formats every 4xx
|
||||||
|
// message in the app. Escaping here is what makes the guarantee general; do not
|
||||||
|
// "simplify" it to `%message` on the grounds that names are already validated.
|
||||||
|
//
|
||||||
|
// 401 and 404 are logged at DEBUG rather than WARN. They carry no operator signal (an
|
||||||
|
// expired session, a mistyped URL) and they are the cheapest lines for a scanner to
|
||||||
|
// generate — at ~260 bytes each against the 30 MB the json-file driver retains
|
||||||
|
// (docker-compose.yml), a sustained flood could otherwise roll the whole window in
|
||||||
|
// minutes and destroy the post-event forensics this logging exists to provide.
|
||||||
|
if status.is_client_error() {
|
||||||
|
let noisy = status == StatusCode::UNAUTHORIZED || status == StatusCode::NOT_FOUND;
|
||||||
|
if noisy {
|
||||||
|
tracing::debug!(status = status.as_u16(), code, detail = ?message, "request rejected");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(status = status.as_u16(), code, detail = ?message, "request rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"error": code,
|
"error": code,
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -93,6 +176,120 @@ impl From<anyhow::Error> for AppError {
|
|||||||
|
|
||||||
impl From<sqlx::Error> for AppError {
|
impl From<sqlx::Error> for AppError {
|
||||||
fn from(err: sqlx::Error) -> Self {
|
fn from(err: sqlx::Error) -> Self {
|
||||||
Self::Internal(err.into())
|
match err {
|
||||||
|
// Pool saturation is load, not a bug. Reporting it as a 500 was actively harmful:
|
||||||
|
// the frontend's upload-queue classifier treats 5xx as transient and retries, so
|
||||||
|
// the retries piled straight back into the saturated pool with no Retry-After to
|
||||||
|
// pace them. A 503 says the same thing honestly and carries the backoff.
|
||||||
|
//
|
||||||
|
// `PoolClosed` stays `Internal` — it only happens during shutdown, where a 503
|
||||||
|
// would invite a retry against a server that is going away.
|
||||||
|
sqlx::Error::PoolTimedOut => {
|
||||||
|
tracing::warn!("database pool exhausted; shedding a request with 503");
|
||||||
|
Self::ServiceUnavailable(
|
||||||
|
"Server ist gerade ausgelastet. Bitte versuche es in ein paar Sekunden erneut."
|
||||||
|
.into(),
|
||||||
|
Some(POOL_TIMEOUT_RETRY_AFTER_SECS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => Self::Internal(other.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry-After for a shed request. Short: pool saturation clears in seconds once the queue
|
||||||
|
/// drains, and a long value would make a brief spike feel like an outage.
|
||||||
|
const POOL_TIMEOUT_RETRY_AFTER_SECS: u64 = 3;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `into_response` extracts `retry_after_secs` by MATCHING ON VARIANTS, so unlike
|
||||||
|
/// `message()` a missing arm is not a compile error — it silently drops the header. Pin the
|
||||||
|
/// behaviour for both retry-carrying variants.
|
||||||
|
#[test]
|
||||||
|
fn both_retry_carrying_variants_emit_retry_after() {
|
||||||
|
for err in [
|
||||||
|
AppError::TooManyRequests("slow down".into(), Some(42)),
|
||||||
|
AppError::ServiceUnavailable("busy".into(), Some(3)),
|
||||||
|
] {
|
||||||
|
let expected = match &err {
|
||||||
|
AppError::TooManyRequests(_, Some(s))
|
||||||
|
| AppError::ServiceUnavailable(_, Some(s)) => s.to_string(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let resp = err.into_response();
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(axum::http::header::RETRY_AFTER)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
Some(expected.as_str()),
|
||||||
|
"a shed/throttled client must be told when to come back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4xx must be logged and 5xx must not be logged HERE — `Internal` logs its source chain in
|
||||||
|
/// `message()` and the pool-exhaustion 503 logs at construction, so a second line in
|
||||||
|
/// `into_response` would double every server-side failure in the post-event logs.
|
||||||
|
///
|
||||||
|
/// The guard is `status.is_client_error()`, so this pins the classification rather than the
|
||||||
|
/// logging itself (which needs a subscriber to observe).
|
||||||
|
#[test]
|
||||||
|
fn only_client_errors_are_in_the_logged_band() {
|
||||||
|
for err in [
|
||||||
|
AppError::BadRequest("x".into()),
|
||||||
|
AppError::Unauthorized("x".into()),
|
||||||
|
AppError::Forbidden("x".into()),
|
||||||
|
AppError::UploadsLocked("x".into()),
|
||||||
|
AppError::NotFound("x".into()),
|
||||||
|
AppError::Conflict("x".into()),
|
||||||
|
AppError::TooManyRequests("x".into(), Some(1)),
|
||||||
|
AppError::QuotaExceeded("x".into()),
|
||||||
|
] {
|
||||||
|
let (status, _) = err.status_and_code();
|
||||||
|
assert!(
|
||||||
|
status.is_client_error(),
|
||||||
|
"{status} should be in the 4xx band this logs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for err in [
|
||||||
|
AppError::ServiceUnavailable("x".into(), Some(3)),
|
||||||
|
AppError::Internal(anyhow::anyhow!("boom")),
|
||||||
|
] {
|
||||||
|
let (status, _) = err.status_and_code();
|
||||||
|
assert!(
|
||||||
|
!status.is_client_error(),
|
||||||
|
"{status} logs elsewhere; logging it here would double it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pool saturation is load, not a bug. A 500 makes the frontend's retry classifier pile
|
||||||
|
/// straight back into the saturated pool with no backoff to pace it.
|
||||||
|
#[test]
|
||||||
|
fn pool_exhaustion_sheds_with_503_but_shutdown_does_not() {
|
||||||
|
let shed: AppError = sqlx::Error::PoolTimedOut.into();
|
||||||
|
assert_eq!(
|
||||||
|
shed.status_and_code(),
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable")
|
||||||
|
);
|
||||||
|
|
||||||
|
// PoolClosed only happens during shutdown; a 503 there would invite a retry against a
|
||||||
|
// server that is going away.
|
||||||
|
let closing: AppError = sqlx::Error::PoolClosed.into();
|
||||||
|
assert_eq!(
|
||||||
|
closing.status_and_code(),
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Everything else must keep its existing mapping.
|
||||||
|
let missing: AppError = sqlx::Error::RowNotFound.into();
|
||||||
|
assert_eq!(
|
||||||
|
missing.status_and_code(),
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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::sse_tickets::TicketKind;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -104,7 +105,7 @@ pub struct PatchConfigRequest(pub HashMap<String, String>);
|
|||||||
|
|
||||||
pub async fn patch_config(
|
pub async fn patch_config(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(auth): RequireAdmin,
|
||||||
Json(body): Json<HashMap<String, String>>,
|
Json(body): Json<HashMap<String, String>>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
|
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
|
||||||
@@ -132,6 +133,16 @@ pub async fn patch_config(
|
|||||||
("social_rate_per_min", true, 1.0, 100_000.0),
|
("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),
|
||||||
|
// The three limiters migration 025 introduced. All are READ at runtime
|
||||||
|
// (`upload.rs` for the edit limiter, `auth/handlers.rs` for the other two) and 025
|
||||||
|
// INSERTs all of them into `config`, so `GET /admin/config` listed them while
|
||||||
|
// `PATCH /admin/config` answered "Unbekannter Konfigurationsschlüssel" — the same
|
||||||
|
// dead-key defect the comment under BOOL_KEYS says was fixed for the two login
|
||||||
|
// toggles. These are precisely the knobs an operator reaches for while abuse is
|
||||||
|
// happening, which is the one moment a restart to change them is unaffordable.
|
||||||
|
("upload_edit_rate_per_min", true, 1.0, 100_000.0),
|
||||||
|
("recover_name_rate_per_15min", true, 1.0, 100_000.0),
|
||||||
|
("pin_reset_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||||
];
|
];
|
||||||
const BOOL_KEYS: &[&str] = &[
|
const BOOL_KEYS: &[&str] = &[
|
||||||
"rate_limits_enabled",
|
"rate_limits_enabled",
|
||||||
@@ -145,6 +156,9 @@ pub async fn patch_config(
|
|||||||
"admin_login_rate_enabled",
|
"admin_login_rate_enabled",
|
||||||
"recover_rate_enabled",
|
"recover_rate_enabled",
|
||||||
"social_rate_enabled",
|
"social_rate_enabled",
|
||||||
|
// Read by `upload::edit_upload`, inserted by migration 025, and until now unreachable
|
||||||
|
// from this endpoint — see the note in NUMERIC_SPECS.
|
||||||
|
"upload_edit_rate_enabled",
|
||||||
"quota_enabled",
|
"quota_enabled",
|
||||||
"storage_quota_enabled",
|
"storage_quota_enabled",
|
||||||
"upload_count_quota_enabled",
|
"upload_count_quota_enabled",
|
||||||
@@ -275,6 +289,23 @@ pub async fn patch_config(
|
|||||||
// the TTL is only a backstop and must not be relied on for correctness.
|
// the TTL is only a backstop and must not be relied on for correctness.
|
||||||
state.config_cache.invalidate();
|
state.config_cache.invalidate();
|
||||||
|
|
||||||
|
// Config changes were logged NOWHERE. They are the actions most likely to be blamed the
|
||||||
|
// morning after ("why did uploads stop?") and the hardest to reconstruct, because the value
|
||||||
|
// that caused the problem has since been changed back. Record the keys and their new values;
|
||||||
|
// these are operational settings, not credentials, so the payload is safe to keep.
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"patch_config",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
serde_json::to_value(&body).ok(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// 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 || theme_changed {
|
if privacy_note_changed || theme_changed {
|
||||||
@@ -333,27 +364,142 @@ pub struct DownloadQuery {
|
|||||||
/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
|
/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
|
||||||
/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
|
/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
|
||||||
/// carry an `Authorization` header, so the client exchanges its Bearer token for
|
/// carry an `Authorization` header, so the client exchanges its Bearer token for
|
||||||
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
|
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Uses the same store as the SSE
|
||||||
/// single-use, 30s-TTL store as the SSE stream.
|
/// stream, but NOT the same lifetime: a download ticket lives `DOWNLOAD_TTL` (6 h) and is
|
||||||
|
/// redeemable up to `MAX_DOWNLOAD_REDEMPTIONS` times, because a multi-GB transfer over venue wifi
|
||||||
|
/// has to survive being resumed with `Range`.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct ExportTicketQuery {
|
||||||
|
/// Which archive the ticket is for — `zip` or `html`.
|
||||||
|
///
|
||||||
|
/// REQUIRED. It used to be optional "so an older client keeps working", but the ticket is now
|
||||||
|
/// bound to the archive it was minted for (see `TicketKind::Download`), and a ticket with no
|
||||||
|
/// archive would either have to be valid for both — the abuse this closes — or be issued for a
|
||||||
|
/// guess that 401s at the other endpoint. Every shipped client sends it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn export_ticket(
|
pub async fn export_ticket(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
|
||||||
auth: crate::auth::middleware::AuthUser,
|
auth: crate::auth::middleware::AuthUser,
|
||||||
) -> Json<serde_json::Value> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
|
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
|
||||||
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
|
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
|
||||||
// released — Spec design choice"). The export is read-only, so it stays available
|
// released — Spec design choice"). The export is read-only, so it stays available
|
||||||
// to them, consistent with the read-only-ban model.
|
// to them, consistent with the read-only-ban model.
|
||||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
|
||||||
Json(serde_json::json!({ "ticket": ticket }))
|
// The rate limit is enforced HERE rather than on the download itself, and that placement is
|
||||||
|
// the whole point: the download is an iframe navigation, so its response is invisible to the
|
||||||
|
// page. Limiting it there meant a guest over the limit tapped "Herunterladen", the ticket
|
||||||
|
// POST returned 200, the iframe silently received a 429, and absolutely nothing happened —
|
||||||
|
// forever, with no explanation, on the one screen that is the emotional payoff of the app.
|
||||||
|
// Minting is a normal `fetch`, so a 429 here reaches the user as a German message.
|
||||||
|
//
|
||||||
|
// Moving it does not weaken the limit meaningfully: a ticket can only be obtained from this
|
||||||
|
// authenticated endpoint, is bound to one archive, and — since downloads must be resumable —
|
||||||
|
// is worth at most `MAX_DOWNLOAD_REDEMPTIONS` transfers rather than exactly one. The daily
|
||||||
|
// limit is therefore a bound on mints, not on bytes; see `MAX_DOWNLOAD_REDEMPTIONS` for why
|
||||||
|
// charging per redemption would re-break resumption.
|
||||||
|
// Confirm the archive actually EXISTS before spending anything on it.
|
||||||
|
//
|
||||||
|
// `export_status` — which is what enables the Download button — reports `done` from
|
||||||
|
// `export_job`, while the download resolves through `export_current.file_path` plus a
|
||||||
|
// `Path::exists()`. Those are different sources of truth and can legitimately disagree: a
|
||||||
|
// row can say done while the file is gone, or an epoch bump can retire it between the page
|
||||||
|
// rendering and the guest tapping. When they disagreed the guest got the worst possible
|
||||||
|
// shape of failure — a green "Download gestartet" toast, a consumed single-use ticket, one
|
||||||
|
// of only three daily slots spent, and nothing in their Downloads folder, repeatable until
|
||||||
|
// the day's allowance was gone.
|
||||||
|
//
|
||||||
|
// Checking here, before `enforce_export_rate`, turns that into an honest error on a plain
|
||||||
|
// `fetch` that the existing `toastError` path already renders. This is NOT the HEAD probe
|
||||||
|
// ruled out elsewhere: it reads the same indexed row the download will read and touches no
|
||||||
|
// ticket, so it cannot consume anything.
|
||||||
|
let export_kind = match q.kind.as_deref() {
|
||||||
|
Some("zip") => crate::services::sse_tickets::ExportKind::Zip,
|
||||||
|
Some("html") => crate::services::sse_tickets::ExportKind::Html,
|
||||||
|
Some(other) => {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Unbekannter Export-Typ: {other}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Es fehlt die Angabe, welches Archiv geladen werden soll. Bitte lade die Seite \
|
||||||
|
neu und versuche es erneut."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let export_type = match export_kind {
|
||||||
|
crate::services::sse_tickets::ExportKind::Zip => "zip",
|
||||||
|
crate::services::sse_tickets::ExportKind::Html => "html",
|
||||||
|
};
|
||||||
|
let msg = if export_type == "zip" {
|
||||||
|
"Der ZIP-Export ist noch nicht verfügbar."
|
||||||
|
} else {
|
||||||
|
"Der HTML-Export ist noch nicht verfügbar."
|
||||||
|
};
|
||||||
|
resolve_export_file(&state, export_type, msg).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `issue` returns None when the ticket store is at capacity. Unwrapping it into the JSON body
|
||||||
|
// serialized `{"ticket": null}` with a 200 — so `api.post` resolved happily, the page toasted
|
||||||
|
// success, and the iframe navigated to `?ticket=null`. 503 + Retry-After, matching how
|
||||||
|
// `sse::issue_ticket` answers the identical condition.
|
||||||
|
//
|
||||||
|
// Minted BEFORE the rate limit is charged. Charging first meant a store-capacity 503 — a
|
||||||
|
// server-side condition the guest did nothing to cause and cannot see — still cost one of
|
||||||
|
// their three DAILY downloads. There is no refund path, so the only fix is not to charge until
|
||||||
|
// the thing being charged for actually exists.
|
||||||
|
let ticket = state
|
||||||
|
.sse_tickets
|
||||||
|
.issue(auth.token_hash, TicketKind::Download(export_kind))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::ServiceUnavailable(
|
||||||
|
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
|
||||||
|
Some(30),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// A refused mint must not leave its ticket behind. The per-session cap is FOUR tickets of the
|
||||||
|
// same kind, and a download ticket now lives six hours instead of being consumed on first use —
|
||||||
|
// so every abandoned one occupies a slot until it expires. A guest whose 1.4 GB transfer looks
|
||||||
|
// stuck and who taps "Herunterladen" a few more times spends mints 1-3 legitimately, then gets
|
||||||
|
// a 429 on taps 4 and 5 — but both still minted, and the fifth evicted the OLDEST download
|
||||||
|
// ticket for the session: the one the running transfer is holding. The next `Range` resume then
|
||||||
|
// 401s, and re-minting is impossible because they are at the daily limit. The keepsake is gone
|
||||||
|
// until tomorrow, having done nothing worse than tapping a button that appeared to do nothing.
|
||||||
|
//
|
||||||
|
// Discarding here keeps both properties that put the mint first: a store-capacity 503 still
|
||||||
|
// costs no download, and a refused download costs no slot.
|
||||||
|
if let Err(e) = enforce_export_rate(&state, auth.user_id).await {
|
||||||
|
let _ = state
|
||||||
|
.sse_tickets
|
||||||
|
.consume(&ticket, TicketKind::Download(export_kind));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ticket": ticket })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a download ticket (single-use) and confirm its session still exists.
|
/// Validate a download ticket and confirm its session still exists, resolving it to the user who
|
||||||
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
|
/// minted it. Deliberately NOT single-use — see the note on `redeem_download` below.
|
||||||
/// id to key the export rate limit per-user (see `enforce_export_rate`).
|
async fn authenticate_download_ticket(
|
||||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
|
state: &AppState,
|
||||||
|
ticket: &str,
|
||||||
|
want: crate::services::sse_tickets::ExportKind,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
// Non-consuming: a keepsake download must survive being resumed with `Range`, and a
|
||||||
|
// single-use ticket meant the resume 401'd and cost the guest another of their three daily
|
||||||
|
// downloads. `redeem_download` bounds it by DOWNLOAD_TTL instead, and the session check
|
||||||
|
// below still runs on every request.
|
||||||
let token_hash = state
|
let token_hash = state
|
||||||
.sse_tickets
|
.sse_tickets
|
||||||
.consume(ticket)
|
.redeem_download(ticket, want)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||||
let session = 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
|
||||||
@@ -364,14 +510,32 @@ async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<
|
|||||||
|
|
||||||
pub async fn download_zip(
|
pub async fn download_zip(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Query(q): Query<DownloadQuery>,
|
Query(q): Query<DownloadQuery>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
// Ticket validation only — the rate limit was charged at mint time, where a 429 is visible
|
||||||
enforce_export_rate(&state, user_id).await?;
|
// to the page. Charging it again here would cost every download two slots.
|
||||||
|
authenticate_download_ticket(
|
||||||
|
&state,
|
||||||
|
&q.ticket,
|
||||||
|
crate::services::sse_tickets::ExportKind::Zip,
|
||||||
|
)
|
||||||
|
.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?;
|
||||||
serve_file(path, "Gallery.zip", "application/zip").await
|
serve_file(
|
||||||
|
path,
|
||||||
|
"Gallery.zip",
|
||||||
|
"application/zip",
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::RANGE)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::IF_RANGE)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
|
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
|
||||||
@@ -417,45 +581,130 @@ async fn resolve_export_file(
|
|||||||
|
|
||||||
pub async fn download_html(
|
pub async fn download_html(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Query(q): Query<DownloadQuery>,
|
Query(q): Query<DownloadQuery>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
// See `download_zip`: the limit is charged at ticket mint, where the client can see it.
|
||||||
enforce_export_rate(&state, user_id).await?;
|
authenticate_download_ticket(
|
||||||
|
&state,
|
||||||
|
&q.ticket,
|
||||||
|
crate::services::sse_tickets::ExportKind::Html,
|
||||||
|
)
|
||||||
|
.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?;
|
||||||
serve_file(path, "Memories.zip", "application/zip").await
|
serve_file(
|
||||||
|
path,
|
||||||
|
"Memories.zip",
|
||||||
|
"application/zip",
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::RANGE)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::IF_RANGE)
|
||||||
|
.and_then(|v| v.to_str().ok()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream a keepsake archive, honouring `Range`.
|
||||||
|
///
|
||||||
|
/// Range support is not a nicety here. The keepsake is the emotional payoff of the product and can
|
||||||
|
/// be ~1.4 GB; without `Accept-Ranges` a download that dies at 90% over hotel wifi restarts at byte
|
||||||
|
/// zero. Worse, the 3/day limit is charged when the download TICKET is minted and ZIP+HTML already
|
||||||
|
/// costs 2 — so one dropped connection locked a guest out of their own wedding photos for ~24h.
|
||||||
|
///
|
||||||
|
/// Reuses `upload::parse_range`, which already implements exactly the forms a client sends and is
|
||||||
|
/// unit-tested there. The media routes have always done this correctly; this route was the outlier.
|
||||||
async fn serve_file(
|
async fn serve_file(
|
||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
|
range_header: Option<&str>,
|
||||||
|
if_range_header: Option<&str>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
use crate::handlers::upload::{RangeSpec, parse_range};
|
||||||
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;
|
||||||
|
|
||||||
let file = tokio::fs::File::open(&path)
|
let mut file = tokio::fs::File::open(&path)
|
||||||
.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();
|
||||||
|
|
||||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||||
|
|
||||||
let response = Response::builder()
|
// A validator that CHANGES when the archive does, so a resume cannot splice two generations.
|
||||||
.status(StatusCode::OK)
|
//
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
// The on-disk name is `{prefix}.{event_id}.{epoch}.zip`, so it already identifies the exact
|
||||||
.header(header::CONTENT_DISPOSITION, disposition)
|
// generation; length distinguishes a rebuild at the same epoch. Together they are a strong
|
||||||
.header(header::CONTENT_LENGTH, metadata.len())
|
// validator.
|
||||||
.body(Body::from_stream(stream))
|
//
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
// Why this matters: `resolve_export_file` re-reads `export_current` on EVERY request, and a
|
||||||
|
// download ticket outlives several redemptions. So a guest whose 500 MB download drops at
|
||||||
|
// 500 MB, while the host takes a photo down (epoch bumps, rebuild lands, the old generation is
|
||||||
|
// pruned), used to resume with `Range: bytes=500000000-` against a DIFFERENT FILE of a
|
||||||
|
// different length — and the server would happily seek 500 MB into it and stream. The client
|
||||||
|
// concatenated the two halves into a structurally corrupt ZIP, with nothing logged anywhere.
|
||||||
|
let etag = format!(
|
||||||
|
"\"{}-{len}\"",
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(filename)
|
||||||
|
);
|
||||||
|
|
||||||
Ok(response)
|
let base = |status: StatusCode| {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::CONTENT_DISPOSITION, disposition.clone())
|
||||||
|
// Advertised on EVERY response, including the 200. A client only knows it may resume
|
||||||
|
// if the first (unranged) response says so.
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
.header(header::ETAG, etag.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serve a partial ONLY when the client proves it is resuming the same bytes.
|
||||||
|
//
|
||||||
|
// `If-Range` matching our ETag is that proof. A client that sends `Range` with no `If-Range`
|
||||||
|
// at all (curl -C -, wget -c, most download managers) cannot be given a partial safely — it
|
||||||
|
// has no way to notice the archive changed underneath it — so it gets a 200 and starts over.
|
||||||
|
// Restarting a download is a cost; a silently corrupt keepsake is not recoverable. Browsers
|
||||||
|
// send `If-Range`, so the ordinary resume path is unaffected, and this is the first release
|
||||||
|
// where their resume works at all: without a validator they simply refused to try.
|
||||||
|
let resume_is_safe = if_range_header.is_some_and(|v| v.trim() == etag);
|
||||||
|
let effective_range = if resume_is_safe { range_header } else { None };
|
||||||
|
|
||||||
|
match parse_range(effective_range, len) {
|
||||||
|
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())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Also expose export status to all authenticated users (guests need it for the export page)
|
/// Also expose export status to all authenticated users (guests need it for the export page)
|
||||||
@@ -536,8 +785,13 @@ async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppE
|
|||||||
limit,
|
limit,
|
||||||
Duration::from_secs(86400),
|
Duration::from_secs(86400),
|
||||||
) {
|
) {
|
||||||
|
// Names the real window. The generic "warte kurz" wording this used to share with the
|
||||||
|
// per-minute limiters is actively wrong here — the bucket is a DAY, so a guest told to
|
||||||
|
// wait a moment would keep tapping a button that cannot work again until tomorrow.
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Du hast das Tageslimit für Downloads erreicht. Versuch es später noch einmal — \
|
||||||
|
deine Galerie bleibt gespeichert."
|
||||||
|
.into(),
|
||||||
Some(retry_after_secs),
|
Some(retry_after_secs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,43 @@ use crate::state::AppState;
|
|||||||
pub struct FeedQuery {
|
pub struct FeedQuery {
|
||||||
pub cursor: Option<Uuid>,
|
pub cursor: Option<Uuid>,
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
|
/// Single tag (list view). Kept alongside `hashtags` so existing callers keep working.
|
||||||
pub hashtag: Option<String>,
|
pub hashtag: Option<String>,
|
||||||
|
/// Comma-separated tags, combined with **OR** — the grid's chip semantics
|
||||||
|
/// (USER_JOURNEYS §8). Filtering moved server-side because the client could only ever
|
||||||
|
/// filter the pages it had already loaded: with page 1 = 20 items out of a 1000-photo
|
||||||
|
/// event, selecting a tag showed a handful of tiles and looked complete. Matching is
|
||||||
|
/// now the exact `hashtag` row in both views, so the grid and the list can no longer
|
||||||
|
/// disagree about which photos carry a tag (the client matched a caption SUBSTRING, so
|
||||||
|
/// `#tanz` also matched `#tanzflaeche`).
|
||||||
|
pub hashtags: Option<String>,
|
||||||
|
/// Exact uploader display name, combined with the tag group using **AND**.
|
||||||
|
pub uploader: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge the single-tag and CSV tag params into one normalised, de-duplicated list.
|
||||||
|
///
|
||||||
|
/// Normalisation mirrors `Hashtag::upsert` exactly (trim, drop a leading `#`, lowercase), so
|
||||||
|
/// a chip built from a display string like `#Tanz` matches the stored `tanz` row. Returns
|
||||||
|
/// `None` when no usable tag was supplied, which makes the SQL predicate a no-op — an empty
|
||||||
|
/// list must mean "no tag filter", never "match nothing".
|
||||||
|
fn normalize_tags(single: Option<&str>, csv: Option<&str>) -> Option<Vec<String>> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut push = |raw: &str| {
|
||||||
|
let t = raw.trim().trim_start_matches('#').to_lowercase();
|
||||||
|
if !t.is_empty() && !out.contains(&t) {
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(s) = single {
|
||||||
|
push(s);
|
||||||
|
}
|
||||||
|
if let Some(s) = csv {
|
||||||
|
for part in s.split(',') {
|
||||||
|
push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() { None } else { Some(out) }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -80,7 +116,9 @@ pub async fn feed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = q.limit.unwrap_or(20).min(100);
|
// Clamped at BOTH ends: only the upper bound was enforced, so `?limit=-5` reached Postgres
|
||||||
|
// as `LIMIT -4` and answered a hand-written URL with a 500.
|
||||||
|
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
// Resolve the cursor to a (created_at, id) position. The pair is compared as a
|
// Resolve the cursor to a (created_at, id) position. The pair is compared as a
|
||||||
// tuple so ties on created_at break on id — keyset pagination on created_at
|
// tuple so ties on created_at break on id — keyset pagination on created_at
|
||||||
@@ -93,44 +131,42 @@ pub async fn feed(
|
|||||||
None => (None, None),
|
None => (None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows = if let Some(hashtag) = &q.hashtag {
|
// Tags from either param, normalised the same way `Hashtag::upsert` stores them
|
||||||
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
|
// (trimmed, leading `#` dropped, lowercased) so the comparison is exact.
|
||||||
sqlx::query_as::<_, FeedRow>(
|
let tags = normalize_tags(q.hashtag.as_deref(), q.hashtags.as_deref());
|
||||||
|
let uploader = q
|
||||||
|
.uploader
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
// ONE statement for every combination, rather than a branch per filter. `EXISTS` with
|
||||||
|
// `= ANY($4)` gives OR across the tag group without the row multiplication a JOIN would
|
||||||
|
// cause when a photo carries two selected tags; the uploader predicate ANDs on top. Both
|
||||||
|
// are no-ops when NULL, so the unfiltered feed takes the same path.
|
||||||
|
let rows = 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.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
|
v.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
|
||||||
v.created_at
|
v.created_at
|
||||||
FROM v_feed v
|
FROM v_feed v
|
||||||
JOIN upload_hashtag uh ON uh.upload_id = v.id
|
WHERE v.event_id = $1
|
||||||
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
|
AND ($2::timestamptz IS NULL OR (v.created_at, v.id) < ($2, $3))
|
||||||
WHERE v.event_id = $2
|
AND ($4::text[] IS NULL OR EXISTS (
|
||||||
AND ($3::timestamptz IS NULL OR (v.created_at, v.id) < ($3, $4))
|
SELECT 1 FROM upload_hashtag uh
|
||||||
|
JOIN hashtag h ON h.id = uh.hashtag_id
|
||||||
|
WHERE uh.upload_id = v.id AND h.tag = ANY($4)))
|
||||||
|
AND ($5::text IS NULL OR v.uploader_name = $5)
|
||||||
ORDER BY v.created_at DESC, v.id DESC
|
ORDER BY v.created_at DESC, v.id DESC
|
||||||
LIMIT $5",
|
LIMIT $6",
|
||||||
)
|
|
||||||
.bind(&tag)
|
|
||||||
.bind(auth.event_id)
|
|
||||||
.bind(cursor_time)
|
|
||||||
.bind(cursor_id)
|
|
||||||
.bind(limit + 1)
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, FeedRow>(
|
|
||||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
|
||||||
display_path, mime_type, caption, like_count, comment_count, created_at
|
|
||||||
FROM v_feed
|
|
||||||
WHERE event_id = $1
|
|
||||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
|
||||||
ORDER BY created_at DESC, id DESC
|
|
||||||
LIMIT $4",
|
|
||||||
)
|
)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.bind(cursor_time)
|
.bind(cursor_time)
|
||||||
.bind(cursor_id)
|
.bind(cursor_id)
|
||||||
|
.bind(tags.as_deref())
|
||||||
|
.bind(uploader)
|
||||||
.bind(limit + 1)
|
.bind(limit + 1)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?
|
.await?;
|
||||||
};
|
|
||||||
|
|
||||||
let has_more = rows.len() as i64 > limit;
|
let has_more = rows.len() as i64 > limit;
|
||||||
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
|
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
|
||||||
@@ -357,6 +393,32 @@ pub async fn hashtags(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every uploader who has at least one visible upload, for the grid's "Nutzer suchen" picker.
|
||||||
|
///
|
||||||
|
/// The picker used to derive names from the uploads currently in memory — page 1, 20 items —
|
||||||
|
/// so typing a guest's name found nothing whenever their photos happened to sit below the
|
||||||
|
/// fold, which reads as "search is broken". This is the authoritative list.
|
||||||
|
///
|
||||||
|
/// Reads `v_feed`, so it inherits exactly the feed's visibility rules: soft-deleted uploads,
|
||||||
|
/// banned uploaders and hidden uploaders are all excluded, and a guest who has not uploaded
|
||||||
|
/// anything never appears. Uncapped on purpose — one short string per uploader, bounded by
|
||||||
|
/// the guest count, and truncating it would reintroduce the very bug this replaces.
|
||||||
|
/// Deliberately NOT the host-only `/host/users` route: that one lists every joined guest and
|
||||||
|
/// exposes moderation state.
|
||||||
|
pub async fn uploaders(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<Vec<String>>, AppError> {
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT DISTINCT uploader_name FROM v_feed WHERE event_id = $1 ORDER BY uploader_name",
|
||||||
|
)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows.into_iter().map(|(name,)| name).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a cursor id to its `(created_at, id)` position. Both are needed:
|
/// Resolve a cursor id to its `(created_at, id)` position. Both are needed:
|
||||||
/// `created_at` alone isn't unique, so pagination must break ties on `id` to
|
/// `created_at` alone isn't unique, so pagination must break ties on `id` to
|
||||||
/// avoid silently dropping rows that share a timestamp across a page boundary.
|
/// avoid silently dropping rows that share a timestamp across a page boundary.
|
||||||
@@ -388,3 +450,41 @@ async fn get_liked_set(
|
|||||||
|
|
||||||
rows.into_iter().map(|r| r.0).collect()
|
rows.into_iter().map(|r| r.0).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::normalize_tags;
|
||||||
|
|
||||||
|
/// The chips carry display strings (`#Tanz`), the `hashtag` table stores `tanz`. If these
|
||||||
|
/// two drift the filter silently returns nothing, which is indistinguishable from "no
|
||||||
|
/// photos have this tag" — so pin the normalisation to `Hashtag::upsert`'s rule.
|
||||||
|
#[test]
|
||||||
|
fn tags_are_normalised_like_upsert_stores_them() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_tags(Some("#Tanz"), None),
|
||||||
|
Some(vec!["tanz".to_string()])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_tags(None, Some(" #Buffet , reden ")),
|
||||||
|
Some(vec!["buffet".to_string(), "reden".to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty list must mean "no filter", never "match nothing" — returning `Some(vec![])`
|
||||||
|
/// would make `= ANY('{}')` false for every row and blank the feed.
|
||||||
|
#[test]
|
||||||
|
fn blank_input_disables_the_filter() {
|
||||||
|
assert_eq!(normalize_tags(None, None), None);
|
||||||
|
assert_eq!(normalize_tags(Some(" "), Some(" , ,#")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both params feed one list, de-duplicated: the list view sends `hashtag`, the grid sends
|
||||||
|
/// `hashtags`, and carrying a filter across views can legitimately set both to the same tag.
|
||||||
|
#[test]
|
||||||
|
fn single_and_csv_merge_without_duplicates() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_tags(Some("tanz"), Some("tanz,buffet")),
|
||||||
|
Some(vec!["tanz".to_string(), "buffet".to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ pub struct EventStatus {
|
|||||||
pub disk_low: bool,
|
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?
|
/// Is free space low enough that the host needs to know?
|
||||||
///
|
///
|
||||||
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
|
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
|
||||||
@@ -59,15 +55,47 @@ const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
|||||||
/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they
|
/// 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
|
/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end
|
||||||
/// into a decision someone can still make.
|
/// into a decision someone can still make.
|
||||||
|
///
|
||||||
|
/// IT MUST FIRE BEFORE THE UPLOAD GATE CLOSES, and that is why the reserve and the margin are
|
||||||
|
/// here. The gate in `handlers::upload` refuses at
|
||||||
|
/// `free < keepsake_required + DISK_RESERVE_BYTES + UPLOAD_GATE_HEADROOM_BYTES`;
|
||||||
|
/// warning at `free < keepsake_required` alone meant the two differed by the whole reserve, so
|
||||||
|
/// the wall was always hit FIRST. Every guest would be blocked from uploading while this
|
||||||
|
/// dashboard showed a comfortable disk and no banner at all — on the shipped 40 GB box, uploads
|
||||||
|
/// stopping with ~27 GB free and nothing on screen to explain it, with no operator present.
|
||||||
|
///
|
||||||
|
/// The 25% margin makes it a warning rather than an obituary: the host sees it while there is
|
||||||
|
/// still room to act (delete a few large videos, which refunds immediately and reopens the gate).
|
||||||
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
||||||
free < LOW_DISK_FLOOR_BYTES || free < keepsake_required
|
// Mirrors the gate EXACTLY, headroom included. The gate now demands
|
||||||
|
// `UPLOAD_GATE_HEADROOM_BYTES` more than the export preflight does, so that ordinary
|
||||||
|
// end-of-night writes cannot flip the preflight after uploads have already stopped. Leaving
|
||||||
|
// that term out here would shrink the warning's lead by 1.5 GB — and the whole point of this
|
||||||
|
// function is that the banner must appear while the host can still act.
|
||||||
|
let gate_closes_at = keepsake_required
|
||||||
|
.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64)
|
||||||
|
.saturating_add(crate::handlers::upload::UPLOAD_GATE_HEADROOM_BYTES as u64);
|
||||||
|
let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4);
|
||||||
|
// No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here,
|
||||||
|
// and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at
|
||||||
|
// least 1.25x it (12.5 GB) — always above the 10 GB floor. Two tests were named after that
|
||||||
|
// clause and neither could fail if it were deleted. Keeping dead code that tests claim to
|
||||||
|
// cover is worse than not having it.
|
||||||
|
free < warn_at
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
|
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
|
||||||
/// always keeps at least one operator" floor.
|
/// always keeps at least one operator" floor.
|
||||||
|
///
|
||||||
|
/// Takes a CONNECTION, not the pool, and every caller passes the same transaction it is about to
|
||||||
|
/// write in — after taking [`lock_operator_floor`]. Read on the pool beforehand, this count was a
|
||||||
|
/// snapshot that any concurrent operator-removing action could invalidate before the UPDATE landed:
|
||||||
|
/// an admin demoting host B while host A calls `DELETE /me` saw two independent checks each observe
|
||||||
|
/// the other still present, both commit, and the event end up with zero operators — which is not
|
||||||
|
/// recoverable from inside the app, since appointing an operator requires being one.
|
||||||
async fn remaining_operators(
|
async fn remaining_operators(
|
||||||
state: &AppState,
|
conn: &mut sqlx::PgConnection,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
excluding: Uuid,
|
excluding: Uuid,
|
||||||
) -> Result<i64, AppError> {
|
) -> Result<i64, AppError> {
|
||||||
@@ -78,11 +106,36 @@ async fn remaining_operators(
|
|||||||
)
|
)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.bind(excluding)
|
.bind(excluding)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(conn)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialise every action that can remove an operator from an event.
|
||||||
|
///
|
||||||
|
/// The same key `me::delete_account` takes — namespace 4242, `hashtext(event_id)` — and it MUST
|
||||||
|
/// stay identical, or the two families of caller lock against nothing. An advisory lock is used
|
||||||
|
/// rather than a row lock because it is a separate lock space and so cannot join the
|
||||||
|
/// `event`/`user` row-lock graph that moderation traffic already traverses in both directions;
|
||||||
|
/// it is released automatically when the transaction ends.
|
||||||
|
///
|
||||||
|
/// **Call this FIRST in the transaction, before taking any row lock.** Being a separate lock space
|
||||||
|
/// means it cannot form a cycle *with itself*, not that ordering is free: all three callers go on
|
||||||
|
/// to lock `user` and `event` rows, so a caller that took those rows first and reached for this
|
||||||
|
/// lock afterwards would deadlock against one that did it the other way round. Postgres would
|
||||||
|
/// break the tie by killing one transaction with a 500. Every caller acquires it first; keep it
|
||||||
|
/// that way.
|
||||||
|
pub(crate) async fn lock_operator_floor(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
event_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
|
||||||
|
.bind(event_id)
|
||||||
|
.execute(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetRoleRequest {
|
pub struct SetRoleRequest {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
@@ -179,14 +232,6 @@ pub async fn ban_user(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floor: never leave the event with zero operators. Banning removes the target from
|
|
||||||
// the active-operator pool, so refuse if they're the last non-banned host/admin.
|
|
||||||
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Der letzte Host kann nicht gesperrt werden.".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
|
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
|
||||||
// views/queries now also filter on `is_banned` (defense in depth), and we set
|
// views/queries now also filter on `is_banned` (defense in depth), and we set
|
||||||
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
|
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
|
||||||
@@ -201,6 +246,21 @@ pub async fn ban_user(
|
|||||||
//
|
//
|
||||||
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
|
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
|
// Floor: never leave the event with zero operators. Banning removes the target from the
|
||||||
|
// active-operator pool, so refuse if they're the last non-banned host/admin.
|
||||||
|
//
|
||||||
|
// INSIDE the transaction and behind the operator lock — see `remaining_operators`. Checked on
|
||||||
|
// the pool beforehand, this raced `set_role` and `DELETE /me` into an event with no operator.
|
||||||
|
if target.0 == "host" {
|
||||||
|
lock_operator_floor(&mut tx, auth.event_id).await?;
|
||||||
|
if remaining_operators(&mut tx, auth.event_id, user_id).await? == 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Der letzte Host kann nicht gesperrt werden.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
||||||
@@ -242,6 +302,19 @@ pub async fn ban_user(
|
|||||||
"host: ban_user"
|
"host: ban_user"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"ban_user",
|
||||||
|
Some(user_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +373,40 @@ pub async fn unban_user(
|
|||||||
start_regen(&state, r);
|
start_regen(&state, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The exact mirror of `ban_user`'s `user-hidden`, and it was missing entirely: every open
|
||||||
|
// feed and the unattended projector kept the guest evicted until somebody reloaded the page
|
||||||
|
// by hand. Meanwhile the host's own confirm copy promises the photos "come back to the
|
||||||
|
// gallery, die Diashow und den Export" — so the one surface that would have shown the host
|
||||||
|
// their action had worked showed the opposite.
|
||||||
|
//
|
||||||
|
// Also the signal a banned guest's upload queue waits on: their queued photos parked with
|
||||||
|
// the blob intact rather than being purged (see `AppError::UserBanned`), and this is what
|
||||||
|
// releases them.
|
||||||
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
|
"user-shown",
|
||||||
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
target_user_id = %user_id,
|
target_user_id = %user_id,
|
||||||
event_id = %auth.event_id,
|
event_id = %auth.event_id,
|
||||||
"host: unban_user"
|
"host: unban_user"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"unban_user",
|
||||||
|
Some(user_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,21 +507,26 @@ pub async fn set_role(
|
|||||||
|
|
||||||
// Floor: demoting the last non-banned host/admin to guest would leave the event with
|
// Floor: demoting the last non-banned host/admin to guest would leave the event with
|
||||||
// no operator. Refuse.
|
// no operator. Refuse.
|
||||||
if new_role == "guest"
|
//
|
||||||
&& target.0 == "host"
|
// The check and the UPDATE are ONE transaction, behind the operator lock — see
|
||||||
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
|
// `remaining_operators`. Split apart on the pool, this raced `ban_user` and `DELETE /me`.
|
||||||
{
|
let mut tx = state.pool.begin().await?;
|
||||||
|
if new_role == "guest" && target.0 == "host" {
|
||||||
|
lock_operator_floor(&mut tx, auth.event_id).await?;
|
||||||
|
if remaining_operators(&mut tx, auth.event_id, user_id).await? == 0 {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
|
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(new_role)
|
.bind(new_role)
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
target_user_id = %user_id,
|
target_user_id = %user_id,
|
||||||
@@ -429,6 +535,19 @@ pub async fn set_role(
|
|||||||
new_role,
|
new_role,
|
||||||
"host: set_role"
|
"host: set_role"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"set_role",
|
||||||
|
Some(user_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +640,19 @@ pub async fn reset_user_pin(
|
|||||||
"host: reset_user_pin"
|
"host: reset_user_pin"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"reset_pin",
|
||||||
|
Some(user_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(PinResetResponse { pin }))
|
Ok(Json(PinResetResponse { pin }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,8 +729,13 @@ pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRege
|
|||||||
state.config.comments_enabled,
|
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.
|
||||||
crate::services::export::REGEN_DEBOUNCE,
|
//
|
||||||
|
// Measured from the START of the burst, not from this request — a fixed per-request delay
|
||||||
|
// meant a steady stream of invalidations faster than one per 20s deferred the build
|
||||||
|
// forever, leaving the keepsake permanently 404 and the UI stuck on "Wird vorbereitet…".
|
||||||
|
// See export::regen_delay_for.
|
||||||
|
crate::services::export::regen_delay_for(regen.event_id),
|
||||||
state.pool.clone(),
|
state.pool.clone(),
|
||||||
state.config.media_path.clone(),
|
state.config.media_path.clone(),
|
||||||
state.config.export_path.clone(),
|
state.config.export_path.clone(),
|
||||||
@@ -619,7 +756,9 @@ pub async fn host_delete_upload(
|
|||||||
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
|
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
|
||||||
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
|
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
// `by_host: true` — the takedown holds the uploader's idempotency key so a late retry from
|
||||||
|
// their queue cannot resurrect the photo. See migration 031.
|
||||||
|
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id, true).await?;
|
||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
@@ -646,6 +785,19 @@ pub async fn host_delete_upload(
|
|||||||
"host: host_delete_upload"
|
"host: host_delete_upload"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"delete_upload",
|
||||||
|
Some(upload_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,12 +835,25 @@ pub async fn host_delete_comment(
|
|||||||
comment_id = %comment_id,
|
comment_id = %comment_id,
|
||||||
"host: host_delete_comment"
|
"host: host_delete_comment"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"delete_comment",
|
||||||
|
Some(comment_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn close_event(
|
pub async fn close_event(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
||||||
@@ -703,12 +868,27 @@ pub async fn close_event(
|
|||||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
|
||||||
|
// the record the morning after had no way to see when uploads were locked or the
|
||||||
|
// gallery released, which are the two actions that change what every guest can do.
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"lock_uploads",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_event(
|
pub async fn open_event(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
||||||
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
||||||
@@ -734,12 +914,27 @@ pub async fn open_event(
|
|||||||
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
|
||||||
|
// the record the morning after had no way to see when uploads were locked or the
|
||||||
|
// gallery released, which are the two actions that change what every guest can do.
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"unlock_uploads",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn release_gallery(
|
pub async fn release_gallery(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
|
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
|
||||||
// transaction. Two reasons, both of which were live bugs:
|
// transaction. Two reasons, both of which were live bugs:
|
||||||
@@ -795,6 +990,20 @@ pub async fn release_gallery(
|
|||||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||||
|
|
||||||
// Detached — survives this handler being cancelled.
|
// Detached — survives this handler being cancelled.
|
||||||
|
//
|
||||||
|
// SPAWNED IMMEDIATELY AFTER THE COMMIT, BEFORE ANY OTHER `.await`. Every `invalidate_and_arm`
|
||||||
|
// call site does this; `me::delete_account` carries the same note. The audit write below used
|
||||||
|
// to sit here, and it is two pool round-trips that can each wait up to the 5 s acquire timeout
|
||||||
|
// — right at the moment `event-closed` has just fanned out to ~100 phones whose queues all hit
|
||||||
|
// the API at once, so the pool is as contended as it ever gets. Drop the handler future during
|
||||||
|
// that suspension (the host's phone sleeps, the tab closes, Caddy times the request out) and
|
||||||
|
// the task never spawns: the event is released, uploads are locked, both `export_job` rows sit
|
||||||
|
// `pending` at the live epoch, and no worker exists. `/export/*` 404s, the page sits on "Wird
|
||||||
|
// vorbereitet…", `recover_exports` only runs at boot, and `release_gallery` refuses a retry
|
||||||
|
// because the gallery is already released.
|
||||||
|
//
|
||||||
|
// This is the one path that arms the FIRST build of the keepsake, so it is the worst possible
|
||||||
|
// place to reintroduce that window.
|
||||||
crate::services::export::spawn_export_jobs(
|
crate::services::export::spawn_export_jobs(
|
||||||
event_id,
|
event_id,
|
||||||
event_name,
|
event_name,
|
||||||
@@ -807,28 +1016,131 @@ pub async fn release_gallery(
|
|||||||
state.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
|
||||||
|
// the record the morning after had no way to see when uploads were locked or the
|
||||||
|
// gallery released, which are the two actions that change what every guest can do.
|
||||||
|
//
|
||||||
|
// Last, deliberately: it is best-effort by design (it swallows its own errors), so nothing
|
||||||
|
// downstream may depend on it having completed.
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
None,
|
||||||
|
auth.role.clone(),
|
||||||
|
"release_gallery",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
|
use super::disk_is_low;
|
||||||
|
use crate::handlers::upload::{DISK_RESERVE_BYTES, UPLOAD_GATE_HEADROOM_BYTES};
|
||||||
|
use crate::services::export::required_free_bytes;
|
||||||
|
|
||||||
const GB: u64 = 1_000_000_000;
|
const GB: u64 = 1_000_000_000;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
|
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
|
||||||
assert!(!disk_is_low(40 * GB, 25 * GB));
|
// Room for the keepsake AND the reserve the upload gate holds back, with margin.
|
||||||
|
assert!(!disk_is_low(60 * GB, 25 * GB));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renamed from `the_absolute_floor_fires_...`: there is no separate floor clause any more
|
||||||
|
/// (see `disk_is_low`). What still has to hold is the behaviour the floor was there FOR — a
|
||||||
|
/// nearly-empty disk is low even when the gallery is small enough that the keepsake term
|
||||||
|
/// alone would clear it, because all three volumes share one filesystem and Postgres needs
|
||||||
|
/// room to write.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
|
fn a_nearly_empty_disk_is_low_even_when_the_gallery_is_tiny() {
|
||||||
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
|
// 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
|
// 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.
|
// the keepsake test comfortably; the floor is what catches this.
|
||||||
assert!(disk_is_low(5 * GB, GB));
|
assert!(disk_is_low(5 * GB, GB));
|
||||||
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
|
assert!(disk_is_low(9 * GB, 0));
|
||||||
assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
|
assert!(!disk_is_low(20 * GB, 0), "a roomy empty disk is not low");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE PROPERTY THIS EXISTS FOR: the host must be warned BEFORE guests are blocked.
|
||||||
|
///
|
||||||
|
/// `handlers::upload` refuses at `free < keepsake_required + DISK_RESERVE_BYTES`. If the
|
||||||
|
/// banner fires only at or below that, the host's first signal is 100 guests being unable
|
||||||
|
/// to upload while the dashboard shows a comfortable disk — with nobody on site to ask.
|
||||||
|
#[test]
|
||||||
|
fn the_banner_always_fires_before_the_upload_gate_closes() {
|
||||||
|
// Asserting `disk_is_low(gate_closes_at, required)` is what this used to do, and it was a
|
||||||
|
// tautology: `disk_is_low` recomputes the same `gate_closes_at` internally and compares
|
||||||
|
// against `gate + gate/4`, so the assertion reduced to `G < G + G/4` — true for every G,
|
||||||
|
// for any margin, even a margin of zero. It could not detect the banner being moved to
|
||||||
|
// exactly the gate, which is the regression it is named for.
|
||||||
|
//
|
||||||
|
// So pin the GAP instead: find the free-space level at which the banner starts, and
|
||||||
|
// require it to be strictly above the level at which the gate closes, by a usable amount.
|
||||||
|
for media_gb in [0u64, 1, 4, 8, 16, 32] {
|
||||||
|
let required = required_free_bytes(media_gb * GB, 2);
|
||||||
|
let gate_closes_at =
|
||||||
|
required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64;
|
||||||
|
|
||||||
|
// Just above the gate: guests can still upload, and the host must already be warned.
|
||||||
|
assert!(
|
||||||
|
disk_is_low(gate_closes_at + 1, required),
|
||||||
|
"at media={media_gb}GB the banner is not yet showing while the gate still allows uploads"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The warning must lead by a margin the host can act inside, not by one byte.
|
||||||
|
let mut warn_starts_at = gate_closes_at;
|
||||||
|
while disk_is_low(warn_starts_at, required) {
|
||||||
|
warn_starts_at += GB / 10;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
warn_starts_at >= gate_closes_at + gate_closes_at / 5,
|
||||||
|
"at media={media_gb}GB the banner leads the gate by only {} bytes",
|
||||||
|
warn_starts_at - gate_closes_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invariant the headroom exists for: uploads must stop while the keepsake can STILL be
|
||||||
|
/// built, with room to spare — not at the exact instant the preflight reaches its own limit.
|
||||||
|
///
|
||||||
|
/// Both thresholds used to be `required_free_bytes(media, 2) + DISK_RESERVE_BYTES`, identically.
|
||||||
|
/// So the moment the gate refused its first upload, the export preflight was already sitting on
|
||||||
|
/// its limit, and every byte written afterwards (WAL, container logs, the compression backlog
|
||||||
|
/// draining at exactly that hour) pushed it under. The release would then COMMIT — event closed,
|
||||||
|
/// uploads locked, epoch bumped, `event-closed` fanned out to every phone — and only then would
|
||||||
|
/// both workers bail, with no second release possible.
|
||||||
|
#[test]
|
||||||
|
fn the_upload_gate_closes_before_the_export_preflight_would_refuse() {
|
||||||
|
for media_gb in [0u64, 1, 4, 8, 16, 32] {
|
||||||
|
let required = required_free_bytes(media_gb * GB, 2);
|
||||||
|
|
||||||
|
// `services::export::preflight` bails below this.
|
||||||
|
let preflight_refuses_below = required + DISK_RESERVE_BYTES as u64;
|
||||||
|
// `handlers::upload` refuses below this.
|
||||||
|
let gate_refuses_below = preflight_refuses_below + UPLOAD_GATE_HEADROOM_BYTES as u64;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
gate_refuses_below > preflight_refuses_below,
|
||||||
|
"at media={media_gb}GB the gate and the preflight share a threshold, so the \
|
||||||
|
keepsake's fate rests on whatever is written after uploads stop"
|
||||||
|
);
|
||||||
|
|
||||||
|
// At the instant the last upload is refused, the preflight must still pass with the
|
||||||
|
// whole headroom to spare — that is the slack the night's remaining writes consume.
|
||||||
|
let free_when_gate_closes = gate_refuses_below;
|
||||||
|
assert!(
|
||||||
|
free_when_gate_closes
|
||||||
|
>= preflight_refuses_below + UPLOAD_GATE_HEADROOM_BYTES as u64,
|
||||||
|
"at media={media_gb}GB there is no slack between the gate closing and the \
|
||||||
|
preflight failing"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -841,13 +1153,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_keepsake_trigger_is_exact_at_the_boundary() {
|
fn the_keepsake_trigger_is_exact_at_the_boundary() {
|
||||||
assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough");
|
// The boundary is the UPLOAD GATE's threshold plus a 25% margin, not the bare keepsake
|
||||||
assert!(disk_is_low(66 * GB - 1, 66 * GB));
|
// size — see `disk_is_low`. Warning at the bare size fired only after the gate had
|
||||||
|
// already blocked every guest.
|
||||||
|
let required = 20 * GB;
|
||||||
|
let gate = required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64;
|
||||||
|
let warn_at = gate + gate / 4;
|
||||||
|
assert!(!disk_is_low(warn_at, required), "exactly enough is enough");
|
||||||
|
assert!(disk_is_low(warn_at - 1, required));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
|
fn an_empty_gallery_still_reserves_room_for_postgres() {
|
||||||
assert!(!disk_is_low(11 * GB, 0));
|
// With no gallery the keepsake term is 0, so the warn threshold collapses to
|
||||||
|
// 1.25 x (DISK_RESERVE_BYTES + UPLOAD_GATE_HEADROOM_BYTES) = 1.25 x 11.5 GB = 14.375 GB,
|
||||||
|
// which dominates the 10 GB absolute floor.
|
||||||
|
assert!(!disk_is_low(15 * GB, 0));
|
||||||
assert!(disk_is_low(9 * GB, 0));
|
assert!(disk_is_low(9 * GB, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ pub struct MeContextDto {
|
|||||||
/// The gallery has been released and the export snapshotted — uploads are permanently
|
/// The gallery has been released and the export snapshotted — uploads are permanently
|
||||||
/// closed for this run (release ⇒ lock, and reopening regenerates).
|
/// closed for this run (release ⇒ lock, and reopening regenerates).
|
||||||
pub gallery_released: bool,
|
pub gallery_released: bool,
|
||||||
|
/// This guest is banned: a deliberately READ-ONLY ban (see `handlers/host.rs`) — they keep
|
||||||
|
/// the feed and the keepsake, but every write is refused.
|
||||||
|
///
|
||||||
|
/// Exposed so the UI can SAY so. Without it the client had no idea, so the upload button,
|
||||||
|
/// the like button and "Löschen" all rendered enabled and returned 403 "Du bist gesperrt."
|
||||||
|
/// on every tap — a guest tapping upload repeatedly with nobody to ask. The lock case
|
||||||
|
/// (`uploads_locked`) has always been surfaced for exactly this reason; a ban was not.
|
||||||
|
pub is_banned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_context(
|
pub async fn get_context(
|
||||||
@@ -110,5 +118,203 @@ pub async fn get_context(
|
|||||||
storage_quota_enabled,
|
storage_quota_enabled,
|
||||||
uploads_locked,
|
uploads_locked,
|
||||||
gallery_released,
|
gallery_released,
|
||||||
|
is_banned: user.is_banned,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `(original_path, preview_path, thumbnail_path, display_path)` for one upload.
|
||||||
|
type UploadFilePaths = (String, Option<String>, Option<String>, Option<String>);
|
||||||
|
|
||||||
|
/// Delete the caller's own account and everything attached to it.
|
||||||
|
///
|
||||||
|
/// The erasure path (H18). There was no user-deletion route at ANY role, so honouring a "please
|
||||||
|
/// remove my photos and my name" request meant hand-written SQL against production — during or
|
||||||
|
/// after a wedding, by whoever happened to have psql access. Deletion also never removed text:
|
||||||
|
/// captions, comment bodies and hashtag links survived indefinitely by design, so even the
|
||||||
|
/// existing per-photo delete left the guest's words in the database and in the keepsake.
|
||||||
|
///
|
||||||
|
/// Self-service on purpose. The alternative (host-initiated only) puts a guest's erasure request
|
||||||
|
/// through a third party who is at a party, and the join page's data notice now promises this.
|
||||||
|
///
|
||||||
|
/// ORDER MATTERS. `upload.user_id` and `comment.user_id` are plain FKs with NO `ON DELETE CASCADE`
|
||||||
|
/// (migration 002), so deleting the user first fails on a constraint violation. Children first,
|
||||||
|
/// then the row itself — at which point `session`, `like` and `pin_reset_request` do cascade.
|
||||||
|
pub async fn delete_account(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<axum::http::StatusCode, AppError> {
|
||||||
|
// The last host/admin may not erase themselves: it would leave the event with no operator and
|
||||||
|
// no way to appoint one. Mirrors the floor `set_role` and `ban_user` already enforce.
|
||||||
|
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
if matches!(user.role, UserRole::Host | UserRole::Admin) {
|
||||||
|
let others = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM \"user\"
|
||||||
|
WHERE event_id = $1 AND id != $2
|
||||||
|
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
||||||
|
)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if others == 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
|
||||||
|
dein Konto löschst."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the file paths BEFORE the rows go, or they are unrecoverable. Every derivative, not
|
||||||
|
// just the original: a preview left behind is still the guest's photo.
|
||||||
|
let files: Vec<UploadFilePaths> = sqlx::query_as(
|
||||||
|
"SELECT original_path, preview_path, thumbnail_path, display_path
|
||||||
|
FROM upload WHERE user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
|
// The last-host guard, AUTHORITATIVELY — inside the transaction, holding a lock.
|
||||||
|
//
|
||||||
|
// The pre-check further up runs on the pool before this transaction opens, so two hosts
|
||||||
|
// deleting themselves at the same moment each saw the other and both proceeded, leaving the
|
||||||
|
// event with NO operator: nobody to moderate, nobody to release the gallery, and no way to
|
||||||
|
// appoint anyone because appointing requires a host. Not recoverable from inside the app.
|
||||||
|
//
|
||||||
|
// Serialised with a transaction-scoped ADVISORY lock, not a row lock. `FOR UPDATE` on the
|
||||||
|
// other operators\' rows looks like the obvious answer and is the wrong one: each deleter would
|
||||||
|
// lock the OTHER\'s row and then try to delete its own, so the two block on each other and
|
||||||
|
// Postgres resolves it by killing one with a deadlock error — the invariant holds, but the
|
||||||
|
// loser gets a 500 instead of the sentence below. Locking the `event` row instead would
|
||||||
|
// serialise cleanly, but it inverts the lock order every moderation path uses (upload/user
|
||||||
|
// rows first, event last). An advisory lock is a separate lock space, so it cannot join the
|
||||||
|
// row-lock graph at all, and it is released automatically when this transaction ends.
|
||||||
|
//
|
||||||
|
// FIRST STATEMENT IN THE TRANSACTION, before any row lock — the ORDER matters as much as the
|
||||||
|
// lock. `ban_user` and `set_role` take this same lock and then go on to lock `user` and
|
||||||
|
// `event` rows. If this path grabbed those rows first and reached for the advisory lock
|
||||||
|
// afterwards, the two would deadlock, each holding what the other needs, and Postgres would
|
||||||
|
// kill one with a 500: the invariant would survive, but a host deleting their account would
|
||||||
|
// get an error page instead of the sentence below.
|
||||||
|
//
|
||||||
|
// Taking it up front also means the refusal path does no work at all before answering.
|
||||||
|
if matches!(user.role, UserRole::Host | UserRole::Admin) {
|
||||||
|
// Shared with `host::ban_user` and `host::set_role` — the same key, by construction rather
|
||||||
|
// than by two copies agreeing. All three remove an operator, so all three must serialise
|
||||||
|
// against each other or the floor is enforceable only against its own kind of caller.
|
||||||
|
crate::handlers::host::lock_operator_floor(&mut tx, auth.event_id).await?;
|
||||||
|
let others: Vec<uuid::Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM \"user\"
|
||||||
|
WHERE event_id = $1 AND id != $2
|
||||||
|
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
||||||
|
)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if others.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
|
||||||
|
dein Konto löschst."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments the guest wrote on OTHER people's photos. Hard delete, not `deleted_at`: this is
|
||||||
|
// erasure, and a soft delete leaves the body in the table and in the keepsake's data.json.
|
||||||
|
sqlx::query("DELETE FROM comment WHERE user_id = $1")
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
// Their uploads. Cascades comments and likes ON those uploads, plus upload_hashtag links.
|
||||||
|
sqlx::query("DELETE FROM upload WHERE user_id = $1")
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
// Invalidate the keepsake inside the same transaction — an already-released archive still
|
||||||
|
// contains this guest's photos and captions, and erasure that leaves them in the downloadable
|
||||||
|
// ZIP has not happened. Returns None when the event isn't released, in which case there is
|
||||||
|
// nothing to rebuild.
|
||||||
|
let regen = crate::services::export::invalidate_and_arm(
|
||||||
|
&mut tx,
|
||||||
|
&state.config.event_slug,
|
||||||
|
crate::services::export::Affects::Both,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// And the account. `session`, `like` and `pin_reset_request` cascade from here.
|
||||||
|
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
// IMMEDIATELY after the commit, before any other `.await`. Every other `invalidate_and_arm`
|
||||||
|
// call site does this; this one used to spawn the workers *after* the file-removal loop below,
|
||||||
|
// and axum drops a handler future the moment the client disconnects. Drop it inside that loop
|
||||||
|
// and the keepsake is left with the epoch bumped, both `export_job` rows armed `pending` at
|
||||||
|
// that epoch, and NO WORKER: `/export/zip` and `/export/html` 404, the UI sits on
|
||||||
|
// "Wird vorbereitet…" forever, and `recover_exports` only runs at boot. Deleting your account
|
||||||
|
// from a phone that walks out of wifi range is enough to do it.
|
||||||
|
if let Some(r) = regen {
|
||||||
|
crate::handlers::host::start_regen(&state, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best effort, after the commit. Anything missed here is an orphan with no row pointing at it,
|
||||||
|
// which `sweep_orphan_originals` reclaims on its next pass — so a failure delays reclamation
|
||||||
|
// rather than leaving the file referenced.
|
||||||
|
for (original, preview, thumbnail, display) in &files {
|
||||||
|
for rel in [
|
||||||
|
Some(original),
|
||||||
|
preview.as_ref(),
|
||||||
|
thumbnail.as_ref(),
|
||||||
|
display.as_ref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let abs = state.config.media_path.join(rel);
|
||||||
|
if let Err(e) = tokio::fs::remove_file(&abs).await
|
||||||
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
tracing::warn!(error = ?e, path = %abs.display(), "account deletion: could not remove media file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict their content from every open feed and the projector. `user-hidden` is exactly the
|
||||||
|
// right signal — it already means "this user's cards must go" — and reusing it means every
|
||||||
|
// client already handles this with no new event type.
|
||||||
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
|
"user-hidden",
|
||||||
|
serde_json::json!({ "user_id": auth.user_id }).to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Audited like the host actions it resembles, with the actor and target being the same person.
|
||||||
|
//
|
||||||
|
// The names are passed EXPLICITLY here, unlike every other call site. `audit::record` resolves
|
||||||
|
// a missing name by looking the id up in `"user"` — and this handler has just hard-deleted that
|
||||||
|
// row, so the lookup would find nothing and write the NULL that makes the record unreadable.
|
||||||
|
// This is the row most likely to be read later ("whose photos disappeared?"), and migration 029
|
||||||
|
// made these columns non-FK precisely so it would survive the deletion.
|
||||||
|
crate::services::audit::record(
|
||||||
|
&state.pool,
|
||||||
|
auth.event_id,
|
||||||
|
auth.user_id,
|
||||||
|
Some(&user.display_name),
|
||||||
|
user.role.clone(),
|
||||||
|
"delete_account",
|
||||||
|
Some(auth.user_id),
|
||||||
|
Some(&user.display_name),
|
||||||
|
Some(serde_json::json!({ "uploads_removed": files.len() })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %auth.user_id, uploads = files.len(), "account deleted by its owner");
|
||||||
|
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ pub struct PublicEventDto {
|
|||||||
pub theme_preset: String,
|
pub theme_preset: String,
|
||||||
pub theme_primary: String,
|
pub theme_primary: String,
|
||||||
pub theme_accent: String,
|
pub theme_accent: String,
|
||||||
|
/// The operator's data notice, if they set one. Empty string when unset (migration 009
|
||||||
|
/// defaults it to `''`).
|
||||||
|
///
|
||||||
|
/// Exposed PUBLICLY — it was only on `/me/context`, which requires a token, so the one place a
|
||||||
|
/// notice actually has to appear (before a name is collected) could not read it. The join page
|
||||||
|
/// pairs this with a baseline notice of its own, precisely because this can be empty: relying
|
||||||
|
/// on an operator-supplied string meant a stock deploy collected ~100 EU guests' photos of
|
||||||
|
/// identifiable people, including children, with no notice at the point of collection at all.
|
||||||
|
pub privacy_note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public event identity + presentation config, used by the pre-auth join/recover
|
/// Public event identity + presentation config, used by the pre-auth join/recover
|
||||||
@@ -40,5 +49,6 @@ pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEvent
|
|||||||
.await,
|
.await,
|
||||||
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
|
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
|
||||||
.await,
|
.await,
|
||||||
|
privacy_note: config::get_str(cache, "privacy_note", "").await,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,17 @@ pub async fn toggle_like(
|
|||||||
// itself is already committed, so a failed count must not fail the request — but we
|
// itself is already committed, so a failed count must not fail the request — but we
|
||||||
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
|
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
|
||||||
// client until the next event). On error we skip the broadcast and return null.
|
// client until the next event). On error we skip the broadcast and return null.
|
||||||
|
// The `NOT u.is_banned` join is what makes "mirrors v_feed.like_count" true. Migration 028
|
||||||
|
// added it to the view and not here, so the two disagreed the moment anyone was banned: the
|
||||||
|
// host bans a guest, the feed correctly drops to the lower number, and then the very next like
|
||||||
|
// on that photo broadcasts the UNFILTERED count back to every open client — including the
|
||||||
|
// host's, who is watching that number to confirm the ban took. It stayed wrong until a full
|
||||||
|
// page-1 refetch. `like.user_id` is NOT NULL REFERENCES "user"(id), so the inner join can
|
||||||
|
// neither drop nor duplicate a row.
|
||||||
let like_count = sqlx::query_scalar::<_, i64>(
|
let like_count = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
|
"SELECT COUNT(DISTINCT l.user_id) FROM \"like\" l \
|
||||||
|
JOIN \"user\" u ON u.id = l.user_id \
|
||||||
|
WHERE l.upload_id = $1 AND NOT u.is_banned",
|
||||||
)
|
)
|
||||||
.bind(upload_id)
|
.bind(upload_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
@@ -195,7 +204,14 @@ pub async fn add_comment(
|
|||||||
|
|
||||||
// Insert the comment and link its hashtags atomically, so a crash mid-loop
|
// Insert the comment and link its hashtags atomically, so a crash mid-loop
|
||||||
// can't leave a committed comment with only some of its tags indexed.
|
// can't leave a committed comment with only some of its tags indexed.
|
||||||
let tags = hashtag::extract_hashtags(text);
|
let mut tags = hashtag::extract_hashtags(text);
|
||||||
|
// Deterministic lock order, matching the upload path. `Hashtag::upsert` takes row locks,
|
||||||
|
// so two transactions touching the same two tags in OPPOSITE order deadlock — Postgres
|
||||||
|
// aborts one after ~1s and that guest's comment 500s. `extract_hashtags` returns them in
|
||||||
|
// text order, which is exactly the unordered case. Sort on the NORMALISED form, because
|
||||||
|
// that is the key `upsert` locks on.
|
||||||
|
tags.sort_by_key(|t| t.trim().trim_start_matches('#').to_lowercase());
|
||||||
|
tags.dedup_by_key(|t| t.trim().trim_start_matches('#').to_lowercase());
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
|
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
|
||||||
for tag in &tags {
|
for tag in &tags {
|
||||||
@@ -215,8 +231,13 @@ pub async fn add_comment(
|
|||||||
// over the same deleted_at filter is identical since comment.id is the PK). The
|
// over the same deleted_at filter is identical since comment.id is the PK). The
|
||||||
// count + broadcast are a UI optimisation — the comment is already committed, so a
|
// count + broadcast are a UI optimisation — the comment is already committed, so a
|
||||||
// failure here must not fail the request. Swallow the error and skip the broadcast.
|
// failure here must not fail the request. Swallow the error and skip the broadcast.
|
||||||
|
// `NOT u.is_banned` for the same reason as `like_count` above — see that comment. Migration
|
||||||
|
// 028 put this filter in `v_feed.comment_count` and `Comment::list_for_upload`, but not here,
|
||||||
|
// so posting a comment pushed the pre-ban total back to every client.
|
||||||
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
|
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
|
"SELECT COUNT(*) FROM comment c \
|
||||||
|
JOIN \"user\" u ON u.id = c.user_id \
|
||||||
|
WHERE c.upload_id = $1 AND c.deleted_at IS NULL AND NOT u.is_banned",
|
||||||
)
|
)
|
||||||
.bind(upload_id)
|
.bind(upload_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::session::Session;
|
use crate::models::session::Session;
|
||||||
|
use crate::services::sse_tickets::TicketKind;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -38,7 +39,29 @@ pub async fn issue_ticket(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<StreamTicketResponse>, AppError> {
|
) -> Result<Json<StreamTicketResponse>, AppError> {
|
||||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
// The endpoint had no rate limit at all. Authentication is not a bound here: one valid
|
||||||
|
// session could loop it freely. 60/min is far above a real client (one ticket per SSE
|
||||||
|
// (re)connect, and reconnects are backed off) while capping a loop.
|
||||||
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("sse_ticket:{}", auth.user_id),
|
||||||
|
60,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
) {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Verbindungsversuche. Bitte warte kurz.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticket = state
|
||||||
|
.sse_tickets
|
||||||
|
.issue(auth.token_hash, TicketKind::Sse)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::ServiceUnavailable(
|
||||||
|
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
|
||||||
|
Some(30),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -48,6 +71,57 @@ pub async fn issue_ticket(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live SSE streams one session may hold OPEN at once.
|
||||||
|
///
|
||||||
|
/// The ticket store's `MAX_TICKETS_PER_SESSION` bounds UNCONSUMED tickets, not open streams — so it
|
||||||
|
/// never bounded this at all: mint a ticket, redeem it (freeing the slot), repeat. At the 60/min
|
||||||
|
/// ticket ceiling one guest could accumulate 60 new live streams per minute indefinitely, each
|
||||||
|
/// holding a broadcast receiver, a tokio task and a 60-second DB revalidation ticker.
|
||||||
|
///
|
||||||
|
/// 6 rather than 2: a guest legitimately has the feed in one tab, the diashow on a laptop, and both
|
||||||
|
/// may briefly double during a reconnect before the old socket's `Drop` lands. Well above real use,
|
||||||
|
/// far below anything that hurts.
|
||||||
|
const MAX_OPEN_STREAMS_PER_SESSION: usize = 6;
|
||||||
|
|
||||||
|
/// Open stream count per session token hash.
|
||||||
|
type OpenStreams = std::collections::HashMap<String, usize>;
|
||||||
|
static OPEN_STREAMS: std::sync::LazyLock<std::sync::Mutex<OpenStreams>> =
|
||||||
|
std::sync::LazyLock::new(|| std::sync::Mutex::new(OpenStreams::new()));
|
||||||
|
|
||||||
|
/// Decrements the open-stream count for its session when the stream is dropped.
|
||||||
|
///
|
||||||
|
/// A `Drop` guard is the only thing that works here: a client vanishing off wifi never runs any
|
||||||
|
/// cleanup path we write, but dropping the response future is exactly what happens.
|
||||||
|
struct StreamSlot(String);
|
||||||
|
|
||||||
|
impl Drop for StreamSlot {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut map) = OPEN_STREAMS.lock()
|
||||||
|
&& let Some(n) = map.get_mut(&self.0)
|
||||||
|
{
|
||||||
|
*n = n.saturating_sub(1);
|
||||||
|
if *n == 0 {
|
||||||
|
map.remove(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim one of this session's stream slots, or `None` when it is already at the cap.
|
||||||
|
fn claim_stream_slot(token_hash: &str) -> Option<StreamSlot> {
|
||||||
|
let mut map = match OPEN_STREAMS.lock() {
|
||||||
|
Ok(m) => m,
|
||||||
|
// Never let a poisoned lock take live updates down for the whole venue.
|
||||||
|
Err(e) => e.into_inner(),
|
||||||
|
};
|
||||||
|
let n = map.entry(token_hash.to_string()).or_insert(0);
|
||||||
|
if *n >= MAX_OPEN_STREAMS_PER_SESSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
*n += 1;
|
||||||
|
Some(StreamSlot(token_hash.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
||||||
/// [`issue_ticket`]) — never the raw JWT.
|
/// [`issue_ticket`]) — never the raw JWT.
|
||||||
pub async fn stream(
|
pub async fn stream(
|
||||||
@@ -56,7 +130,7 @@ pub async fn stream(
|
|||||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
|
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
|
||||||
let token_hash = state
|
let token_hash = state
|
||||||
.sse_tickets
|
.sse_tickets
|
||||||
.consume(&q.ticket)
|
.consume(&q.ticket, TicketKind::Sse)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||||
|
|
||||||
// NOTE: this authenticates via ticket→session, not the `AuthUser` extractor. The
|
// NOTE: this authenticates via ticket→session, not the `AuthUser` extractor. The
|
||||||
@@ -68,6 +142,17 @@ pub async fn stream(
|
|||||||
.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()))?;
|
||||||
|
|
||||||
|
// Bound how many streams this session holds open — see MAX_OPEN_STREAMS_PER_SESSION. Refuse
|
||||||
|
// rather than evict: closing somebody's live feed to make room for their own reconnect loop
|
||||||
|
// reads exactly like the flakiness it would be trying to fix.
|
||||||
|
let slot = claim_stream_slot(&token_hash).ok_or_else(|| {
|
||||||
|
tracing::warn!("session at its open-SSE-stream cap; refusing another");
|
||||||
|
AppError::TooManyRequests(
|
||||||
|
"Zu viele offene Verbindungen. Bitte schließe andere Tabs.".into(),
|
||||||
|
Some(10),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let rx = state.sse_tx.subscribe();
|
let rx = state.sse_tx.subscribe();
|
||||||
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||||
Ok(sse_event) => Some(Ok(Event::default()
|
Ok(sse_event) => Some(Ok(Event::default()
|
||||||
@@ -93,6 +178,10 @@ pub async fn stream(
|
|||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
let session_hash = token_hash.clone();
|
let session_hash = token_hash.clone();
|
||||||
let session_gone = async move {
|
let session_gone = async move {
|
||||||
|
// Owns the slot guard, and this future is owned by the returned stream — so the slot is
|
||||||
|
// released exactly when the stream is dropped, including when the client simply walks out
|
||||||
|
// of range and no cleanup code of ours ever runs.
|
||||||
|
let _slot = slot;
|
||||||
let mut ticker = tokio::time::interval(Duration::from_secs(60));
|
let mut ticker = tokio::time::interval(Duration::from_secs(60));
|
||||||
ticker.tick().await; // consume the immediate first tick
|
ticker.tick().await; // consume the immediate first tick
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub async fn truncate_all(
|
|||||||
('upload_rate_per_hour', '100'),
|
('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'),
|
('join_ip_rate_per_min', '300'),
|
||||||
('recover_ip_rate_per_min', '30'),
|
('recover_ip_rate_per_min', '30'),
|
||||||
('social_rate_per_min', '120'),
|
('social_rate_per_min', '120'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
@@ -111,6 +111,12 @@ pub async fn truncate_all(
|
|||||||
// steers the per-user limit off `free_disk_bytes`), i.e. two holes were masking each other.
|
// steers the per-user limit off `free_disk_bytes`), i.e. two holes were masking each other.
|
||||||
state.disk_cache.invalidate();
|
state.disk_cache.invalidate();
|
||||||
|
|
||||||
|
// `media_total` caches SUM(user.total_upload_bytes) for the upload gate's keepsake-headroom
|
||||||
|
// check. TRUNCATE has just zeroed every one of those rows, so a surviving reading would make
|
||||||
|
// the next test's first upload measure its headroom against the previous test's gallery —
|
||||||
|
// and that gate REFUSES uploads, so the failure would look like a spurious quota rejection.
|
||||||
|
state.media_total.invalidate();
|
||||||
|
|
||||||
// `sse_tickets` maps a ticket to a session token hash. TRUNCATE deletes the sessions, so every
|
// `sse_tickets` maps a ticket to a session token hash. TRUNCATE deletes the sessions, so every
|
||||||
// surviving ticket is a dangling reference to a user that no longer exists.
|
// surviving ticket is a dangling reference to a user that no longer exists.
|
||||||
state.sse_tickets.clear();
|
state.sse_tickets.clear();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,13 +27,34 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
|
// `info`, not `debug`. A stock deploy sets RUST_LOG nowhere (it is absent from
|
||||||
|
// .env.example and was absent from docker-compose.yml), so this fallback IS the
|
||||||
|
// production level — and at `debug` the TraceLayer below emits a line per request
|
||||||
|
// AND per response, into a log file that had no rotation. `tower_http=warn`
|
||||||
|
// rather than `info` states the intent: those spans are diagnostics, not an
|
||||||
|
// access log, and a future `DefaultOnResponse::new().level(Level::INFO)` should
|
||||||
|
// not silently re-enable them.
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
|
.unwrap_or_else(|_| "eventsnap_backend=info,tower_http=warn".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
|
|
||||||
|
// Prove both media directories are writable BEFORE anything else runs. This is first
|
||||||
|
// because everything downstream — the derivative backfill, export recovery, every upload —
|
||||||
|
// assumes it silently.
|
||||||
|
//
|
||||||
|
// This used to be `create_dir_all(&media_path).await.ok()` far below, which discarded the
|
||||||
|
// only signal there was, and EXPORT_PATH was never created or probed at all. The failure
|
||||||
|
// mode that produced: a wrong bind mount or a root-owned volume left the app booting
|
||||||
|
// *green* — `/health` only probes the database — so Caddy routed traffic to it, guests
|
||||||
|
// joined, and every single upload failed with EACCES. Existence is not the property we
|
||||||
|
// need; writability is, and the only way to know is to write.
|
||||||
|
ensure_writable_dir(&config.media_path, "MEDIA_PATH").await?;
|
||||||
|
ensure_writable_dir(&config.export_path, "EXPORT_PATH").await?;
|
||||||
|
|
||||||
let pool = db::create_pool(&config.database_url).await?;
|
let pool = db::create_pool(&config.database_url).await?;
|
||||||
|
|
||||||
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
|
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
|
||||||
@@ -51,6 +72,12 @@ async fn main() -> Result<()> {
|
|||||||
// originals are never touched, so a failure just retries on the next start.
|
// originals are never touched, so a failure just retries on the next start.
|
||||||
state.compression.backfill_stale_derivatives().await;
|
state.compression.backfill_stale_derivatives().await;
|
||||||
|
|
||||||
|
// Re-extract poster frames for videos a restart interrupted. `startup_recovery` above
|
||||||
|
// marks their compression `failed` but nothing re-enqueued them, so `thumbnail_path`
|
||||||
|
// stayed NULL for the rest of the event. Shares the attempt budget with the image
|
||||||
|
// backfill, so a clip that genuinely yields no frame stops being retried.
|
||||||
|
state.compression.backfill_video_posters().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
|
||||||
@@ -73,9 +100,6 @@ async fn main() -> Result<()> {
|
|||||||
config.media_path.clone(),
|
config.media_path.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure media directories exist
|
|
||||||
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
// Auth
|
// Auth
|
||||||
.route("/api/v1/event", get(handlers::public::get_public_event))
|
.route("/api/v1/event", get(handlers::public::get_public_event))
|
||||||
@@ -125,10 +149,15 @@ async fn main() -> Result<()> {
|
|||||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||||
|
// Self-service erasure. There was no user-deletion route at any role, so an erasure
|
||||||
|
// request could only be honoured with hand-written SQL against production — and the join
|
||||||
|
// page's data notice now promises this exists. See `me::delete_account`.
|
||||||
|
.route("/api/v1/me", delete(handlers::me::delete_account))
|
||||||
// Feed
|
// Feed
|
||||||
.route("/api/v1/feed", get(handlers::feed::feed))
|
.route("/api/v1/feed", get(handlers::feed::feed))
|
||||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||||
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
||||||
|
.route("/api/v1/uploaders", get(handlers::feed::uploaders))
|
||||||
// Social
|
// Social
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/upload/{id}/like",
|
"/api/v1/upload/{id}/like",
|
||||||
@@ -248,7 +277,27 @@ async fn main() -> Result<()> {
|
|||||||
// four subtrees. Deleting the route removes the vector outright rather than racing the
|
// four subtrees. Deleting the route removes the vector outright rather than racing the
|
||||||
// decoder; `/media/**` now 404s regardless of encoding.
|
// decoder; `/media/**` now 404s regardless of encoding.
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
// ONE probe, and it touches the database. The merge of the unattended-blockers work
|
||||||
|
// brought a competing design — a dependency-free `/health` for the compose gate plus
|
||||||
|
// a DB-backed `/health/ready` for an external monitor. That split is defensible, and
|
||||||
|
// it was rejected deliberately:
|
||||||
|
//
|
||||||
|
// * `/health` returning a constant "ok" is the exact defect faea555 fixed and
|
||||||
|
// verified live (stop Postgres → 503 → start → 200, with no app restart). Every
|
||||||
|
// request path touches the database, so a constant probe reports healthy while
|
||||||
|
// the app is useless — the disk-full endgame stayed green all the way down.
|
||||||
|
// * The split's motive was that Caddy's `depends_on: app: service_healthy` would
|
||||||
|
// be blocked by a Postgres hiccup at boot. But `app` itself already gates on
|
||||||
|
// `db: service_healthy`, so the DB is up before this probe ever runs, and the
|
||||||
|
// healthcheck carries a 20s start_period plus 5 retries on top.
|
||||||
|
// * The two handlers were the same `SELECT 1` with the same 2s timeout under two
|
||||||
|
// names, so keeping both bought nothing.
|
||||||
|
//
|
||||||
|
// The external uptime monitor points at this route — DEPLOYMENT_RUNBOOK.md §10.4,
|
||||||
|
// which documents the response table and is the only thing in this deployment that
|
||||||
|
// can page a human. (That section previously did not exist and this comment claimed
|
||||||
|
// it did; if you are removing §10.4, this route loses its only consumer.)
|
||||||
|
.route("/health", get(health))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
@@ -268,6 +317,97 @@ async fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create `dir` if absent, then prove we can actually write inside it. Hard error otherwise.
|
||||||
|
///
|
||||||
|
/// `create_dir_all` succeeding proves nothing: it is a no-op on an existing directory, so a
|
||||||
|
/// root-owned volume, a read-only bind mount and a full filesystem all "succeed". The probe
|
||||||
|
/// below is the only thing that distinguishes them, and it is worth the two syscalls once per
|
||||||
|
/// boot to turn a silent evening of failed uploads into a container that refuses to start.
|
||||||
|
///
|
||||||
|
/// `label` is the env var name so the operator gets the name of the knob to fix, not a path
|
||||||
|
/// they then have to trace back to a variable.
|
||||||
|
async fn ensure_writable_dir(dir: &std::path::Path, label: &str) -> anyhow::Result<()> {
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(dir)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("{label}: cannot create {}", dir.display()))?;
|
||||||
|
|
||||||
|
// A fixed name is fine: this runs once, before the server accepts requests, and two
|
||||||
|
// instances sharing one volume would be a misconfiguration in its own right. Removed on
|
||||||
|
// both the success and failure paths so a crashed boot cannot leave litter behind.
|
||||||
|
let probe = dir.join(".eventsnap-write-probe");
|
||||||
|
let result = async {
|
||||||
|
let mut f = tokio::fs::File::create(&probe)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("{label}: cannot create a file in {}", dir.display()))?;
|
||||||
|
// Write and fsync rather than just create: a full filesystem lets the create succeed
|
||||||
|
// and fails at the first byte, which is exactly the disk-full endgame this guards.
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut f, b"ok")
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("{label}: cannot write to {}", dir.display()))?;
|
||||||
|
f.sync_all()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("{label}: cannot flush to {}", dir.display()))?;
|
||||||
|
anyhow::Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let _ = tokio::fs::remove_file(&probe).await;
|
||||||
|
|
||||||
|
result.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"{label} ({}) is not writable. The app refuses to start rather than accept uploads \
|
||||||
|
it cannot store — check the bind mount and that the volume is owned by the \
|
||||||
|
container's non-root user.",
|
||||||
|
dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(path = %dir.display(), "{label} is writable");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long `/health` waits for the database before calling the app unhealthy. Deliberately
|
||||||
|
/// short: the point is to answer "can this process actually serve a request right now", and a
|
||||||
|
/// probe that blocks for the acquire timeout is itself a symptom.
|
||||||
|
const HEALTH_DB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Readiness probe — the Docker healthcheck, Caddy's `depends_on` gate, and the runbook's
|
||||||
|
/// event-day `curl` all hit this.
|
||||||
|
///
|
||||||
|
/// It used to return the literal string `"ok"` and touch nothing. Every request in the app needs
|
||||||
|
/// the database, so that answered a question nobody asked: the container reported healthy while
|
||||||
|
/// every real request 500'd, and with no operator watching during the event there was no signal
|
||||||
|
/// at all. Note what this does NOT buy: Compose's `restart: unless-stopped` does not react to
|
||||||
|
/// healthcheck state, so nothing restarts on a red probe — this is a diagnostic, and it is
|
||||||
|
/// deliberately not wired to automatic recovery, because the pool already heals itself across a
|
||||||
|
/// Postgres restart (sqlx revalidates on acquire) and an auto-restart would truncate every
|
||||||
|
/// in-flight upload to "fix" an outage that was about to clear on its own.
|
||||||
|
async fn health(
|
||||||
|
axum::extract::State(state): axum::extract::State<AppState>,
|
||||||
|
) -> impl axum::response::IntoResponse {
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
match tokio::time::timeout(
|
||||||
|
HEALTH_DB_TIMEOUT,
|
||||||
|
sqlx::query("SELECT 1").execute(&state.pool),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(_)) => (StatusCode::OK, "ok"),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!(error = ?e, "health check: database query failed");
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "database unavailable")
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!(
|
||||||
|
timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
|
||||||
|
"health check: database did not respond"
|
||||||
|
);
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "database timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
|
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
|
||||||
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
|
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
|
||||||
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
|
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
|
||||||
|
|||||||
@@ -62,13 +62,27 @@ impl Comment {
|
|||||||
) -> Result<Vec<CommentDto>, sqlx::Error> {
|
) -> Result<Vec<CommentDto>, sqlx::Error> {
|
||||||
// Two-step: pick the newest `limit` rows older than `before`, then flip
|
// Two-step: pick the newest `limit` rows older than `before`, then flip
|
||||||
// them back into ascending order so the caller can render top-to-bottom.
|
// them back into ascending order so the caller can render top-to-bottom.
|
||||||
|
// `AND NOT u.is_banned` — the filter that was missing (H11).
|
||||||
|
//
|
||||||
|
// Only `deleted_at` was checked, so a banned guest's comments stayed on the live feed
|
||||||
|
// forever: the host bans somebody for an abusive comment, watches every photo of theirs
|
||||||
|
// vanish, and the comment is still sitting there on the most-viewed photo of the evening.
|
||||||
|
// Nothing on the client evicted them either.
|
||||||
|
//
|
||||||
|
// The tell that this was an oversight rather than a decision: the EXPORT query already
|
||||||
|
// filters `is_banned`, so the comment disappeared from the keepsake but not from the app —
|
||||||
|
// the two views of the same moderation action disagreed. Migration 021 did the same for
|
||||||
|
// hashtag counts. This brings the live read path in line with both.
|
||||||
|
//
|
||||||
|
// A ban is reversible and this is derived at read time, so `unban_user` restores the
|
||||||
|
// comments with no extra work.
|
||||||
sqlx::query_as::<_, CommentDto>(
|
sqlx::query_as::<_, CommentDto>(
|
||||||
"SELECT * FROM (
|
"SELECT * FROM (
|
||||||
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
|
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
|
||||||
c.body, c.created_at
|
c.body, c.created_at
|
||||||
FROM comment c
|
FROM comment c
|
||||||
JOIN \"user\" u ON u.id = c.user_id
|
JOIN \"user\" u ON u.id = c.user_id
|
||||||
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
|
WHERE c.upload_id = $1 AND c.deleted_at IS NULL AND NOT u.is_banned
|
||||||
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
||||||
ORDER BY c.created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
|
|||||||
@@ -32,14 +32,32 @@ impl Event {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert the event, or return the existing row if another request won the race.
|
||||||
|
///
|
||||||
|
/// `ON CONFLICT`, not a bare INSERT. `slug` is UNIQUE (migration 002), and the only callers are
|
||||||
|
/// `/join` and `/admin/login` — both of which run before the row exists, at the one moment the
|
||||||
|
/// app is most concurrent: the QR code goes up and every phone in the room posts `/join` within
|
||||||
|
/// the same second. A check-then-insert loses that race by construction, and the losers got a
|
||||||
|
/// bare unique violation surfaced as a 500 on the very first screen of the event.
|
||||||
|
///
|
||||||
|
/// `DO UPDATE SET slug = EXCLUDED.slug` is a deliberate no-op write: `DO NOTHING` returns no
|
||||||
|
/// row on conflict, which would put the loser right back at square one. It touches only `slug`,
|
||||||
|
/// so `name`, `export_epoch` and the lock/release timestamps are never disturbed by a late
|
||||||
|
/// arrival.
|
||||||
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
|
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
|
sqlx::query_as::<_, Self>(
|
||||||
|
"INSERT INTO event (slug, name) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET slug = EXCLUDED.slug
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads first so the common case (the row already exists, i.e. every join after the first)
|
||||||
|
/// stays a plain SELECT and never takes a row lock.
|
||||||
pub async fn find_or_create(
|
pub async fn find_or_create(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
@@ -51,3 +69,71 @@ impl Event {
|
|||||||
Self::create(pool, slug, name).await
|
Self::create(pool, slug, name).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The QR code goes up and every phone posts `/join` in the same second, before the event row
|
||||||
|
/// exists. `find_or_create` reads first, so all of them miss, and all of them insert.
|
||||||
|
///
|
||||||
|
/// With a bare `INSERT`, exactly one wins and the rest get a unique violation on `slug` —
|
||||||
|
/// surfaced as a 500 on the first screen of the event, for everyone but the winner. There is no
|
||||||
|
/// retry on that path and nothing in the UI explains it.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn concurrent_first_joins_all_get_the_same_event(pool: PgPool) {
|
||||||
|
let racers: Vec<_> = (0..16)
|
||||||
|
.map(|_| {
|
||||||
|
let pool = pool.clone();
|
||||||
|
tokio::spawn(
|
||||||
|
async move { Event::find_or_create(&pool, "wedding", "Hochzeit").await },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for r in racers {
|
||||||
|
let event = r
|
||||||
|
.await
|
||||||
|
.expect("task panicked")
|
||||||
|
.expect("a concurrent first join must not fail — this is the QR-scan burst");
|
||||||
|
ids.push(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(ids.len(), 16);
|
||||||
|
assert!(
|
||||||
|
ids.iter().all(|id| *id == ids[0]),
|
||||||
|
"every racer must land on ONE event row, not create rivals"
|
||||||
|
);
|
||||||
|
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM event WHERE slug = 'wedding'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(count, 1, "exactly one event row may exist for a slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A late arrival must not clobber the row it collides with — the no-op `DO UPDATE` exists to
|
||||||
|
/// return the loser a row, not to let it rewrite one mid-event.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_late_create_does_not_disturb_the_existing_row(pool: PgPool) {
|
||||||
|
let first = Event::find_or_create(&pool, "wedding", "Hochzeit")
|
||||||
|
.await
|
||||||
|
.expect("first");
|
||||||
|
|
||||||
|
sqlx::query("UPDATE event SET name = $1, export_epoch = 7 WHERE id = $2")
|
||||||
|
.bind("Anna und Ben")
|
||||||
|
.bind(first.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("simulate a live event");
|
||||||
|
|
||||||
|
let late = Event::create(&pool, "wedding", "Hochzeit")
|
||||||
|
.await
|
||||||
|
.expect("a colliding insert must still return the row");
|
||||||
|
|
||||||
|
assert_eq!(late.id, first.id);
|
||||||
|
assert_eq!(late.name, "Anna und Ben", "the name must survive");
|
||||||
|
assert_eq!(late.export_epoch, 7, "and so must the export epoch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,33 @@ impl Hashtag {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The upload's current tags, lowercased and sorted — the comparable form.
|
||||||
|
///
|
||||||
|
/// Exists so `edit_upload` can tell a real hashtag change from a re-send of the same list.
|
||||||
|
/// Without it, `PATCH {"hashtags": []}` in a loop retired the HTML keepsake on every request
|
||||||
|
/// (readiness is derived from `event.export_epoch`), and every armed rebuild was superseded
|
||||||
|
/// before the debounce let it start — so the viewer 404'd for the rest of the event at zero
|
||||||
|
/// cost to the client. One indexed lookup on `upload_hashtag(upload_id)` is a fair price for
|
||||||
|
/// closing that.
|
||||||
|
pub async fn normalized_for_upload<'e, E>(
|
||||||
|
executor: E,
|
||||||
|
upload_id: Uuid,
|
||||||
|
) -> Result<Vec<String>, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT lower(h.tag) FROM hashtag h
|
||||||
|
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||||
|
WHERE uh.upload_id = $1
|
||||||
|
ORDER BY lower(h.tag)",
|
||||||
|
)
|
||||||
|
.bind(upload_id)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(t,)| t).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
|
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ pub struct VisibleMedia {
|
|||||||
impl Upload {
|
impl Upload {
|
||||||
/// Takes any executor so the caller can run it inside a transaction (atomic
|
/// Takes any executor so the caller can run it inside a transaction (atomic
|
||||||
/// quota + insert) or standalone against the pool.
|
/// quota + insert) or standalone against the pool.
|
||||||
|
// Eight arguments, one per column the INSERT writes, with exactly one call site. A params
|
||||||
|
// struct here would restate the column list a second time and buy nothing.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn create<'e, E>(
|
pub async fn create<'e, E>(
|
||||||
executor: E,
|
executor: E,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
@@ -61,13 +64,34 @@ impl Upload {
|
|||||||
mime_type: &str,
|
mime_type: &str,
|
||||||
original_size_bytes: i64,
|
original_size_bytes: i64,
|
||||||
caption: Option<&str>,
|
caption: Option<&str>,
|
||||||
) -> Result<Self, sqlx::Error>
|
client_upload_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<Self>, sqlx::Error>
|
||||||
where
|
where
|
||||||
E: sqlx::PgExecutor<'e>,
|
E: sqlx::PgExecutor<'e>,
|
||||||
{
|
{
|
||||||
|
// `Ok(None)` means this exact `client_upload_id` is already stored — the caller's request
|
||||||
|
// is a retry of one that already succeeded, and it must replay the original row rather
|
||||||
|
// than create a second. Letting the unique index raise instead would work, but only after
|
||||||
|
// the whole transaction had aborted, and it would arrive as an opaque database error the
|
||||||
|
// caller would have to string-match to recognise.
|
||||||
|
//
|
||||||
|
// The conflict target repeats the index's `WHERE` clause because it is a partial index;
|
||||||
|
// without it Postgres cannot prove which index to use and rejects the statement.
|
||||||
|
//
|
||||||
|
// KEEP THIS IN LOCKSTEP WITH `upload_client_upload_id_key` (migrations 026 and 031). The
|
||||||
|
// predicate here must match the index's, or the arbiter cannot be inferred and every
|
||||||
|
// upload that carries a `client_upload_id` fails as a runtime 500 — queries in this
|
||||||
|
// codebase are not compile-time checked, so nothing catches a drift at build time.
|
||||||
|
//
|
||||||
|
// `deleted_at IS NULL` is what makes a retry-after-delete work instead of 409ing forever:
|
||||||
|
// the key is claimed only while a LIVE row holds it, which is what
|
||||||
|
// `find_by_client_upload_id` below has always assumed. `OR taken_down_by_host` carves the
|
||||||
|
// moderation case back out — see migration 031: releasing the key for a HOST takedown let
|
||||||
|
// a late retry resurrect a photo the host had deliberately removed.
|
||||||
sqlx::query_as::<_, Self>(
|
sqlx::query_as::<_, Self>(
|
||||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host) DO NOTHING
|
||||||
RETURNING *",
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
@@ -76,7 +100,52 @@ impl Upload {
|
|||||||
.bind(mime_type)
|
.bind(mime_type)
|
||||||
.bind(original_size_bytes)
|
.bind(original_size_bytes)
|
||||||
.bind(caption)
|
.bind(caption)
|
||||||
.fetch_one(executor)
|
.bind(client_upload_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a live upload by the idempotency key its client sent.
|
||||||
|
///
|
||||||
|
/// Scoped to the user as well as the key: the key alone is unique, but a lookup that ignored
|
||||||
|
/// ownership would let one guest's retry return another guest's row if a key ever repeated.
|
||||||
|
/// Soft-deleted rows are excluded on purpose — if the guest deleted the photo and their queue
|
||||||
|
/// later retries, they should get a fresh upload rather than a resurrection of a deleted one.
|
||||||
|
pub async fn find_by_client_upload_id(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
client_upload_id: Uuid,
|
||||||
|
) -> Result<Option<Self>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Self>(
|
||||||
|
"SELECT * FROM upload
|
||||||
|
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(client_upload_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was this key claimed by a row the HOST took down?
|
||||||
|
///
|
||||||
|
/// Only used to answer a refused retry honestly. Without it the guest's queue shows
|
||||||
|
/// "Dieser Upload wurde bereits verarbeitet." for a photo that was in fact removed by the
|
||||||
|
/// hosts — technically true, actively misleading, and it invites them to try again.
|
||||||
|
pub async fn taken_down_by_client_upload_id(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
client_upload_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS (
|
||||||
|
SELECT 1 FROM upload
|
||||||
|
WHERE client_upload_id = $1 AND user_id = $2
|
||||||
|
AND deleted_at IS NOT NULL AND taken_down_by_host
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(client_upload_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +219,16 @@ impl Upload {
|
|||||||
|
|
||||||
/// Stamp which revision of the derivative pipeline produced this row's preview/display,
|
/// 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.
|
/// so the startup backfill can find rows generated by an older one exactly once.
|
||||||
|
///
|
||||||
|
/// Also clears the attempt counter: success is the only thing that resets it, and folding
|
||||||
|
/// the reset in here means both the live path and the backfill get it with no extra call
|
||||||
|
/// site to forget.
|
||||||
pub async fn set_derivatives_rev(pool: &PgPool, id: Uuid, rev: i16) -> Result<(), sqlx::Error> {
|
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")
|
sqlx::query(
|
||||||
|
"UPDATE upload
|
||||||
|
SET derivatives_rev = $2, derivative_attempts = 0, derivative_last_error = NULL
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(rev)
|
.bind(rev)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -159,6 +236,62 @@ impl Upload {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record that derivative processing is ABOUT to be attempted, returning the new count.
|
||||||
|
///
|
||||||
|
/// WRITE-AHEAD ON PURPOSE. The failure this bounds is a cgroup SIGKILL: the process
|
||||||
|
/// vanishes mid-work, so no `Err` is returned, no error handler runs and no `Drop` fires.
|
||||||
|
/// A counter incremented after a failure would increment zero times per crash and the
|
||||||
|
/// boot loop would be unchanged. Counting the ATTEMPT is the only thing that survives the
|
||||||
|
/// process dying. The cost is that a genuinely transient failure also burns an attempt —
|
||||||
|
/// acceptable, because the retry budget is per-boot-loop, not per-request, and success
|
||||||
|
/// resets it to zero.
|
||||||
|
/// `None` when the row no longer exists (hard-deleted, or an e2e TRUNCATE landed while the
|
||||||
|
/// task waited on the semaphore) — the caller should abandon quietly rather than treat a
|
||||||
|
/// missing row as a processing failure.
|
||||||
|
pub async fn begin_derivative_attempt(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<i16>, sqlx::Error> {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"UPDATE upload
|
||||||
|
SET derivative_attempts = derivative_attempts + 1
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING derivative_attempts",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the lifetime derivative-attempt counter WITHOUT charging it.
|
||||||
|
///
|
||||||
|
/// Used by the in-request retries after the first: those re-enter `do_process` but must not
|
||||||
|
/// spend the lifetime budget again (see `charge_lifetime_attempt`). `None` still means the
|
||||||
|
/// row vanished, so the caller's "nothing to do" branch keeps working unchanged.
|
||||||
|
pub async fn derivative_attempts(pool: &PgPool, id: Uuid) -> Result<Option<i16>, sqlx::Error> {
|
||||||
|
sqlx::query_scalar("SELECT derivative_attempts FROM upload WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store why the last derivative attempt failed. Diagnostics only — nothing branches on it.
|
||||||
|
pub async fn record_derivative_failure(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
error: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
// Bounded: an anyhow chain can be long, and this is written on a failure path that may
|
||||||
|
// repeat across every row of a bad batch.
|
||||||
|
let truncated: String = error.chars().take(500).collect();
|
||||||
|
sqlx::query("UPDATE upload SET derivative_last_error = $2 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(truncated)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_thumbnail_path(
|
pub async fn set_thumbnail_path(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -172,40 +305,8 @@ impl Upload {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
|
/// Soft-deletes an upload within its event and refunds the uploader's
|
||||||
/// Done in a single transaction so a crash between the two writes can't leave
|
/// `total_upload_bytes`, in one transaction. Returns `false` if no row
|
||||||
/// the quota counter pointing at bytes the user has already deleted (which would
|
|
||||||
/// silently lock them out of future uploads).
|
|
||||||
///
|
|
||||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
|
||||||
/// delete action double-decrementing the counter.
|
|
||||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
|
||||||
"UPDATE upload
|
|
||||||
SET deleted_at = NOW()
|
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
|
||||||
RETURNING user_id, original_size_bytes",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
if let Some((user_id, bytes)) = row {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE \"user\"
|
|
||||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(bytes)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
|
||||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||||
/// can return a clean 404 instead of silently no-op'ing.
|
/// can return a clean 404 instead of silently no-op'ing.
|
||||||
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
|
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
|
||||||
@@ -213,20 +314,27 @@ impl Upload {
|
|||||||
/// dropped handler future, a failed second tx), the taken-down photo stays in the downloadable
|
/// dropped handler future, a failed second tx), the taken-down photo stays in the downloadable
|
||||||
/// archive forever, and recovery can't tell — the keepsake still looks complete at the current
|
/// archive forever, and recovery can't tell — the keepsake still looks complete at the current
|
||||||
/// epoch, and the host can no longer even find the upload to retry.
|
/// epoch, and the host can no longer even find the upload to retry.
|
||||||
|
///
|
||||||
|
/// `by_host` records WHO removed it, which decides whether the row keeps holding its
|
||||||
|
/// idempotency key — see migration 031. A host takedown holds it, so a late retry from the
|
||||||
|
/// uploader's queue cannot bring the photo back; a guest deleting their own photo releases it,
|
||||||
|
/// so their next upload of the same queue item succeeds.
|
||||||
pub async fn soft_delete_in_event(
|
pub async fn soft_delete_in_event(
|
||||||
conn: &mut sqlx::PgConnection,
|
conn: &mut sqlx::PgConnection,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
|
by_host: bool,
|
||||||
) -> Result<bool, sqlx::Error> {
|
) -> Result<bool, sqlx::Error> {
|
||||||
let tx = conn;
|
let tx = conn;
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||||
"UPDATE upload
|
"UPDATE upload
|
||||||
SET deleted_at = NOW()
|
SET deleted_at = NOW(), taken_down_by_host = $3
|
||||||
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
|
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
|
||||||
RETURNING user_id, original_size_bytes",
|
RETURNING user_id, original_size_bytes",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
|
.bind(by_host)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let deleted = if let Some((user_id, bytes)) = row {
|
let deleted = if let Some((user_id, bytes)) = row {
|
||||||
|
|||||||
@@ -47,19 +47,89 @@ impl User {
|
|||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
pin_hash: &str,
|
pin_hash: &str,
|
||||||
|
client_join_id: Option<Uuid>,
|
||||||
) -> Result<Self, sqlx::Error> {
|
) -> Result<Self, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>(
|
sqlx::query_as::<_, Self>(
|
||||||
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
|
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, client_join_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *",
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.bind(display_name)
|
.bind(display_name)
|
||||||
.bind(pin_hash)
|
.bind(pin_hash)
|
||||||
|
.bind(client_join_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Look up a join that already succeeded, by the idempotency key its client sent.
|
||||||
|
///
|
||||||
|
/// The retry path for H16: the account was created but the response never arrived, so the
|
||||||
|
/// client re-sends the same `client_join_id`. Finding a row here means "this join already
|
||||||
|
/// happened" — the caller rotates the PIN and answers with a usable one rather than 409ing
|
||||||
|
/// on a name the caller itself owns.
|
||||||
|
pub async fn find_by_client_join_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
client_join_id: Uuid,
|
||||||
|
) -> Result<Option<Self>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Self>(
|
||||||
|
"SELECT * FROM \"user\" WHERE event_id = $1 AND client_join_id = $2",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(client_join_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a user with an explicit role, in ONE statement.
|
||||||
|
///
|
||||||
|
/// `create` + a separate `UPDATE ... SET role` is not equivalent: a crash or a pool error
|
||||||
|
/// between the two leaves a GUEST row holding a reserved name, which is exactly the
|
||||||
|
/// poisoned state that bricked admin login — now self-inflicted, and invisible to a
|
||||||
|
/// role-based lookup, so the next login would create yet another.
|
||||||
|
pub async fn create_with_role(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
display_name: &str,
|
||||||
|
pin_hash: &str,
|
||||||
|
role: UserRole,
|
||||||
|
) -> Result<Self, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Self>(
|
||||||
|
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, role)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(pin_hash)
|
||||||
|
.bind(role)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event's admin, looked up BY ROLE.
|
||||||
|
///
|
||||||
|
/// The name is not the identity and never was. Looking the admin up by `display_name`
|
||||||
|
/// meant any guest who joined as "Admin" first made the lookup miss, and the fallback
|
||||||
|
/// `create` then violated the case-insensitive unique index from migration 007 — a
|
||||||
|
/// permanent 500 on admin login, recoverable only by hand-editing the database.
|
||||||
|
///
|
||||||
|
/// `ORDER BY created_at` so a database that somehow acquired two admin rows resolves to a
|
||||||
|
/// stable one rather than alternating between them.
|
||||||
|
pub async fn find_admin_for_event(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
) -> Result<Option<Self>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Self>(
|
||||||
|
"SELECT * FROM \"user\" WHERE event_id = $1 AND role = 'admin'
|
||||||
|
ORDER BY created_at ASC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Self>("SELECT * FROM \"user\" WHERE id = $1")
|
sqlx::query_as::<_, Self>("SELECT * FROM \"user\" WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -96,14 +166,31 @@ impl User {
|
|||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window after which a failed-PIN streak is forgotten. Matches the lockout duration, so
|
||||||
|
/// "wait out the cooldown" and "start clean" are the same interval to a guest.
|
||||||
|
const PIN_ATTEMPT_DECAY_MINUTES: i64 = 15;
|
||||||
|
|
||||||
|
/// Record a wrong PIN and return the CURRENT streak length.
|
||||||
|
///
|
||||||
|
/// The counter decays: before this, it only ever cleared on a successful recovery or after
|
||||||
|
/// a lockout expired, so ordinary typos accumulated across days and a guest could arrive at
|
||||||
|
/// an event already most of the way to being locked out by mistakes made the night before.
|
||||||
|
/// Decay is what makes the raised lock threshold safe rather than merely lenient.
|
||||||
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
|
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
|
||||||
let row: (i16,) = sqlx::query_as(
|
let row: (i16,) = sqlx::query_as(
|
||||||
"UPDATE \"user\"
|
"UPDATE \"user\"
|
||||||
SET failed_pin_attempts = failed_pin_attempts + 1
|
SET failed_pin_attempts = CASE
|
||||||
|
WHEN last_failed_pin_at IS NULL
|
||||||
|
OR last_failed_pin_at < NOW() - ($2 || ' minutes')::interval
|
||||||
|
THEN 1
|
||||||
|
ELSE failed_pin_attempts + 1
|
||||||
|
END,
|
||||||
|
last_failed_pin_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING failed_pin_attempts",
|
RETURNING failed_pin_attempts",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(Self::PIN_ATTEMPT_DECAY_MINUTES.to_string())
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.0)
|
Ok(row.0)
|
||||||
@@ -124,7 +211,9 @@ impl User {
|
|||||||
|
|
||||||
pub async fn reset_pin_attempts(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
pub async fn reset_pin_attempts(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE \"user\" SET failed_pin_attempts = 0, pin_locked_until = NULL WHERE id = $1",
|
"UPDATE \"user\"
|
||||||
|
SET failed_pin_attempts = 0, pin_locked_until = NULL, last_failed_pin_at = NULL
|
||||||
|
WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
314
backend/src/services/audit.rs
Normal file
314
backend/src/services/audit.rs
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
//! Append-only record of privileged actions. See migration 029 for why it exists.
|
||||||
|
//!
|
||||||
|
//! Design constraints, both learned from the rest of this codebase:
|
||||||
|
//!
|
||||||
|
//! * **Never fail the action.** An audit write that can turn a successful ban into a 500 makes
|
||||||
|
//! moderation less reliable than no audit at all. Every failure here is logged and swallowed.
|
||||||
|
//! * **Never store a credential.** `reset_pin` is the action most worth recording and the one
|
||||||
|
//! whose payload must never be in `detail` — a table that could hand back a guest's PIN would
|
||||||
|
//! be a worse privacy problem than the gap it closes.
|
||||||
|
//!
|
||||||
|
//! **Action slugs actually written**, since migration 029's header lists three (`promote_user`,
|
||||||
|
//! `demote_user`, `delete_user`) that no call site has ever emitted, and the migration file cannot
|
||||||
|
//! be corrected without changing its checksum and crash-looping every database that ran it:
|
||||||
|
//!
|
||||||
|
//! `ban_user`, `unban_user`, `set_role`, `reset_pin`, `delete_upload`, `delete_comment`,
|
||||||
|
//! `lock_uploads`, `unlock_uploads`, `release_gallery`, `delete_account`, `patch_config`.
|
||||||
|
//! Eleven, one per `audit::record` call site — grep for it if this list ages.
|
||||||
|
//!
|
||||||
|
//! **There is deliberately no read endpoint.** The table is queried by hand:
|
||||||
|
//!
|
||||||
|
//! ```sql
|
||||||
|
//! SELECT created_at, actor_name, actor_role, action, target_name, detail
|
||||||
|
//! FROM host_action_audit ORDER BY created_at DESC LIMIT 50;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::user::UserRole;
|
||||||
|
|
||||||
|
/// Record one privileged action.
|
||||||
|
///
|
||||||
|
/// Takes `&PgPool` rather than a transaction on purpose: the audit row is not part of the action's
|
||||||
|
/// atomicity. If the action commits and the audit write fails we want the action to stand (and a
|
||||||
|
/// loud log line); if the action rolls back, an orphan audit row saying "someone tried" is more
|
||||||
|
/// useful than silence.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn record(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
actor_id: Uuid,
|
||||||
|
actor_name: Option<&str>,
|
||||||
|
actor_role: UserRole,
|
||||||
|
action: &str,
|
||||||
|
target_id: Option<Uuid>,
|
||||||
|
target_name: Option<&str>,
|
||||||
|
detail: Option<Value>,
|
||||||
|
) {
|
||||||
|
// Resolve whatever names the caller did not supply.
|
||||||
|
//
|
||||||
|
// Migration 029 made `actor_id`/`target_id` deliberately non-FK so "the record survives the
|
||||||
|
// actor's account being removed, which is exactly when it is most likely to be wanted". Every
|
||||||
|
// caller passed None for both names, so what survived was a bare uuid resolving to nothing —
|
||||||
|
// the guarantee the column exists for, minus the only thing that made it readable.
|
||||||
|
//
|
||||||
|
// Resolved HERE rather than at eleven call sites so none can be missed. The one caller that
|
||||||
|
// destroys the row it is recording — `me::delete_account` — must still pass the name in, since
|
||||||
|
// by the time this runs there is nothing left to look up, and that is precisely the row a host
|
||||||
|
// will be reading the next morning ("whose photos disappeared?").
|
||||||
|
let (actor_name, target_name) =
|
||||||
|
resolve_names(pool, actor_id, actor_name, target_id, target_name).await;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO host_action_audit
|
||||||
|
(event_id, actor_id, actor_name, actor_role, action, target_id, target_name, detail)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(actor_id)
|
||||||
|
.bind(actor_name.as_deref())
|
||||||
|
// `as_str()`, not `format!("{actor_role:?}")`: the Debug spelling is not a stable wire format,
|
||||||
|
// so a `#[derive(Debug)]` change or a renamed variant would silently start writing a different
|
||||||
|
// string into a column nothing validates. `as_str` is the one the rest of the codebase uses.
|
||||||
|
.bind(actor_role.as_str())
|
||||||
|
.bind(action)
|
||||||
|
.bind(target_id)
|
||||||
|
.bind(target_name.as_deref())
|
||||||
|
.bind(detail)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// `error`, not `warn`: losing an audit row is the kind of thing that should show up in
|
||||||
|
// whatever is watching the logs, even though it must not fail the request.
|
||||||
|
tracing::error!(
|
||||||
|
error = ?e, action, %actor_id, ?target_id,
|
||||||
|
"failed to write host action audit row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in any name the caller left as `None`, in ONE query.
|
||||||
|
///
|
||||||
|
/// Best-effort by the same rule as the insert: a failed lookup writes NULL rather than failing the
|
||||||
|
/// action, and it is one round-trip whether zero, one or both names are missing.
|
||||||
|
async fn resolve_names(
|
||||||
|
pool: &PgPool,
|
||||||
|
actor_id: Uuid,
|
||||||
|
actor_name: Option<&str>,
|
||||||
|
target_id: Option<Uuid>,
|
||||||
|
target_name: Option<&str>,
|
||||||
|
) -> (Option<String>, Option<String>) {
|
||||||
|
let need_actor = actor_name.is_none();
|
||||||
|
let need_target = target_name.is_none() && target_id.is_some();
|
||||||
|
if !need_actor && !need_target {
|
||||||
|
return (
|
||||||
|
actor_name.map(str::to_owned),
|
||||||
|
target_name.map(str::to_owned),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wanted: Vec<Uuid> = Vec::with_capacity(2);
|
||||||
|
if need_actor {
|
||||||
|
wanted.push(actor_id);
|
||||||
|
}
|
||||||
|
if let Some(t) = target_id
|
||||||
|
&& need_target
|
||||||
|
{
|
||||||
|
wanted.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<(Uuid, String)> =
|
||||||
|
sqlx::query_as("SELECT id, display_name FROM \"user\" WHERE id = ANY($1)")
|
||||||
|
.bind(&wanted)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let lookup = |id: Uuid| rows.iter().find(|(i, _)| *i == id).map(|(_, n)| n.clone());
|
||||||
|
|
||||||
|
(
|
||||||
|
actor_name.map(str::to_owned).or_else(|| lookup(actor_id)),
|
||||||
|
target_name
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| target_id.and_then(lookup)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// These live HERE, not in `tests/`, and that is the entire point.
|
||||||
|
///
|
||||||
|
/// `backend/` is a binary crate, so an integration test cannot import `record`. The house rule in
|
||||||
|
/// `tests/common/mod.rs` — copy the production SQL character-for-character — works for pinning
|
||||||
|
/// behaviour that already existed, but applied to a NEW fix whose only coverage is the copy it
|
||||||
|
/// proves nothing: the fix and its test become two independent implementations, and deleting the
|
||||||
|
/// fix leaves the test green. The previous `tests/audit_names.rs` did exactly that, down to
|
||||||
|
/// asserting `actor_role == "host"` against its own hardcoded `.bind("host")` — an assertion that
|
||||||
|
/// could not fail for any change to the code it named.
|
||||||
|
///
|
||||||
|
/// A `#[cfg(test)]` module inside the binary can call the real function, so these do.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn seed_event(pool: &PgPool, slug: &str) -> Uuid {
|
||||||
|
sqlx::query_scalar("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING id")
|
||||||
|
.bind(slug)
|
||||||
|
.bind("Hochzeit")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_user(pool: &PgPool, event_id: Uuid, name: &str) -> Uuid {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
|
||||||
|
VALUES ($1, $2, 'x') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("seed user")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn audit_row(pool: &PgPool, action: &str) -> Option<(Option<String>, Option<String>)> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT actor_name, target_name FROM host_action_audit
|
||||||
|
WHERE action = $1 ORDER BY created_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(action)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.expect("audit lookup")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordinary case: the caller supplies no names and `record` resolves both from the ids.
|
||||||
|
/// This is what nine of the eleven call sites do. Revert `resolve_names` and both names go NULL.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_recorded_action_carries_both_names_without_the_caller_supplying_them(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let host = seed_user(&pool, event_id, "Gastgeberin Greta").await;
|
||||||
|
let guest = seed_user(&pool, event_id, "Gesperrter Gustav").await;
|
||||||
|
|
||||||
|
record(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
host,
|
||||||
|
None,
|
||||||
|
UserRole::Host,
|
||||||
|
"ban_user",
|
||||||
|
Some(guest),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (actor_name, target_name) = audit_row(&pool, "ban_user").await.expect("a row");
|
||||||
|
assert_eq!(actor_name.as_deref(), Some("Gastgeberin Greta"));
|
||||||
|
assert_eq!(target_name.as_deref(), Some("Gesperrter Gustav"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `as_str()`, not the `Debug` spelling. Asserted against `UserRole::as_str` itself rather than
|
||||||
|
/// a literal, so it tracks a rename instead of pretending to: what must hold is that the column
|
||||||
|
/// carries the SAME string the rest of the codebase uses, whatever that string is. Swap line 75
|
||||||
|
/// back to `format!("{actor_role:?}")` and this goes red on the `Host`/`host` casing.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_role_column_carries_the_canonical_spelling(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let host = seed_user(&pool, event_id, "Gastgeberin Greta").await;
|
||||||
|
|
||||||
|
record(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
host,
|
||||||
|
None,
|
||||||
|
UserRole::Host,
|
||||||
|
"release_gallery",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let role: String = sqlx::query_scalar(
|
||||||
|
"SELECT actor_role FROM host_action_audit WHERE action = 'release_gallery'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("role");
|
||||||
|
assert_eq!(role, UserRole::Host.as_str());
|
||||||
|
assert_ne!(
|
||||||
|
role,
|
||||||
|
format!("{:?}", UserRole::Host),
|
||||||
|
"the Debug spelling is not a wire format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case the columns exist for. `delete_account` hard-deletes the user row, so a name
|
||||||
|
/// resolved AFTER the fact would be NULL — the caller has to pass it in.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_name_supplied_by_the_caller_survives_the_row_being_deleted(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let leaver = seed_user(&pool, event_id, "Abschied Anke").await;
|
||||||
|
|
||||||
|
// Exactly the order `me::delete_account` runs in: the row goes first, the audit row second.
|
||||||
|
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
|
||||||
|
.bind(leaver)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("delete user");
|
||||||
|
|
||||||
|
record(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
leaver,
|
||||||
|
Some("Abschied Anke"),
|
||||||
|
UserRole::Guest,
|
||||||
|
"delete_account",
|
||||||
|
Some(leaver),
|
||||||
|
Some("Abschied Anke"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (actor_name, target_name) = audit_row(&pool, "delete_account").await.expect("a row");
|
||||||
|
assert_eq!(
|
||||||
|
actor_name.as_deref(),
|
||||||
|
Some("Abschied Anke"),
|
||||||
|
"the audit row must name the deleted account — resolving it later is impossible"
|
||||||
|
);
|
||||||
|
assert_eq!(target_name.as_deref(), Some("Abschied Anke"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And the failure mode that made this worth testing: with nothing supplied and nothing to look
|
||||||
|
/// up, the write must still succeed (an audit row must never fail an action) and carry NULLs.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn an_unresolvable_name_writes_the_row_anyway(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let ghost = Uuid::new_v4();
|
||||||
|
|
||||||
|
record(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
ghost,
|
||||||
|
None,
|
||||||
|
UserRole::Host,
|
||||||
|
"reset_pin",
|
||||||
|
Some(ghost),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (actor_name, target_name) = audit_row(&pool, "reset_pin")
|
||||||
|
.await
|
||||||
|
.expect("the row must be written even when no name can be resolved");
|
||||||
|
assert_eq!(actor_name, None);
|
||||||
|
assert_eq!(target_name, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,21 @@ impl CompressionWorker {
|
|||||||
/// next start. Rev 1 = EXIF orientation is applied.
|
/// next start. Rev 1 = EXIF orientation is applied.
|
||||||
const DERIVATIVES_REV: i16 = 1;
|
const DERIVATIVES_REV: i16 = 1;
|
||||||
|
|
||||||
|
/// How many times derivative generation may be ATTEMPTED for one upload before it is left
|
||||||
|
/// alone. Counted write-ahead and reset on success — see `Upload::begin_derivative_attempt`.
|
||||||
|
///
|
||||||
|
/// This is what turns a fatal input from an outage into a blemish. The startup backfill
|
||||||
|
/// runs unconditionally on every boot, so before this bound a row whose processing killed
|
||||||
|
/// the process was re-selected and re-run forever, and `restart: unless-stopped` made that
|
||||||
|
/// an infinite loop that also dropped every SSE stream and truncated every in-flight
|
||||||
|
/// upload on each cycle. Three attempts absorbs genuinely transient infrastructure
|
||||||
|
/// failures (an ENOSPC spike, a pool blip) without ever becoming unbounded.
|
||||||
|
const MAX_DERIVATIVE_ATTEMPTS: i16 = 3;
|
||||||
|
|
||||||
|
/// Rows regenerated per boot. Bounds both the query and the amount of work a single start
|
||||||
|
/// can queue; whatever is left is picked up on the next boot.
|
||||||
|
const BACKFILL_BATCH: i64 = 200;
|
||||||
|
|
||||||
/// 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();
|
||||||
@@ -82,13 +97,15 @@ impl CompressionWorker {
|
|||||||
let mut attempt = 1u32;
|
let mut attempt = 1u32;
|
||||||
let outcome = loop {
|
let outcome = loop {
|
||||||
match worker
|
match worker
|
||||||
.do_process(upload_id, &original_path, &mime_type)
|
// Charge the lifetime budget once per episode, on the first attempt only.
|
||||||
|
.do_process(upload_id, &original_path, &mime_type, attempt == 1)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(v) => break Ok(v),
|
Ok(v) => break Ok(v),
|
||||||
Err(e)
|
Err(e)
|
||||||
if attempt < Self::MAX_PROCESS_ATTEMPTS
|
if attempt < Self::MAX_PROCESS_ATTEMPTS
|
||||||
&& !crate::services::imaging::is_permanent_image_error(&e) =>
|
&& !crate::services::imaging::is_permanent_image_error(&e)
|
||||||
|
&& !crate::services::imaging::is_storage_full_error(&e) =>
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = ?e, %upload_id, attempt,
|
error = ?e, %upload_id, attempt,
|
||||||
@@ -113,39 +130,75 @@ impl CompressionWorker {
|
|||||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Err(e) if crate::services::imaging::is_storage_full_error(&e) => {
|
||||||
|
// Out of disk. Keep the row AND the original — the opposite of the branch
|
||||||
|
// below, and for the same reason it retains the file: nothing here is the
|
||||||
|
// guest's fault and nothing about the photo is wrong.
|
||||||
|
//
|
||||||
|
// Soft-deleting on ENOSPC was strictly harmful. It refunded the quota while
|
||||||
|
// keeping the bytes, so it freed nothing, removed the photo from the feed
|
||||||
|
// seconds after a `201 Created`, and handed the guest the allowance to
|
||||||
|
// upload it again into the same full disk. Leaving the row live costs
|
||||||
|
// nothing instead: every client already falls back to the original when
|
||||||
|
// `preview_url` and `thumbnail_url` are NULL, so the photo stays visible —
|
||||||
|
// just uncompressed — and `backfill_stale_derivatives` regenerates the
|
||||||
|
// derivatives on the next start, once there is room for them.
|
||||||
|
tracing::error!(
|
||||||
|
%upload_id,
|
||||||
|
"compression failed: the media filesystem is out of space. The upload is \
|
||||||
|
kept and served from its original; free disk space and restart to \
|
||||||
|
regenerate derivatives: {e:#}"
|
||||||
|
);
|
||||||
|
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||||
|
// Not an "error" event: nothing was lost and there is nothing for the guest
|
||||||
|
// to act on. Clients treat this purely as "refetch me", which is what makes
|
||||||
|
// the card appear with its original as the image source.
|
||||||
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
|
event_type: "upload-processed".to_string(),
|
||||||
|
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
||||||
);
|
);
|
||||||
// Refund + soft-delete (one tx, so v_feed excludes it) so a failed
|
// KEEP THE ROW. This used to soft-delete, which made a derivative failure
|
||||||
// transcode doesn't leave a permanently broken feed card or silently
|
// indistinguishable — to the guest — from their photo being deleted: they
|
||||||
// charge the uploader's quota. Then tell the uploader (upload-error
|
// got a `201 Created`, watched the card appear, and then watched it vanish.
|
||||||
// toast) and evict the card everywhere (upload-deleted).
|
// The row left `v_feed`, `find_visible_media` and BOTH keepsake archives,
|
||||||
|
// so the photo was gone from the product's core promise while its bytes sat
|
||||||
|
// on disk for 14 days waiting for a `cleanup_deleted_media` that nothing
|
||||||
|
// told anyone about. There is no host or admin screen listing compression
|
||||||
|
// failures, so recovery meant hand-written SQL that also had to re-add the
|
||||||
|
// refunded quota bytes. Against "0 lost uploads", that was silent per-photo
|
||||||
|
// loss on any error the ENOSPC arm above doesn't catch — a HEIC that slipped
|
||||||
|
// the allowlist, a truncated frame, an ffmpeg hiccup, a pool blip.
|
||||||
//
|
//
|
||||||
// The ORIGINAL IS DELIBERATELY KEPT. This path used to `remove_file` it
|
// This is exactly what the ENOSPC arm already does and documents as correct:
|
||||||
// unconditionally, which meant any transient error — a disk-full blip
|
// every client falls back to the original when `preview_url` and
|
||||||
// while saving a derivative, a pool hiccup, a panic in the image codec —
|
// `thumbnail_url` are NULL, so the photo stays visible and downloadable —
|
||||||
// irreversibly destroyed the guest's only copy of a photo they can never
|
// just uncompressed — and `backfill_stale_derivatives` retries it on the
|
||||||
// retake. The row is only soft-deleted, so keeping the bytes makes the
|
// next boot, now bounded by `derivative_attempts` so a poisoned row cannot
|
||||||
// upload fully recoverable; the file is orphaned rather than lost, and
|
// loop. The quota stays charged, which is correct: the bytes are still on
|
||||||
// the path is logged so it can be found. `backfill_stale_derivatives`
|
// disk and still the guest's.
|
||||||
// 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 {
|
|
||||||
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
|
||||||
}
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%upload_id,
|
%upload_id,
|
||||||
path = %worker.media_path.join(&original_path).display(),
|
path = %worker.media_path.join(&original_path).display(),
|
||||||
"original retained for recovery after compression failure"
|
"derivatives failed; the upload is kept and served from its original"
|
||||||
);
|
);
|
||||||
|
// `upload-error` still fires so the uploader learns the photo will look
|
||||||
|
// uncompressed. `upload-deleted` deliberately does NOT — nothing was
|
||||||
|
// deleted, and evicting the card was the visible half of the data loss.
|
||||||
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() })
|
||||||
.to_string(),
|
.to_string(),
|
||||||
});
|
});
|
||||||
|
// Tell every client to refetch, so the card re-renders from the original
|
||||||
|
// instead of sitting on a stale "processing" placeholder forever.
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-deleted".to_string(),
|
event_type: "upload-processed".to_string(),
|
||||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,17 +206,53 @@ impl CompressionWorker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `charge_lifetime_attempt` is true only for the FIRST `do_process` of a given
|
||||||
|
/// `process()` call, so the two budgets stay independent.
|
||||||
|
///
|
||||||
|
/// They were not. `MAX_PROCESS_ATTEMPTS` (in-request retries, 3) and
|
||||||
|
/// `MAX_DERIVATIVE_ATTEMPTS` (lifetime, 3) are equal, and every retry re-entered here and
|
||||||
|
/// charged the lifetime counter — so one request's three retries, six seconds apart,
|
||||||
|
/// exhausted the entire lifetime budget. A ten-second pool blip during the arrival burst
|
||||||
|
/// therefore stranded every photo whose worker was inside that window with no preview and no
|
||||||
|
/// display derivative, permanently, recoverable by nothing: the boot backfill re-selects them
|
||||||
|
/// and immediately gives up on the same exhausted counter.
|
||||||
|
///
|
||||||
|
/// The two exist to bound different things — "this request is flapping" versus "this INPUT is
|
||||||
|
/// poison" — and only the second should survive across requests.
|
||||||
async fn do_process(
|
async fn do_process(
|
||||||
&self,
|
&self,
|
||||||
upload_id: Uuid,
|
upload_id: Uuid,
|
||||||
original_path: &str,
|
original_path: &str,
|
||||||
mime_type: &str,
|
mime_type: &str,
|
||||||
|
charge_lifetime_attempt: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
|
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
|
||||||
|
|
||||||
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/") {
|
||||||
|
// Count the attempt BEFORE doing the work — see `begin_derivative_attempt`. If this
|
||||||
|
// input is the one that kills the container, this write is the only record that
|
||||||
|
// survives, and it is what stops the boot backfill replaying it forever. Charging on
|
||||||
|
// the first attempt preserves that: a container-killing input never reaches a second.
|
||||||
|
let charged = if charge_lifetime_attempt {
|
||||||
|
Upload::begin_derivative_attempt(&self.pool, upload_id).await?
|
||||||
|
} else {
|
||||||
|
// Already charged for this episode. Re-read the row only to notice it vanished.
|
||||||
|
Upload::derivative_attempts(&self.pool, upload_id).await?
|
||||||
|
};
|
||||||
|
match charged {
|
||||||
|
Some(attempts) if attempts > Self::MAX_DERIVATIVE_ATTEMPTS => {
|
||||||
|
anyhow::bail!(
|
||||||
|
"derivative generation gave up after {} attempt(s)",
|
||||||
|
attempts - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
// The row vanished while this task waited on the semaphore. Nothing to do, and
|
||||||
|
// reporting a failure would broadcast into a stream that no longer has a card.
|
||||||
|
None => return Ok(()),
|
||||||
|
}
|
||||||
let (preview_rel, display_rel) = self
|
let (preview_rel, display_rel) = self
|
||||||
.generate_image_derivatives(upload_id, &original, mime_type)
|
.generate_image_derivatives(upload_id, &original, mime_type)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -180,17 +269,39 @@ impl CompressionWorker {
|
|||||||
// this non-fatal would have been far worse than the bug. Every clip of a second or less
|
// this non-fatal would have been far worse than the bug. Every clip of a second or less
|
||||||
// would fail compression, exhaust its retries and be soft-deleted — a cosmetic defect
|
// would fail compression, exhaust its retries and be soft-deleted — a cosmetic defect
|
||||||
// turned into data loss, on exactly the mis-tap/Live-Photo clips guests produce most.
|
// turned into data loss, on exactly the mis-tap/Live-Photo clips guests produce most.
|
||||||
match self.generate_video_thumbnail(upload_id, &original).await? {
|
// Handling only the `Ok(None)` arm was not enough: the `?` on the call itself still
|
||||||
Some(thumb_rel) => {
|
// routed every OTHER poster failure into the give-up path. `extract_poster_frame`
|
||||||
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
// returns `Err` when ffmpeg is missing from the image, when it hangs on a truncated
|
||||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
// `.mov` and trips FFMPEG_TIMEOUT, or when `thumbnails/` can't be created — and
|
||||||
|
// `set_thumbnail_path` returns `Err` on any DB blip. None of those say anything about
|
||||||
|
// the video itself, yet each one destroyed it. Confirmed live: on a box with no ffmpeg
|
||||||
|
// the spawn error propagated, exhausted all three attempts and soft-deleted the clip.
|
||||||
|
//
|
||||||
|
// Nothing about a video post depends on the poster — `get_original` serves the file
|
||||||
|
// byte-for-byte and the tile falls back to the video element — so no failure in this
|
||||||
|
// branch may fail the upload.
|
||||||
|
match self.generate_video_thumbnail(upload_id, &original).await {
|
||||||
|
Ok(Some(thumb_rel)) => {
|
||||||
|
match Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await {
|
||||||
|
Ok(()) => tracing::info!("thumbnail generated for upload {upload_id}"),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
error = ?e, %upload_id,
|
||||||
|
"poster extracted but could not be recorded; the video keeps its own tile"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
None => {
|
}
|
||||||
|
Ok(None) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%upload_id,
|
%upload_id,
|
||||||
"no poster frame could be extracted; the video keeps its own tile"
|
"no poster frame could be extracted; the video keeps its own tile"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = ?e, %upload_id,
|
||||||
|
"poster extraction failed; the video keeps its own tile"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +316,24 @@ impl CompressionWorker {
|
|||||||
/// Longest edge of the phone-feed "preview" (data-saver default).
|
/// Longest edge of the phone-feed "preview" (data-saver default).
|
||||||
const PREVIEW_MAX_EDGE: u32 = 800;
|
const PREVIEW_MAX_EDGE: u32 = 800;
|
||||||
|
|
||||||
|
/// Above this pixel count the PNG original is stored as uploaded, unoptimised.
|
||||||
|
///
|
||||||
|
/// oxipng's peak memory scales with PIXELS, not file size: it decodes the PNG itself and
|
||||||
|
/// then evaluates row filters, each trial holding a full-size buffer. That is why a 2.82
|
||||||
|
/// MiB file could measure 1250 MiB of peak RSS inside a 1 GiB container — smooth,
|
||||||
|
/// synthetic content compresses to almost nothing on disk while still being 8000x8000.
|
||||||
|
/// 8 MP covers every real phone photo; beyond it we decline the (lossless, cosmetic)
|
||||||
|
/// saving rather than risk the OOM kill.
|
||||||
|
const OXIPNG_MAX_PIXELS: u64 = 8_000_000;
|
||||||
|
|
||||||
|
/// Wall-clock ceiling for one oxipng run.
|
||||||
|
///
|
||||||
|
/// Bounds TIME, NOT MEMORY — oxipng checks the deadline between trials, so a single trial
|
||||||
|
/// still allocates in full. The pixel gate above and the sequential build (see
|
||||||
|
/// `default-features = false` in Cargo.toml) are what bound memory. Do not treat this
|
||||||
|
/// constant as the OOM fix.
|
||||||
|
const OXIPNG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||||
|
|
||||||
/// Decode the image ONCE and emit both derivatives — the 800px `preview` (phone feed)
|
/// Decode the image ONCE and emit both derivatives — the 800px `preview` (phone feed)
|
||||||
/// and the 2048px `display` (diashow). Returns `(preview_rel, display_rel)`.
|
/// and the 2048px `display` (diashow). Returns `(preview_rel, display_rel)`.
|
||||||
async fn generate_image_derivatives(
|
async fn generate_image_derivatives(
|
||||||
@@ -223,53 +352,40 @@ impl CompressionWorker {
|
|||||||
let display_path = displays_dir.join(&filename);
|
let display_path = displays_dir.join(&filename);
|
||||||
let original = original.to_path_buf();
|
let original = original.to_path_buf();
|
||||||
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;
|
// Estimate the peak from the HEADER (no pixels decoded — the same kind of cheap probe
|
||||||
|
// the upload handler already does via `exceeds_decode_budget`) and, if this job is a
|
||||||
|
// giant, take the exclusive permit so it cannot overlap another giant. Held for the
|
||||||
|
// whole blocking section, released on drop including on error.
|
||||||
|
let estimate = crate::services::imaging::estimated_processing_peak_bytes(
|
||||||
|
&original,
|
||||||
|
Self::DISPLAY_MAX_EDGE,
|
||||||
|
);
|
||||||
|
let _heavy_permit = match estimate {
|
||||||
|
Some(bytes) if bytes > crate::services::imaging::HEAVY_IMAGE_BYTES => {
|
||||||
|
tracing::debug!(
|
||||||
|
%upload_id,
|
||||||
|
estimated_mib = bytes / (1024 * 1024),
|
||||||
|
"waiting for the heavy-image permit"
|
||||||
|
);
|
||||||
|
Some(
|
||||||
|
crate::services::imaging::HEAVY_IMAGE_PERMITS
|
||||||
|
.acquire()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
// 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 || {
|
||||||
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
write_image_derivatives(
|
||||||
// services::imaging for why neither may be skipped.
|
upload_id,
|
||||||
let img = crate::services::imaging::decode_oriented(&original)?;
|
&original,
|
||||||
|
&mime_owned,
|
||||||
// Preview: max 800px, preserving aspect ratio (data-saver feed).
|
&preview_path,
|
||||||
img.resize(
|
&display_path,
|
||||||
preview_max,
|
|
||||||
preview_max,
|
|
||||||
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 mime_owned == "image/png" {
|
|
||||||
let opts = oxipng::Options::from_preset(2);
|
|
||||||
let _ = oxipng::optimize(
|
|
||||||
&oxipng::InFile::Path(original),
|
|
||||||
&oxipng::OutFile::Path {
|
|
||||||
path: None,
|
|
||||||
preserve_attrs: true,
|
|
||||||
},
|
|
||||||
&opts,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
|
|
||||||
@@ -290,17 +406,29 @@ impl CompressionWorker {
|
|||||||
///
|
///
|
||||||
/// Unlike the failure path in `process`, a backfill error is logged and skipped — it must
|
/// 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.
|
/// NEVER destroy or soft-delete an upload that already has a working preview.
|
||||||
|
///
|
||||||
|
/// Bounded in three ways, all of them load-bearing on a box that restarts itself:
|
||||||
|
/// `derivative_attempts` stops a fatal row being replayed on every boot, `BACKFILL_BATCH`
|
||||||
|
/// stops one start queueing unbounded work, and the whole thing runs as ONE task walking
|
||||||
|
/// the rows sequentially rather than N tasks racing for the same semaphore.
|
||||||
pub async fn backfill_stale_derivatives(&self) {
|
pub async fn backfill_stale_derivatives(&self) {
|
||||||
|
// `original_path IS NOT NULL` was dead — the column is NOT NULL. What actually needs
|
||||||
|
// excluding is the blanked path `cleanup_deleted_media` leaves behind.
|
||||||
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
|
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
|
||||||
"SELECT id, original_path, mime_type FROM upload
|
"SELECT id, original_path, mime_type FROM upload
|
||||||
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||||
AND original_path IS NOT NULL
|
AND original_path <> ''
|
||||||
|
AND derivative_attempts < $2
|
||||||
AND (
|
AND (
|
||||||
(display_path IS NULL AND preview_path IS NOT NULL)
|
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||||
OR derivatives_rev < $1
|
OR derivatives_rev < $1
|
||||||
)",
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $3",
|
||||||
)
|
)
|
||||||
.bind(Self::DERIVATIVES_REV)
|
.bind(Self::DERIVATIVES_REV)
|
||||||
|
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||||
|
.bind(Self::BACKFILL_BATCH)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
let rows = match rows {
|
let rows = match rows {
|
||||||
@@ -310,14 +438,32 @@ impl CompressionWorker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.report_exhausted_derivatives().await;
|
||||||
|
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("regenerating derivatives for {} upload(s)", rows.len());
|
tracing::info!("regenerating derivatives for {} upload(s)", rows.len());
|
||||||
for (id, original_path, mime_type) in rows {
|
|
||||||
|
// ONE task for the whole batch. The previous shape spawned a task per row, so a large
|
||||||
|
// backlog created thousands of live tasks that each held a pool handle and queued on
|
||||||
|
// the same two semaphore permits, competing with live uploads for the entire boot.
|
||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
for (id, original_path, mime_type) in rows {
|
||||||
let _permit = worker.semaphore.acquire().await;
|
let _permit = worker.semaphore.acquire().await;
|
||||||
|
// Write-ahead, exactly as in the live path: if this row is the one that kills
|
||||||
|
// the process, this increment is the only thing that outlives the SIGKILL.
|
||||||
|
match Upload::begin_derivative_attempt(&worker.pool, id).await {
|
||||||
|
Ok(Some(n)) if n > Self::MAX_DERIVATIVE_ATTEMPTS => continue,
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, "could not record a backfill attempt; skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let original = worker.media_path.join(&original_path);
|
let original = worker.media_path.join(&original_path);
|
||||||
match worker
|
match worker
|
||||||
.generate_image_derivatives(id, &original, &mime_type)
|
.generate_image_derivatives(id, &original, &mime_type)
|
||||||
@@ -326,6 +472,8 @@ impl CompressionWorker {
|
|||||||
Ok((preview_rel, display_rel)) => {
|
Ok((preview_rel, display_rel)) => {
|
||||||
let _ = Upload::set_preview_path(&worker.pool, id, &preview_rel).await;
|
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_display_path(&worker.pool, id, &display_rel).await;
|
||||||
|
// Clears derivative_attempts too, so a row that failed transiently is
|
||||||
|
// not one boot closer to being abandoned.
|
||||||
let _ =
|
let _ =
|
||||||
Upload::set_derivatives_rev(&worker.pool, id, Self::DERIVATIVES_REV)
|
Upload::set_derivatives_rev(&worker.pool, id, Self::DERIVATIVES_REV)
|
||||||
.await;
|
.await;
|
||||||
@@ -333,13 +481,134 @@ impl CompressionWorker {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Leave the existing derivatives and the original intact; this row is
|
// Leave the existing derivatives and the original intact; this row is
|
||||||
// simply retried on the next start. The rev stays behind, which is the
|
// retried on the next start until its attempt budget runs out. The rev
|
||||||
// marker that it still needs doing.
|
// stays behind, which is the marker that it still needs doing.
|
||||||
tracing::warn!(error = ?e, %id, "derivative backfill failed; leaving as-is");
|
tracing::warn!(error = ?e, %id, "derivative backfill failed; leaving as-is");
|
||||||
|
let _ =
|
||||||
|
Upload::record_derivative_failure(&worker.pool, id, &format!("{e:#}"))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-extract poster frames for videos that never got one.
|
||||||
|
///
|
||||||
|
/// A video interrupted by a restart is stranded: `startup_recovery` flips its
|
||||||
|
/// `compression_status` from `processing` to `failed` and nothing re-enqueues it, so
|
||||||
|
/// `thumbnail_path` stays NULL forever while the clip itself plays fine. The feed shows a
|
||||||
|
/// posterless tile for the rest of the event, and after
|
||||||
|
/// `FAILED_ORIGINAL_RETENTION_DAYS` the reclaim sweep is entitled to the original.
|
||||||
|
///
|
||||||
|
/// Shares `derivative_attempts` with the image backfill on purpose. Note the consequence,
|
||||||
|
/// which is intended rather than a bug to fix later: `extract_poster_frame` returning
|
||||||
|
/// `Ok(false)` is a NORMAL, permanent outcome for a sub-second clip (Live Photos,
|
||||||
|
/// mis-taps), and since the counter is write-ahead and only cleared by a real success,
|
||||||
|
/// those clips stop being re-ffmpeg'd on every boot once the budget is spent.
|
||||||
|
pub async fn backfill_video_posters(&self) {
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, String)>(
|
||||||
|
"SELECT id, original_path FROM upload
|
||||||
|
WHERE deleted_at IS NULL AND mime_type LIKE 'video/%'
|
||||||
|
AND thumbnail_path IS NULL
|
||||||
|
AND original_path <> ''
|
||||||
|
AND derivative_attempts < $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2",
|
||||||
|
)
|
||||||
|
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||||
|
.bind(Self::BACKFILL_BATCH)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "video poster backfill query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("re-extracting posters for {} video(s)", rows.len());
|
||||||
|
|
||||||
|
let worker = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for (id, original_path) in rows {
|
||||||
|
let _permit = worker.semaphore.acquire().await;
|
||||||
|
match Upload::begin_derivative_attempt(&worker.pool, id).await {
|
||||||
|
Ok(Some(n)) if n > Self::MAX_DERIVATIVE_ATTEMPTS => continue,
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, "could not record a poster attempt; skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let original = worker.media_path.join(&original_path);
|
||||||
|
match worker.generate_video_thumbnail(id, &original).await {
|
||||||
|
Ok(Some(thumb_rel)) => {
|
||||||
|
if Upload::set_thumbnail_path(&worker.pool, id, &thumb_rel)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
// Clears the attempt counter: a video that eventually succeeded
|
||||||
|
// must not carry a budget scar into a future pipeline revision.
|
||||||
|
let _ = Upload::set_derivatives_rev(
|
||||||
|
&worker.pool,
|
||||||
|
id,
|
||||||
|
Self::DERIVATIVES_REV,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tracing::info!("poster regenerated for upload {id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No frame at all — normal for a very short clip. The tile stays
|
||||||
|
// posterless and the attempt is spent, which is what stops the retry.
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::debug!(%id, "still no poster frame; leaving the tile as-is");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, "poster backfill failed; leaving as-is");
|
||||||
|
let _ =
|
||||||
|
Upload::record_derivative_failure(&worker.pool, id, &format!("{e:#}"))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Say out loud, once per boot, that some uploads have stopped being retried.
|
||||||
|
///
|
||||||
|
/// Without this the give-up is invisible: the loop stops (which is the point) but the
|
||||||
|
/// affected photos keep a stale or missing derivative forever with nothing to notice. The
|
||||||
|
/// originals are untouched, so this is recoverable once the cause is fixed — reset
|
||||||
|
/// `derivative_attempts` to 0 and restart.
|
||||||
|
async fn report_exhausted_derivatives(&self) {
|
||||||
|
let exhausted: Result<i64, _> = sqlx::query_scalar(
|
||||||
|
"SELECT count(*) FROM upload
|
||||||
|
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||||
|
AND derivative_attempts >= $2
|
||||||
|
AND (
|
||||||
|
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||||
|
OR derivatives_rev < $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(Self::DERIVATIVES_REV)
|
||||||
|
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
if let Ok(count) = exhausted
|
||||||
|
&& count > 0
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
count,
|
||||||
|
"{count} upload(s) exhausted derivative regeneration and will no longer be \
|
||||||
|
retried; their originals are intact — see upload.derivative_last_error, fix \
|
||||||
|
the cause, then reset derivative_attempts to 0 and restart"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the feed poster for a video. `Ok(None)` when the clip yields no frame — see
|
/// Extract the feed poster for a video. `Ok(None)` when the clip yields no frame — see
|
||||||
@@ -362,3 +631,250 @@ impl CompressionWorker {
|
|||||||
Ok(produced.then(|| format!("thumbnails/{thumb_filename}")))
|
Ok(produced.then(|| format!("thumbnails/{thumb_filename}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The blocking half of [`CompressionWorker::generate_image_derivatives`]: decode once, write
|
||||||
|
/// both derivatives, then optionally shrink a PNG original in place.
|
||||||
|
///
|
||||||
|
/// A free function rather than an inline closure so its memory behaviour is directly testable —
|
||||||
|
/// this is the code path that OOM-killed the container, and the fix is a scoping property that a
|
||||||
|
/// future edit could silently undo.
|
||||||
|
fn write_image_derivatives(
|
||||||
|
upload_id: Uuid,
|
||||||
|
original: &Path,
|
||||||
|
mime_type: &str,
|
||||||
|
preview_path: &Path,
|
||||||
|
display_path: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
let preview_max = CompressionWorker::PREVIEW_MAX_EDGE;
|
||||||
|
let display_max = CompressionWorker::DISPLAY_MAX_EDGE;
|
||||||
|
|
||||||
|
// THE FULL-SIZE DECODE IS SCOPED TO THIS BLOCK ON PURPOSE, and the block yields the
|
||||||
|
// DISPLAY derivative rather than the original.
|
||||||
|
//
|
||||||
|
// `img` is up to 256 MiB (imaging::decode_limits max_alloc) and `resize` only BORROWS it,
|
||||||
|
// so it used to stay alive through both resizes AND the oxipng call below — which decodes
|
||||||
|
// the PNG a second time and holds a full-size buffer per filter trial. That measured
|
||||||
|
// ~1250 MiB of peak RSS for a 2.8 MiB input, inside a 1 GiB cgroup: the container was
|
||||||
|
// SIGKILLed, taking every SSE stream and every in-flight upload with it.
|
||||||
|
//
|
||||||
|
// A block rather than a bare `drop(img)` because a `drop` call is one careless edit away
|
||||||
|
// from being removed as redundant-looking — and note the `else` arm MOVES `img` out, which
|
||||||
|
// is what makes "the block's value is the only survivor" true in both arms.
|
||||||
|
let (display, width, height) = {
|
||||||
|
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||||
|
// services::imaging for why neither may be skipped.
|
||||||
|
let img = crate::services::imaging::decode_oriented(original)?;
|
||||||
|
let (width, height) = (img.width(), img.height());
|
||||||
|
|
||||||
|
// 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 width > display_max || height > display_max {
|
||||||
|
img.resize(
|
||||||
|
display_max,
|
||||||
|
display_max,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
(display, width, height)
|
||||||
|
};
|
||||||
|
|
||||||
|
display
|
||||||
|
.save_with_format(display_path, image::ImageFormat::Jpeg)
|
||||||
|
.context("failed to save display")?;
|
||||||
|
|
||||||
|
// Preview: max 800px, derived from the DISPLAY, not from the original.
|
||||||
|
//
|
||||||
|
// Both derivatives used to resize the full-size decode independently, so a 8000x8000
|
||||||
|
// original paid for two full-size Lanczos passes and their intermediates — measured 520
|
||||||
|
// MiB peak even after the scoping fix above, which two concurrent workers cannot fit in a
|
||||||
|
// 1 GiB container. Chaining 8000 -> 2048 -> 800 makes the second pass operate on 2048px
|
||||||
|
// input, and the full-size buffer is already freed by the time it runs. Quality is not the
|
||||||
|
// trade-off here: a staged Lanczos3 downscale to 800px is visually indistinguishable from
|
||||||
|
// a single-step one (and is a standard technique for large ratios).
|
||||||
|
display
|
||||||
|
.resize(
|
||||||
|
preview_max,
|
||||||
|
preview_max,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
)
|
||||||
|
.save_with_format(preview_path, image::ImageFormat::Jpeg)
|
||||||
|
.context("failed to save preview")?;
|
||||||
|
drop(display);
|
||||||
|
|
||||||
|
let pixels = u64::from(width) * u64::from(height);
|
||||||
|
|
||||||
|
// If the original is PNG, try lossless compression in place — but only when its pixel count
|
||||||
|
// is inside the budget, and never for longer than OXIPNG_TIMEOUT. This is a best-effort size
|
||||||
|
// saving: declining it costs disk, while attempting it unbounded cost the whole container.
|
||||||
|
if mime_type == "image/png" {
|
||||||
|
if pixels <= CompressionWorker::OXIPNG_MAX_PIXELS {
|
||||||
|
let mut opts = oxipng::Options::from_preset(2);
|
||||||
|
opts.timeout = Some(CompressionWorker::OXIPNG_TIMEOUT);
|
||||||
|
let _ = oxipng::optimize(
|
||||||
|
&oxipng::InFile::Path(original.to_path_buf()),
|
||||||
|
&oxipng::OutFile::Path {
|
||||||
|
path: None,
|
||||||
|
preserve_attrs: true,
|
||||||
|
},
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
%upload_id, pixels,
|
||||||
|
"skipping oxipng: above the pixel budget; the original is stored as uploaded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Peak resident set of THIS process, in bytes, from `/proc/self/status`.
|
||||||
|
fn peak_rss_bytes() -> u64 {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").expect("procfs");
|
||||||
|
let line = status
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("VmHWM:"))
|
||||||
|
.expect("VmHWM");
|
||||||
|
let kb: u64 = line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.expect("VmHWM value");
|
||||||
|
kb * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the kernel's peak-RSS watermark so the measurement covers only what follows.
|
||||||
|
/// Linux 4.0+; writing "5" to `clear_refs` resets `VmHWM` to the current RSS.
|
||||||
|
fn reset_peak_rss() {
|
||||||
|
let _ = std::fs::write("/proc/self/clear_refs", "5");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pixel gate has to sit below what the axis limits allow, or it can never fire.
|
||||||
|
#[test]
|
||||||
|
fn the_oxipng_gate_is_reachable_within_the_decode_limits() {
|
||||||
|
const _: () = {
|
||||||
|
// imaging::decode_limits permits 12_000 x 12_000 = 144 MP. A gate above that would
|
||||||
|
// never skip anything.
|
||||||
|
assert!(CompressionWorker::OXIPNG_MAX_PIXELS < 12_000 * 12_000);
|
||||||
|
// ...and it must stay above a 48 MP camera, so real photos still get optimised.
|
||||||
|
assert!(CompressionWorker::OXIPNG_MAX_PIXELS >= 8_000_000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heavy-image gate has to classify the two cases the way the sizing assumed:
|
||||||
|
/// an ordinary phone photo must NOT serialise, and the giant must.
|
||||||
|
#[test]
|
||||||
|
fn the_heavy_gate_separates_a_phone_photo_from_a_giant() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("es-heavy-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// 12 MP, the shape of a default phone capture.
|
||||||
|
let ordinary = dir.join("ordinary.jpg");
|
||||||
|
image::RgbImage::new(4032, 3024).save(&ordinary).unwrap();
|
||||||
|
let ordinary_peak = crate::services::imaging::estimated_processing_peak_bytes(
|
||||||
|
&ordinary,
|
||||||
|
CompressionWorker::DISPLAY_MAX_EDGE,
|
||||||
|
)
|
||||||
|
.expect("header readable");
|
||||||
|
assert!(
|
||||||
|
ordinary_peak <= crate::services::imaging::HEAVY_IMAGE_BYTES,
|
||||||
|
"a 12 MP photo estimated at {} MiB would serialise the common path",
|
||||||
|
ordinary_peak / 1048576
|
||||||
|
);
|
||||||
|
|
||||||
|
// The 64 MP RGBA case that measured ~516 MiB peak.
|
||||||
|
let giant = dir.join("giant.png");
|
||||||
|
image::RgbaImage::new(8000, 8000).save(&giant).unwrap();
|
||||||
|
let giant_peak = crate::services::imaging::estimated_processing_peak_bytes(
|
||||||
|
&giant,
|
||||||
|
CompressionWorker::DISPLAY_MAX_EDGE,
|
||||||
|
)
|
||||||
|
.expect("header readable");
|
||||||
|
assert!(
|
||||||
|
giant_peak > crate::services::imaging::HEAVY_IMAGE_BYTES,
|
||||||
|
"an 8000x8000 RGBA original estimated at only {} MiB would be allowed to run \
|
||||||
|
concurrently with another one — 2x its real ~516 MiB peak does not fit in 1 GiB",
|
||||||
|
giant_peak / 1048576
|
||||||
|
);
|
||||||
|
// The estimate must also be in the right ballpark, not merely on the right side of the
|
||||||
|
// threshold: 244 MiB decode + 262 MiB f32 resize intermediate.
|
||||||
|
assert!(
|
||||||
|
(400..700).contains(&(giant_peak / 1048576)),
|
||||||
|
"estimate {} MiB is far from the measured ~516 MiB peak",
|
||||||
|
giant_peak / 1048576
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OOM that took the container down, measured rather than argued.
|
||||||
|
///
|
||||||
|
/// An 8000x8000 RGBA PNG passes admission: 256,000,000 bytes is just under the 256 MiB
|
||||||
|
/// `max_alloc`, and smooth content is a few MB on disk, far under any size cap. The old
|
||||||
|
/// code kept that ~244 MiB decode alive across an unbounded, multi-threaded oxipng run and
|
||||||
|
/// peaked at ~1250 MiB — inside a 1 GiB cgroup. Being SIGKILLed there is not a blip: the
|
||||||
|
/// row was already committed, so the boot backfill replayed the identical workload on every
|
||||||
|
/// restart.
|
||||||
|
///
|
||||||
|
/// `#[ignore]` because it allocates ~250 MiB and takes a few seconds. Run explicitly:
|
||||||
|
/// cargo test --release oom -- --ignored --nocapture --test-threads=1
|
||||||
|
/// It must run ALONE — `VmHWM` is per process, so a concurrent test would pollute it.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "heavy: allocates ~250 MiB; run with --ignored --test-threads=1"]
|
||||||
|
fn a_large_png_stays_far_below_the_container_limit() {
|
||||||
|
const EDGE: u32 = 8_000;
|
||||||
|
let dir = std::env::temp_dir().join(format!("es-oom-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let original = dir.join("big.png");
|
||||||
|
|
||||||
|
// Smooth gradient: ~244 MiB decoded, a couple of MB on disk. That gap is the whole
|
||||||
|
// point — file size tells you nothing about what a PNG costs to process.
|
||||||
|
{
|
||||||
|
let mut buf = image::RgbaImage::new(EDGE, EDGE);
|
||||||
|
for (x, y, px) in buf.enumerate_pixels_mut() {
|
||||||
|
*px = image::Rgba([(x >> 5) as u8, (y >> 5) as u8, ((x + y) >> 6) as u8, 255]);
|
||||||
|
}
|
||||||
|
buf.save(&original).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything above is fixture setup, not the code under test.
|
||||||
|
reset_peak_rss();
|
||||||
|
let before = peak_rss_bytes();
|
||||||
|
|
||||||
|
write_image_derivatives(
|
||||||
|
Uuid::new_v4(),
|
||||||
|
&original,
|
||||||
|
"image/png",
|
||||||
|
&dir.join("preview.jpg"),
|
||||||
|
&dir.join("display.jpg"),
|
||||||
|
)
|
||||||
|
.expect("derivatives");
|
||||||
|
|
||||||
|
let peak = peak_rss_bytes();
|
||||||
|
let on_disk = std::fs::metadata(&original).unwrap().len();
|
||||||
|
eprintln!(
|
||||||
|
"input {:.2} MiB on disk ({EDGE}x{EDGE}); peak RSS {:.0} MiB (was {:.0} MiB before)",
|
||||||
|
on_disk as f64 / 1048576.0,
|
||||||
|
peak as f64 / 1048576.0,
|
||||||
|
before as f64 / 1048576.0
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(dir.join("preview.jpg").exists() && dir.join("display.jpg").exists());
|
||||||
|
// The container gets 1 GiB and runs two of these concurrently. 600 MiB is a generous
|
||||||
|
// ceiling that the old code (~1250 MiB) could not have met.
|
||||||
|
assert!(
|
||||||
|
peak < 600 * 1024 * 1024,
|
||||||
|
"peak RSS {} MiB — the decode is being held across oxipng again, or the pixel \
|
||||||
|
gate stopped firing",
|
||||||
|
peak / 1048576
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,3 +148,104 @@ pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
|||||||
_ => default,
|
_ => default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod seed_tests {
|
||||||
|
/// The value a fresh database actually ends up with for `key`, by replaying the migrations.
|
||||||
|
///
|
||||||
|
/// This exists because a `config::get_*` default is only a fallback for a MISSING key, and the
|
||||||
|
/// migrations seed nearly every key there is. So the literal in the handler is dead code on any
|
||||||
|
/// real install, and changing it changes nothing — which is exactly what happened to
|
||||||
|
/// `join_ip_rate_per_min`: it was raised 60 → 300 in `auth/handlers.rs` to stop one QR-code
|
||||||
|
/// burst from locking the venue out of `/join`, shipped, and did nothing at all, because
|
||||||
|
/// migration 017 seeds 60 and the seed wins. Nothing in the test suite could see it: the e2e
|
||||||
|
/// regression guard fires 12 concurrent joins, which is green at 60 and at 300 alike.
|
||||||
|
fn effective_seed(key: &str) -> Option<String> {
|
||||||
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
|
||||||
|
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.expect("migrations directory")
|
||||||
|
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||||
|
.filter(|p| p.to_string_lossy().ends_with(".up.sql"))
|
||||||
|
.collect();
|
||||||
|
// Version order: migrations are applied in filename order and later ones override.
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut value: Option<String> = None;
|
||||||
|
for path in files {
|
||||||
|
let sql = std::fs::read_to_string(&path).expect("readable migration");
|
||||||
|
for line in sql.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with("--") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Seed form: ('key', 'value')
|
||||||
|
if let Some(rest) = line.strip_prefix(&format!("('{key}',"))
|
||||||
|
&& let Some(v) = rest.split('\'').nth(1)
|
||||||
|
{
|
||||||
|
value = Some(v.to_string());
|
||||||
|
}
|
||||||
|
// Update form: UPDATE config SET value = 'new' WHERE key = 'key' AND value = 'old'
|
||||||
|
if line.starts_with("UPDATE config SET value")
|
||||||
|
&& line.contains(&format!("key = '{key}'"))
|
||||||
|
&& let Some(new) = line.split('\'').nth(1)
|
||||||
|
{
|
||||||
|
let scoped_to = line
|
||||||
|
.rsplit_once("AND value = '")
|
||||||
|
.and_then(|(_, tail)| tail.split('\'').next().map(|s| s.to_string()));
|
||||||
|
// Only applies if the current value still matches the scope it was written for.
|
||||||
|
if scoped_to.is_none() || scoped_to.as_deref() == value.as_deref() {
|
||||||
|
value = Some(new.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invariant, not the number: `join_ip:{ip}` is keyed on an address the WHOLE VENUE shares
|
||||||
|
/// behind NAT, and `/join` is the one screen with no auto-retry. A ceiling near the size of the
|
||||||
|
/// party is a ceiling on the party. Asserted against the effective seed rather than the code
|
||||||
|
/// default precisely because the code default is what silently did not apply.
|
||||||
|
#[test]
|
||||||
|
fn the_join_ceiling_a_real_install_gets_is_sized_for_a_whole_venue_arriving_at_once() {
|
||||||
|
let seeded = effective_seed("join_ip_rate_per_min")
|
||||||
|
.expect("join_ip_rate_per_min must be seeded by a migration");
|
||||||
|
let seeded: usize = seeded.parse().expect("numeric");
|
||||||
|
assert!(
|
||||||
|
seeded >= 300,
|
||||||
|
"a fresh database ends up with join_ip_rate_per_min = {seeded}. Every guest shares one \
|
||||||
|
NAT address, so this is the ceiling for the entire party scanning one QR code. Raise \
|
||||||
|
it with a value-scoped UPDATE migration (see 030) — changing the default in \
|
||||||
|
auth/handlers.rs does nothing, because the seed wins."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pins the other half of the same trap: the seeded value must not exceed the ceiling the
|
||||||
|
/// handler clamps to, or an operator reading `GET /admin/config` sees a number that is not the
|
||||||
|
/// one being enforced.
|
||||||
|
#[test]
|
||||||
|
fn the_seeded_recover_name_ceiling_is_within_what_the_handler_will_honour() {
|
||||||
|
let seeded = effective_seed("recover_name_rate_per_15min")
|
||||||
|
.expect("recover_name_rate_per_15min must be seeded by a migration");
|
||||||
|
let seeded: usize = seeded.parse().expect("numeric");
|
||||||
|
assert!(
|
||||||
|
seeded <= crate::auth::handlers::RECOVER_NAME_CEILING_MAX,
|
||||||
|
"seeded recover_name_rate_per_15min = {seeded} exceeds RECOVER_NAME_CEILING_MAX = {}; \
|
||||||
|
the handler clamps at the point of use, so the advertised value would be a lie.",
|
||||||
|
crate::auth::handlers::RECOVER_NAME_CEILING_MAX
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parser itself, against a value migration 015 really does change. Without this a bug in
|
||||||
|
/// `effective_seed` makes both tests above vacuously green.
|
||||||
|
#[test]
|
||||||
|
fn the_seed_parser_follows_a_value_through_a_later_update_migration() {
|
||||||
|
assert_eq!(
|
||||||
|
effective_seed("upload_rate_per_hour").as_deref(),
|
||||||
|
Some("100"),
|
||||||
|
"005 seeds 10 and 015 raises it to 100; reading 10 here means the UPDATE form is not \
|
||||||
|
being applied, and every assertion built on this helper is worthless."
|
||||||
|
);
|
||||||
|
assert_eq!(effective_seed("no_such_key_anywhere"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -39,8 +39,10 @@ fn decode_limits() -> image::Limits {
|
|||||||
///
|
///
|
||||||
/// Deliberately narrow. Only the `ImageError` variants that are a property of the *input*
|
/// 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
|
/// 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
|
/// attempts. `IoError` is excluded on purpose — EMFILE under load, or a momentarily
|
||||||
/// EMFILE under load, is exactly the transient case the retry exists for.
|
/// unreadable file, is exactly the transient case the retry exists for. A FULL disk is the
|
||||||
|
/// one io error that must not be retried either, but for a different reason and with a
|
||||||
|
/// different remedy; see [`is_storage_full_error`].
|
||||||
pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
||||||
err.chain().any(|cause| {
|
err.chain().any(|cause| {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -54,6 +56,34 @@ pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the failure is the media filesystem being out of space.
|
||||||
|
///
|
||||||
|
/// Deliberately separate from [`is_permanent_image_error`], which is about the *input*. ENOSPC
|
||||||
|
/// is about the *host*, and it is the one failure the retry loop actively makes worse: a disk
|
||||||
|
/// does not drain during six seconds of backoff, so all three attempts fail identically while
|
||||||
|
/// holding a compression permit that photos are queued behind.
|
||||||
|
///
|
||||||
|
/// The give-up path it fed was worse still. It refunded the guest's quota and soft-deleted the
|
||||||
|
/// row while deliberately RETAINING the original — so the bytes stayed on the full disk, the
|
||||||
|
/// photo vanished from the feed seconds after a `201 Created`, and the guest was handed back
|
||||||
|
/// the quota to upload it again into the same full disk. Each round shrank free space further.
|
||||||
|
pub fn is_storage_full_error(err: &anyhow::Error) -> bool {
|
||||||
|
fn is_full(io: &std::io::Error) -> bool {
|
||||||
|
// `StorageFull` is the portable classification; the raw ENOSPC catches the paths where
|
||||||
|
// the OS error was never mapped to a named kind.
|
||||||
|
io.kind() == std::io::ErrorKind::StorageFull || io.raw_os_error() == Some(28)
|
||||||
|
}
|
||||||
|
err.chain().any(|cause| {
|
||||||
|
// `image` wraps the io error in its own variant rather than exposing it as a source,
|
||||||
|
// so the plain downcast alone would miss every derivative-write failure.
|
||||||
|
cause.downcast_ref::<std::io::Error>().is_some_and(is_full)
|
||||||
|
|| matches!(
|
||||||
|
cause.downcast_ref::<image::ImageError>(),
|
||||||
|
Some(image::ImageError::IoError(io)) if is_full(io)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a decoder for `path` with the budget enforced, WITHOUT reading any pixels.
|
/// 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
|
/// Single source of truth for "may this image be decoded at all": both the upload
|
||||||
@@ -88,6 +118,40 @@ fn decoder_within_budget(path: &Path) -> Result<impl image::ImageDecoder> {
|
|||||||
Ok(decoder)
|
Ok(decoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rough peak heap an image will cost to turn into derivatives, read from the HEADER only —
|
||||||
|
/// no pixels are decoded. `None` when the header can't be read or the image is over budget
|
||||||
|
/// (the caller is about to fail on it anyway).
|
||||||
|
///
|
||||||
|
/// Two terms, and the second is the one that surprises:
|
||||||
|
///
|
||||||
|
/// - the decoded buffer, `width * height * channels`; and
|
||||||
|
/// - the resize intermediate. `image`'s Lanczos3 path accumulates in `f32`, so the buffer
|
||||||
|
/// between the horizontal and vertical passes is `new_width * old_height * 4 channels * 4
|
||||||
|
/// bytes` — 16 bytes per pixel-row-slot, not the 4 the output uses. For an 8000x8000
|
||||||
|
/// original that is 262 MiB on top of a 244 MiB decode, measured. It is bigger than the
|
||||||
|
/// decode for any tall image, which is why "the decode is bounded by max_alloc" was never
|
||||||
|
/// the whole story.
|
||||||
|
///
|
||||||
|
/// Used to decide whether an image is heavy enough to need exclusive use of the box's memory
|
||||||
|
/// headroom, NOT to reject anything.
|
||||||
|
pub fn estimated_processing_peak_bytes(path: &Path, display_edge: u32) -> Option<u64> {
|
||||||
|
let decoder = decoder_within_budget(path).ok()?;
|
||||||
|
let (width, height) = decoder.dimensions();
|
||||||
|
let decoded = decoder.total_bytes();
|
||||||
|
|
||||||
|
// Aspect-preserving fit into `display_edge`, matching DynamicImage::resize. No downscale
|
||||||
|
// means no intermediate at all.
|
||||||
|
let intermediate = if width > display_edge || height > display_edge {
|
||||||
|
let ratio = f64::from(display_edge) / f64::from(width.max(height));
|
||||||
|
let new_width = (f64::from(width) * ratio).round().max(1.0) as u64;
|
||||||
|
new_width * u64::from(height) * 16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(decoded.saturating_add(intermediate))
|
||||||
|
}
|
||||||
|
|
||||||
/// Megapixels an image would decode to, or `None` if its header can't be read. Used only
|
/// 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.
|
/// to put a concrete number in the message the guest sees.
|
||||||
pub fn megapixels(path: &Path) -> Option<f64> {
|
pub fn megapixels(path: &Path) -> Option<f64> {
|
||||||
@@ -141,6 +205,32 @@ pub fn decode_oriented(path: &Path) -> Result<DynamicImage> {
|
|||||||
Ok(img)
|
Ok(img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Process-wide serialisation for memory-heavy image work.
|
||||||
|
///
|
||||||
|
/// The `app` container gets 1 GiB. A single 8000x8000 original measures ~516 MiB peak even with
|
||||||
|
/// the decode correctly scoped, so two overlapping giants is an OOM kill — and the kernel kills
|
||||||
|
/// the whole process, dropping every SSE stream and stranding every in-flight upload.
|
||||||
|
///
|
||||||
|
/// GLOBAL rather than a field on `CompressionWorker`, because the constraint is the container's
|
||||||
|
/// memory and there is more than one producer of this work. The export's own image path
|
||||||
|
/// (`services::export`) decodes and resizes every photo in the gallery — a thumbnail for each,
|
||||||
|
/// plus a 2000px re-encode for every original over 5 MB — and it ran in a bare `spawn_blocking`
|
||||||
|
/// with no permit at all. So "host taps Freigeben while the last phone photos are still
|
||||||
|
/// compressing" put an export decode and a heavy compression job in the same cgroup at the same
|
||||||
|
/// time, which is the scenario the permit exists to make impossible. Worse, it is self-repeating:
|
||||||
|
/// the OOM kill marks the export failed, and `recover_exports` re-spawns it on boot into the same
|
||||||
|
/// conditions.
|
||||||
|
///
|
||||||
|
/// Held across the blocking section and released on drop, including on error.
|
||||||
|
pub static HEAVY_IMAGE_PERMITS: std::sync::LazyLock<tokio::sync::Semaphore> =
|
||||||
|
std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(1));
|
||||||
|
|
||||||
|
/// Estimated peak heap above which a job must take [`HEAVY_IMAGE_PERMITS`].
|
||||||
|
///
|
||||||
|
/// 150 MiB sits far above a normal phone photo (a 12 MP JPEG costs ~50 MiB all-in) so the common
|
||||||
|
/// path never serialises, and far below the point where two jobs stop fitting in the container.
|
||||||
|
pub const HEAVY_IMAGE_BYTES: u64 = 150 * 1024 * 1024;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -189,9 +279,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plain_io_error_is_not_permanent() {
|
fn a_plain_io_error_is_not_permanent() {
|
||||||
// The mirror that keeps the classifier honest. ENOSPC while writing a derivative, or
|
// The mirror that keeps the classifier honest. EMFILE under load, or a momentary
|
||||||
// EMFILE under load, is exactly what the retry exists for — misclassifying those as
|
// unreadable file, is exactly what the retry exists for — misclassifying those as
|
||||||
// permanent would turn a transient blip back into the data loss round 1 fixed.
|
// permanent would turn a transient blip back into the data loss round 1 fixed.
|
||||||
|
// (A FULL disk is its own case now; see the storage-full tests below.)
|
||||||
let err = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
let err = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
||||||
.map(|img| (img.width(), img.height()))
|
.map(|img| (img.width(), img.height()))
|
||||||
.expect_err("a missing file must error");
|
.expect_err("a missing file must error");
|
||||||
@@ -201,6 +292,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_disk_is_recognised_through_both_wrappers() {
|
||||||
|
// The two shapes ENOSPC actually arrives in. A bare io::Error is what `tokio::fs` and
|
||||||
|
// `std::fs` produce; the `image` crate wraps its own in `ImageError::IoError`, which is
|
||||||
|
// NOT reachable via `source()` — so a chain walk that only downcast to io::Error would
|
||||||
|
// miss every derivative-write failure, i.e. the exact case this classifier exists for.
|
||||||
|
let bare = anyhow::Error::from(std::io::Error::from(std::io::ErrorKind::StorageFull))
|
||||||
|
.context("failed to write the preview");
|
||||||
|
assert!(is_storage_full_error(&bare), "bare io::Error: {bare:#}");
|
||||||
|
|
||||||
|
let wrapped = anyhow::Error::from(image::ImageError::IoError(std::io::Error::from(
|
||||||
|
std::io::ErrorKind::StorageFull,
|
||||||
|
)))
|
||||||
|
.context("failed to save the display derivative");
|
||||||
|
assert!(
|
||||||
|
is_storage_full_error(&wrapped),
|
||||||
|
"ImageError::IoError: {wrapped:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_io_error_is_not_a_full_disk() {
|
||||||
|
// Keeps the classifier from swallowing the general case: only ENOSPC may skip the retry
|
||||||
|
// and take the keep-the-row branch. Anything else must still be retried and, if it keeps
|
||||||
|
// failing, soft-deleted as before.
|
||||||
|
let missing = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
||||||
|
.map(|img| (img.width(), img.height()))
|
||||||
|
.expect_err("a missing file must error");
|
||||||
|
assert!(
|
||||||
|
!is_storage_full_error(&missing),
|
||||||
|
"a missing file is not a full disk: {missing:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn admission_rejects_only_the_over_budget_case() {
|
fn admission_rejects_only_the_over_budget_case() {
|
||||||
// Admission and processing must agree about SIZE — a photo accepted at the door and
|
// Admission and processing must agree about SIZE — a photo accepted at the door and
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ pub async fn startup_recovery(pool: &PgPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a file in `originals/` may exist without a database row before it is treated as
|
||||||
|
/// abandoned.
|
||||||
|
///
|
||||||
|
/// This window is the ONLY thing making the sweep safe, because the upload handler renames the
|
||||||
|
/// temp file into its final path BEFORE committing the row: for a short moment a perfectly
|
||||||
|
/// healthy upload legitimately looks exactly like an orphan. Six hours is far beyond any live
|
||||||
|
/// request (a 576 MiB body over a bad venue uplink is minutes, and the request itself is bounded
|
||||||
|
/// by the reverse proxy) while still reclaiming the leak inside a single event.
|
||||||
|
///
|
||||||
|
/// DO NOT SHORTEN THIS to make a test faster — a value below the longest possible in-flight
|
||||||
|
/// upload deletes photos out from under the request that is committing them.
|
||||||
|
const ORPHAN_UPLOAD_RETENTION_HOURS: u64 = 6;
|
||||||
|
|
||||||
/// Spawns a background task that periodically:
|
/// Spawns a background task that periodically:
|
||||||
/// - deletes session rows whose `expires_at` is more than a day in the past
|
/// - deletes session rows whose `expires_at` is more than a day in the past
|
||||||
/// - prunes the in-memory rate-limiter HashMap of empty windows
|
/// - prunes the in-memory rate-limiter HashMap of empty windows
|
||||||
@@ -132,7 +145,42 @@ pub fn spawn_periodic_tasks(
|
|||||||
sse_tickets: SseTicketStore,
|
sse_tickets: SseTicketStore,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
) {
|
) {
|
||||||
|
// Supervised, because this one task carries EVERY piece of recurring hygiene in the app:
|
||||||
|
// session pruning, media reclamation, the orphan-temp sweep, and the rate-limiter and
|
||||||
|
// SSE-ticket maps. As a bare `tokio::spawn` with no retained handle, a single panic anywhere
|
||||||
|
// inside it stopped all five permanently and silently — no log line, no symptom until the
|
||||||
|
// disk or a HashMap grew into one. The supervisor re-spawns and, just as importantly, says
|
||||||
|
// so; it can never spin hot because the inner loop only returns by dying.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let inner = tokio::spawn(periodic_loop(
|
||||||
|
pool.clone(),
|
||||||
|
rate_limiter.clone(),
|
||||||
|
sse_tickets.clone(),
|
||||||
|
media_path.clone(),
|
||||||
|
));
|
||||||
|
match inner.await {
|
||||||
|
Ok(()) => tracing::error!("periodic maintenance loop returned; restarting it"),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = ?e, "periodic maintenance task died; restarting it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual hygiene loop. Never returns in normal operation — see the supervisor above.
|
||||||
|
async fn periodic_loop(
|
||||||
|
pool: PgPool,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
sse_tickets: SseTicketStore,
|
||||||
|
media_path: PathBuf,
|
||||||
|
) {
|
||||||
|
// A crash left whatever the previous process was mid-upload behind, and the first periodic
|
||||||
|
// tick is an hour away — sweep once up front so a restart is also a cleanup.
|
||||||
|
sweep_orphan_upload_temps(&media_path).await;
|
||||||
|
|
||||||
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.
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
@@ -140,10 +188,98 @@ pub fn spawn_periodic_tasks(
|
|||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
cleanup_sessions(&pool).await;
|
cleanup_sessions(&pool).await;
|
||||||
cleanup_deleted_media(&pool, &media_path).await;
|
cleanup_deleted_media(&pool, &media_path).await;
|
||||||
|
sweep_orphan_upload_temps(&media_path).await;
|
||||||
|
// Runs AFTER the .tmp sweep, and covers the class that one structurally cannot see:
|
||||||
|
// an original that was renamed to its final name but whose transaction never
|
||||||
|
// committed. Those have no row, so `cleanup_deleted_media` (row-driven) can never
|
||||||
|
// find them, and `sweep_orphan_upload_temps` skips them because they no longer end
|
||||||
|
// in `.tmp` — they were permanently unowned, silently shrinking the free disk that
|
||||||
|
// `compute_storage_quota` divides among guests.
|
||||||
|
sweep_orphan_originals(&pool, &media_path).await;
|
||||||
rate_limiter.prune();
|
rate_limiter.prune();
|
||||||
sse_tickets.prune();
|
sse_tickets.prune();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/// How long an upload's `.tmp` file must have been untouched before it is treated as abandoned.
|
||||||
|
///
|
||||||
|
/// This is an age on the MODIFICATION time, not on creation, and that is what makes an hour
|
||||||
|
/// safe rather than reckless: a live upload is being written to continuously, so its mtime keeps
|
||||||
|
/// advancing and it can never age into the sweep no matter how slow the connection. The clock
|
||||||
|
/// only starts once the writer stops — i.e. once the upload is genuinely dead.
|
||||||
|
const ORPHAN_TEMP_MAX_AGE: Duration = Duration::from_secs(3600);
|
||||||
|
|
||||||
|
/// Reclaim `.tmp` files left in the media tree by uploads that never finished.
|
||||||
|
///
|
||||||
|
/// `stream_field_to_file` removes its temp file on every error return, which covers everything
|
||||||
|
/// the handler can see. It cannot cover the case that actually happens at a party: the client
|
||||||
|
/// simply goes away — a phone sleeps, a guest walks out of range, the PWA is evicted mid-video —
|
||||||
|
/// and axum DROPS the handler future rather than returning an error, so no cleanup code runs at
|
||||||
|
/// all. The shutdown backstop force-exits in-flight handlers for the same net effect.
|
||||||
|
///
|
||||||
|
/// Nothing else reclaims these. `cleanup_deleted_media` only visits rows with `deleted_at`, and
|
||||||
|
/// an abandoned upload never got a row; `export::sweep_orphan_temps` is only ever pointed at the
|
||||||
|
/// exports volume. So before this, every abandonment stranded up to `max_video_size_mb` of
|
||||||
|
/// unowned bytes permanently — and worse than merely leaking, they were subtracted from what
|
||||||
|
/// everyone else could upload, because the per-user quota is computed from live free disk
|
||||||
|
/// (`compute_storage_quota`). On a 40 GB disk shared with `postgres_data`, an evening of flaky
|
||||||
|
/// venue wifi could take the event down.
|
||||||
|
async fn sweep_orphan_upload_temps(media_path: &std::path::Path) {
|
||||||
|
let originals = media_path.join("originals");
|
||||||
|
let mut event_dirs = match tokio::fs::read_dir(&originals).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
// Absent before the first upload — not a problem worth logging every hour.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, path = %originals.display(), "orphan temp sweep: unreadable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reclaimed = 0usize;
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
while let Ok(Some(event_dir)) = event_dirs.next_entry().await {
|
||||||
|
let Ok(mut files) = tokio::fs::read_dir(event_dir.path()).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
while let Ok(Some(file)) = files.next_entry().await {
|
||||||
|
let path = file.path();
|
||||||
|
if path.extension().is_none_or(|e| e != "tmp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(meta) = file.metadata().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// No mtime (or a clock that moved backwards) means we cannot show the file is
|
||||||
|
// abandoned, and deleting a live upload is far worse than leaking one temp file.
|
||||||
|
let abandoned = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.elapsed().ok())
|
||||||
|
.is_some_and(|age| age >= ORPHAN_TEMP_MAX_AGE);
|
||||||
|
if !abandoned {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tokio::fs::remove_file(&path).await {
|
||||||
|
Ok(()) => {
|
||||||
|
reclaimed += 1;
|
||||||
|
bytes += meta.len();
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, path = %path.display(),
|
||||||
|
"orphan temp sweep: could not reclaim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reclaimed > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"orphan temp sweep: reclaimed {reclaimed} abandoned upload temp file(s), {} MiB",
|
||||||
|
bytes / (1024 * 1024)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reclaim the media of soft-deleted uploads once they are past their retention window.
|
/// Reclaim the media of soft-deleted uploads once they are past their retention window.
|
||||||
@@ -262,3 +398,198 @@ async fn cleanup_sessions(pool: &PgPool) {
|
|||||||
Err(e) => tracing::warn!("session cleanup failed: {e:#}"),
|
Err(e) => tracing::warn!("session cleanup failed: {e:#}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reclaim files in `originals/` that no upload row references.
|
||||||
|
///
|
||||||
|
/// The backstop behind [`TempFileGuard`](crate::handlers::upload). The guard covers the
|
||||||
|
/// process that is running; this covers the process that was killed — a SIGKILL, an OOM, or a
|
||||||
|
/// power cut leaves whatever bytes had been written with no `Drop` to reclaim them, and those
|
||||||
|
/// files are then permanently invisible: they have no row, so `cleanup_deleted_media` (which is
|
||||||
|
/// row-driven) can never see them, and they are not counted against any quota while still
|
||||||
|
/// consuming the free disk that `compute_storage_quota` divides among guests. On a single box
|
||||||
|
/// where all three volumes share a filesystem, that ends with Postgres unable to write WAL.
|
||||||
|
///
|
||||||
|
/// Two classes:
|
||||||
|
/// - `*.tmp` — an upload that never got as far as being renamed. Always safe past the window.
|
||||||
|
/// - everything else — a final-named original whose commit never happened.
|
||||||
|
async fn sweep_orphan_originals(pool: &PgPool, media_path: &std::path::Path) {
|
||||||
|
let originals = media_path.join("originals");
|
||||||
|
let cutoff = Duration::from_secs(ORPHAN_UPLOAD_RETENTION_HOURS * 3600);
|
||||||
|
|
||||||
|
// originals/{event_slug}/{uuid}.{ext} — one level of per-event directories.
|
||||||
|
let mut event_dirs = match tokio::fs::read_dir(&originals).await {
|
||||||
|
Ok(rd) => rd,
|
||||||
|
// Nothing uploaded yet; the directory is created lazily by the upload handler.
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates: Vec<(String, std::path::PathBuf)> = Vec::new();
|
||||||
|
let mut temps_removed = 0u32;
|
||||||
|
|
||||||
|
while let Ok(Some(event_dir)) = event_dirs.next_entry().await {
|
||||||
|
if !event_dir
|
||||||
|
.file_type()
|
||||||
|
.await
|
||||||
|
.map(|t| t.is_dir())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slug = event_dir.file_name().to_string_lossy().to_string();
|
||||||
|
let Ok(mut files) = tokio::fs::read_dir(event_dir.path()).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
while let Ok(Some(entry)) = files.next_entry().await {
|
||||||
|
let Ok(meta) = entry.metadata().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !meta.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Too young to judge: an upload committing RIGHT NOW is indistinguishable from an
|
||||||
|
// orphan, because the rename precedes the commit.
|
||||||
|
//
|
||||||
|
// This sweep is also the backstop for the one case the upload handler's drop guard
|
||||||
|
// deliberately leaks: a client disconnect while `tx.commit()` is in flight disarms
|
||||||
|
// the guard first (so a COMMIT that Postgres applied anyway keeps its file), which
|
||||||
|
// means a COMMIT that did NOT apply leaves a final-named file with no row. The
|
||||||
|
// `NOT EXISTS` check below is what reclaims it. See `upload.rs`, the disarm site.
|
||||||
|
let recent = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.elapsed().ok())
|
||||||
|
.is_none_or(|age| age < cutoff);
|
||||||
|
if recent {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.ends_with(".tmp") {
|
||||||
|
// A `.tmp` never has a row by construction — no DB check needed.
|
||||||
|
if tokio::fs::remove_file(entry.path()).await.is_ok() {
|
||||||
|
temps_removed += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push((format!("originals/{slug}/{name}"), entry.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if temps_removed > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"reclaimed {temps_removed} abandoned upload temp file(s) older than \
|
||||||
|
{ORPHAN_UPLOAD_RETENTION_HOURS}h"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query per batch, not one per file: a backlog of thousands of orphans must not turn
|
||||||
|
// into thousands of round trips on an hourly timer.
|
||||||
|
let mut orphans_removed = 0u32;
|
||||||
|
for chunk in candidates.chunks(500) {
|
||||||
|
let paths: Vec<String> = chunk.iter().map(|(rel, _)| rel.clone()).collect();
|
||||||
|
// NO `deleted_at IS NULL` FILTER HERE. A soft-deleted row still points at its file
|
||||||
|
// during its retention window, and reclaiming that file is `cleanup_deleted_media`'s
|
||||||
|
// job — filtering here would race the two sweeps and destroy the exact files the
|
||||||
|
// recovery window exists to preserve.
|
||||||
|
let unreferenced: Result<Vec<(String,)>, _> = sqlx::query_as(
|
||||||
|
"SELECT p FROM unnest($1::text[]) AS p
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM upload u WHERE u.original_path = p)",
|
||||||
|
)
|
||||||
|
.bind(&paths)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
let unreferenced = match unreferenced {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "orphan-original sweep query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (rel,) in unreferenced {
|
||||||
|
if let Some((_, abs)) = chunk.iter().find(|(r, _)| *r == rel)
|
||||||
|
&& tokio::fs::remove_file(abs).await.is_ok()
|
||||||
|
{
|
||||||
|
orphans_removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if orphans_removed > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"reclaimed {orphans_removed} original(s) with no upload row, older than \
|
||||||
|
{ORPHAN_UPLOAD_RETENTION_HOURS}h"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build `<root>/originals/<event>/<name>` with `len` bytes, optionally back-dating its mtime
|
||||||
|
/// by `age`. Back-dating is the only way to test the sweep without sleeping through an hour.
|
||||||
|
fn temp_file(root: &std::path::Path, name: &str, len: usize, age: Option<Duration>) {
|
||||||
|
let dir = root.join("originals").join("wedding");
|
||||||
|
std::fs::create_dir_all(&dir).expect("create dir");
|
||||||
|
let path = dir.join(name);
|
||||||
|
let f = std::fs::File::create(&path).expect("create file");
|
||||||
|
std::io::Write::write_all(&mut &f, &vec![0u8; len]).expect("write");
|
||||||
|
if let Some(age) = age {
|
||||||
|
let when = std::time::SystemTime::now() - age;
|
||||||
|
f.set_modified(when).expect("set mtime");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists(root: &std::path::Path, name: &str) -> bool {
|
||||||
|
root.join("originals").join("wedding").join(name).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two halves of the guarantee in one pass: an abandoned temp is reclaimed, and a temp
|
||||||
|
/// that is still being written to is NOT — the second matters more, because deleting a live
|
||||||
|
/// upload's temp file would corrupt a photo that was about to succeed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_sweep_reclaims_abandoned_temps_and_spares_live_ones() {
|
||||||
|
let root = std::env::temp_dir().join(format!("es-sweep-{}", uuid::Uuid::new_v4()));
|
||||||
|
|
||||||
|
// Abandoned: the writer died over an hour ago and nothing has touched it since.
|
||||||
|
temp_file(
|
||||||
|
&root,
|
||||||
|
"dead.tmp",
|
||||||
|
2048,
|
||||||
|
Some(ORPHAN_TEMP_MAX_AGE + Duration::from_secs(60)),
|
||||||
|
);
|
||||||
|
// Live: an upload in progress keeps advancing its mtime, so it always looks young —
|
||||||
|
// this is why the threshold is on modification time and not on creation time.
|
||||||
|
temp_file(&root, "inflight.tmp", 2048, None);
|
||||||
|
// A committed original. The sweep must only ever consider `.tmp`.
|
||||||
|
temp_file(&root, "keeper.jpg", 2048, Some(Duration::from_secs(86_400)));
|
||||||
|
|
||||||
|
sweep_orphan_upload_temps(&root).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!exists(&root, "dead.tmp"),
|
||||||
|
"an abandoned temp must be reclaimed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
exists(&root, "inflight.tmp"),
|
||||||
|
"a temp still being written to must survive — deleting it destroys a live upload"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
exists(&root, "keeper.jpg"),
|
||||||
|
"the sweep must never touch a committed original"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs on every boot and every hour, so a media tree that does not exist yet (before the
|
||||||
|
/// first upload) must be a silent no-op rather than an error logged 24 times a day.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_media_tree_is_not_an_error() {
|
||||||
|
let root = std::env::temp_dir().join(format!("es-sweep-absent-{}", uuid::Uuid::new_v4()));
|
||||||
|
sweep_orphan_upload_temps(&root).await; // must simply return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
112
backend/src/services/media_total.rs
Normal file
112
backend/src/services/media_total.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! Cached sum of all media bytes the event is holding.
|
||||||
|
//!
|
||||||
|
//! The upload gate needs to know "how big would the keepsake be if we accept this file", because
|
||||||
|
//! the archive needs room for BOTH halves at once (`export::required_free_bytes` is
|
||||||
|
//! `media × 1.1 × 2` — the ZIP and the HTML viewer are each gallery-sized). Asking that question
|
||||||
|
//! per upload has to be cheap, and it has to be cheap on the busiest write path in the app.
|
||||||
|
//!
|
||||||
|
//! `export::estimate_export_bytes` answers the same question exactly, but it aggregates
|
||||||
|
//! `original_size_bytes` across every upload row joined to `user` — fine once per release,
|
||||||
|
//! wasteful per upload and growing all evening. This sums `user.total_upload_bytes` instead:
|
||||||
|
//! one row per guest (~100), already maintained transactionally by the quota path, already
|
||||||
|
//! refunded on delete.
|
||||||
|
//!
|
||||||
|
//! The two differ slightly — this one counts uploads belonging to banned or hidden users, which
|
||||||
|
//! the export filters out. That skew is in the SAFE direction: it over-estimates the archive, so
|
||||||
|
//! the gate closes marginally early rather than marginally late. Never swap it for a cheaper
|
||||||
|
//! query that could under-estimate; an under-estimate authorises the very upload that makes the
|
||||||
|
//! keepsake unbuildable, which is the failure this exists to prevent.
|
||||||
|
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// How long a reading is trusted. Shorter than [`crate::services::disk`]'s TTL because this
|
||||||
|
/// number only ever grows and does so in the same request path that reads it — a stale value
|
||||||
|
/// under-counts the newest uploads, and under-counting is the direction that matters.
|
||||||
|
const TTL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Cheap-to-clone cache of the event's total media bytes. Lives in `AppState`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MediaTotalCache {
|
||||||
|
inner: Arc<RwLock<Option<(i64, Instant)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaTotalCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached reading so the next `get()` re-queries.
|
||||||
|
///
|
||||||
|
/// Used by the e2e TRUNCATE endpoint for the same reason `DiskCache::invalidate` exists:
|
||||||
|
/// truncation removes every upload, and a surviving reading would make the next test's
|
||||||
|
/// gate compute against the previous test's data.
|
||||||
|
pub fn invalidate(&self) {
|
||||||
|
*self.inner.write().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total bytes of media the event is holding, cached for [`TTL`].
|
||||||
|
///
|
||||||
|
/// Returns 0 when the query fails. That is a deliberate FAIL-OPEN, consistent with the
|
||||||
|
/// quota path and the export preflight: a database blip must not turn into "every upload
|
||||||
|
/// refused". The disk-space half of the gate still applies, so a failure here degrades the
|
||||||
|
/// check to the old flat-reserve behaviour rather than disabling it.
|
||||||
|
pub async fn get(&self, pool: &PgPool, event_slug: &str) -> i64 {
|
||||||
|
if let Some((bytes, at)) = *self.inner.read().unwrap()
|
||||||
|
&& at.elapsed() < TTL
|
||||||
|
{
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
// Scoped to THIS event (H12). The unscoped `SUM(total_upload_bytes) FROM "user"` summed
|
||||||
|
// every user row in the table, so reusing the install for a second event carried the first
|
||||||
|
// one's bytes into the second one's keepsake-headroom gate — closing uploads early with a
|
||||||
|
// message about "the event's storage" being full, counting media that belongs to a party
|
||||||
|
// that already happened (and whose files are never reclaimed either).
|
||||||
|
let queried = sqlx::query_scalar::<_, Option<i64>>(
|
||||||
|
"SELECT SUM(u.total_upload_bytes)::bigint FROM \"user\" u
|
||||||
|
JOIN event e ON e.id = u.event_id
|
||||||
|
WHERE e.slug = $1",
|
||||||
|
)
|
||||||
|
.bind(event_slug)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let bytes = match queried {
|
||||||
|
Ok(v) => v.unwrap_or(0).max(0),
|
||||||
|
Err(e) => {
|
||||||
|
// FAIL OPEN, but do NOT cache the failure, and do NOT let it pass silently.
|
||||||
|
//
|
||||||
|
// Storing 0 here pinned the gate's view of the event at "empty" for the whole
|
||||||
|
// TTL. During that window `media_after` is just this upload, `keepsake_needs`
|
||||||
|
// collapses to ~2.2x one file, and the gate degrades to the flat 10 GB reserve —
|
||||||
|
// precisely the behaviour the two-halves design replaced, reappearing with no
|
||||||
|
// trace in the log. And the trigger correlates with the danger: with
|
||||||
|
// `max_connections = 10` and a 5s acquire timeout, this query fails exactly when
|
||||||
|
// a burst is in progress.
|
||||||
|
//
|
||||||
|
// Falling back to the LAST GOOD reading (however stale) is strictly better than
|
||||||
|
// 0: the total only ever grows, so a stale value under-counts slightly, while 0
|
||||||
|
// under-counts by everything.
|
||||||
|
let previous = self.inner.read().unwrap().map(|(b, _)| b);
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
fallback_bytes = previous.unwrap_or(0),
|
||||||
|
"media total query failed; upload gate is running on a stale reading"
|
||||||
|
);
|
||||||
|
return previous.unwrap_or(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*self.inner.write().unwrap() = Some((bytes, Instant::now()));
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MediaTotalCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
pub mod audit;
|
||||||
pub mod compression;
|
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 imaging;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
|
pub mod media_total;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod sse_tickets;
|
pub mod sse_tickets;
|
||||||
|
pub mod upload_admission;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
|||||||
@@ -7,7 +7,23 @@ use std::time::{Duration, Instant};
|
|||||||
/// of recent requests and rejects new ones once the window is full.
|
/// of recent requests and rejects new ones once the window is full.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RateLimiter {
|
pub struct RateLimiter {
|
||||||
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
|
windows: Arc<Mutex<HashMap<String, Bucket>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One key's recent hits, plus the window they were recorded under.
|
||||||
|
///
|
||||||
|
/// The `window` field is what makes pruning correct. `prune` used a single fixed 24 h ceiling for
|
||||||
|
/// every key, on the reasoning that 24 h is the longest window in use (export downloads) — but that
|
||||||
|
/// meant a `join:{ip}:{name}` key whose 60-SECOND window expired 23 hours ago was still retained.
|
||||||
|
/// Minting one costs a single 409 and no bcrypt, at 60/min per IP across three endpoints: roughly
|
||||||
|
/// 172,800 keys/day/IP, about 31 MB/day/IP inside a 1 GB container. The limiter became the
|
||||||
|
/// memory-exhaustion primitive it exists to prevent.
|
||||||
|
///
|
||||||
|
/// Storing the window per key makes the sweep drop each bucket as soon as ITS OWN window has
|
||||||
|
/// elapsed, which is also what the hot path already does on every check.
|
||||||
|
struct Bucket {
|
||||||
|
hits: Vec<Instant>,
|
||||||
|
window: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RateLimiter {
|
impl RateLimiter {
|
||||||
@@ -34,20 +50,68 @@ impl RateLimiter {
|
|||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let key = key.into();
|
let key = key.into();
|
||||||
let mut map = self.windows.lock().unwrap();
|
let mut map = self.windows.lock().unwrap();
|
||||||
let timestamps = map.entry(key).or_default();
|
let bucket = map.entry(key).or_insert_with(|| Bucket {
|
||||||
|
hits: Vec::new(),
|
||||||
|
window,
|
||||||
|
});
|
||||||
|
// A key's window can change under it when an admin edits the limit at runtime. Track the
|
||||||
|
// current one so `prune` expires the bucket on the window actually in force.
|
||||||
|
bucket.window = window;
|
||||||
|
let timestamps = &mut bucket.hits;
|
||||||
timestamps.retain(|&t| now.duration_since(t) < window);
|
timestamps.retain(|&t| now.duration_since(t) < window);
|
||||||
if timestamps.len() < max {
|
if timestamps.len() < max {
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// The oldest timestamp expires at oldest + window; compute remaining seconds
|
// The oldest timestamp expires at oldest + window; compute remaining seconds.
|
||||||
let oldest = timestamps[0];
|
//
|
||||||
|
// `first()`, not `[0]`: with `max == 0` the length check above is false even on an
|
||||||
|
// empty vec, so indexing would panic — WHILE HOLDING THIS MUTEX. That poisons it
|
||||||
|
// process-wide, so every subsequent `.lock().unwrap()` panics too: upload, feed,
|
||||||
|
// join, recover, social, export and the hourly maintenance task all die, and only
|
||||||
|
// a container restart brings them back. `max == 0` is not reachable through the
|
||||||
|
// admin API (every numeric spec has min = 1) but a direct DB edit would do it, and
|
||||||
|
// the blast radius does not justify the sharper syntax.
|
||||||
|
let Some(&oldest) = timestamps.first() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
let elapsed = now.duration_since(oldest);
|
let elapsed = now.duration_since(oldest);
|
||||||
let remaining = window.saturating_sub(elapsed);
|
let remaining = window.saturating_sub(elapsed);
|
||||||
Err(remaining.as_secs().max(1))
|
Err(remaining.as_secs().max(1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is `key` already at or above `max`, WITHOUT recording a hit?
|
||||||
|
///
|
||||||
|
/// Needed by limiters whose budget is spent by an outcome rather than by the request — the
|
||||||
|
/// per-IP failed-PIN ceiling charges only on a wrong PIN, so the gate at the top of the handler
|
||||||
|
/// has to be able to ask "is this IP shut out?" without itself consuming the budget it guards.
|
||||||
|
/// Using `check_with_retry` for that would charge every *successful* recovery too, and a venue
|
||||||
|
/// full of guests legitimately recovering their own devices would lock itself out.
|
||||||
|
///
|
||||||
|
/// Returns `Err(retry_after_secs)` when exhausted, mirroring `check_with_retry` so callers can
|
||||||
|
/// build the same 429.
|
||||||
|
pub fn peek(&self, key: &str, max: usize, window: Duration) -> Result<(), u64> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = self.windows.lock().unwrap();
|
||||||
|
let Some(bucket) = map.get_mut(key) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
bucket
|
||||||
|
.hits
|
||||||
|
.retain(|&t| now.duration_since(t) < bucket.window);
|
||||||
|
if bucket.hits.len() < max {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(&oldest) = bucket.hits.first() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
Err(window
|
||||||
|
.saturating_sub(now.duration_since(oldest))
|
||||||
|
.as_secs()
|
||||||
|
.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
|
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
|
||||||
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
|
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
@@ -58,17 +122,23 @@ impl RateLimiter {
|
|||||||
/// background task (see [`crate::services::maintenance`]) so that long-lived
|
/// background task (see [`crate::services::maintenance`]) so that long-lived
|
||||||
/// processes don't accumulate one HashMap entry per IP that ever connected.
|
/// processes don't accumulate one HashMap entry per IP that ever connected.
|
||||||
///
|
///
|
||||||
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
|
/// Expires each bucket against ITS OWN window (see [`Bucket`]), not one global ceiling. The
|
||||||
/// of which endpoint's window it was tracked under (the longest window today is
|
/// previous fixed 24 h ceiling retained per-minute keys for a full day — ~172,800 keys/day/IP
|
||||||
/// 24h for export downloads). If we ever add longer windows, raise this constant.
|
/// at 60/min across three endpoints, each mintable with a single 409 and no bcrypt.
|
||||||
|
///
|
||||||
|
/// Holds the one global mutex for the length of the sweep, and that mutex is on the hot path of
|
||||||
|
/// upload, feed, join, recover, social and export — so the retain does the cheap thing per
|
||||||
|
/// bucket and nothing else. Correct pruning also keeps the map small enough that this stays
|
||||||
|
/// cheap, which the old ceiling actively undermined.
|
||||||
pub fn prune(&self) {
|
pub fn prune(&self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let ceiling = Duration::from_secs(24 * 60 * 60);
|
|
||||||
let mut map = self.windows.lock().unwrap();
|
let mut map = self.windows.lock().unwrap();
|
||||||
let before = map.len();
|
let before = map.len();
|
||||||
map.retain(|_, ts| {
|
map.retain(|_, bucket| {
|
||||||
ts.retain(|&t| now.duration_since(t) < ceiling);
|
bucket
|
||||||
!ts.is_empty()
|
.hits
|
||||||
|
.retain(|&t| now.duration_since(t) < bucket.window);
|
||||||
|
!bucket.hits.is_empty()
|
||||||
});
|
});
|
||||||
let dropped = before.saturating_sub(map.len());
|
let dropped = before.saturating_sub(map.len());
|
||||||
if dropped > 0 {
|
if dropped > 0 {
|
||||||
@@ -220,15 +290,20 @@ mod tests {
|
|||||||
fn prune_drops_keys_whose_windows_have_fully_expired() {
|
fn prune_drops_keys_whose_windows_have_fully_expired() {
|
||||||
let rl = RateLimiter::new();
|
let rl = RateLimiter::new();
|
||||||
|
|
||||||
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
|
// A key whose only timestamp is older than its own window. We can't sleep, so backdate
|
||||||
// so backdate the Instant directly.
|
// the Instant directly. A ONE-MINUTE window here on purpose: the old prune applied a flat
|
||||||
|
// 24 h ceiling to every key, so this bucket — expired for over an hour of wall time —
|
||||||
|
// survived the sweep. That is the leak (H3), and pinning it needs a short-window key.
|
||||||
let ancient = Instant::now()
|
let ancient = Instant::now()
|
||||||
.checked_sub(Duration::from_secs(25 * 60 * 60))
|
.checked_sub(Duration::from_secs(90 * 60))
|
||||||
.expect("backdating an Instant by 25h");
|
.expect("backdating an Instant by 90 minutes");
|
||||||
rl.windows
|
rl.windows.lock().unwrap().insert(
|
||||||
.lock()
|
"stale".to_string(),
|
||||||
.unwrap()
|
Bucket {
|
||||||
.insert("stale".to_string(), vec![ancient]);
|
hits: vec![ancient],
|
||||||
|
window: MIN,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ...alongside a key that is still inside its window.
|
// ...alongside a key that is still inside its window.
|
||||||
assert!(rl.check_with_retry("live", 5, MIN).is_ok());
|
assert!(rl.check_with_retry("live", 5, MIN).is_ok());
|
||||||
|
|||||||
@@ -13,6 +13,88 @@ use rand::Rng;
|
|||||||
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
||||||
const TTL: Duration = Duration::from_secs(30);
|
const TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Lifetime of a `Download` ticket, and it is deliberately far longer than [`TTL`].
|
||||||
|
///
|
||||||
|
/// A keepsake is up to ~1.4 GB over hotel or cellular wifi, so the download itself outlives a 30 s
|
||||||
|
/// window many times over — and a resumed transfer arrives minutes or hours after the ticket was
|
||||||
|
/// minted. A `Download` ticket is therefore a short-lived capability for ONE archive rather than a
|
||||||
|
/// single-shot nonce: [`SseTicketStore::redeem_download`] does not remove it, so a client may
|
||||||
|
/// resume with `Range` as many times as the transfer needs.
|
||||||
|
///
|
||||||
|
/// The abuse this does NOT open: the ticket is bound to a session (revoked with it), only mints at
|
||||||
|
/// `/export/ticket` where the 3/day limit is charged, and grants nothing but this event's own
|
||||||
|
/// keepsake — which every authenticated guest is entitled to download anyway. What it buys is that
|
||||||
|
/// one dropped connection no longer costs a guest a third of their daily allowance, at the
|
||||||
|
/// emotional payoff of the product.
|
||||||
|
const DOWNLOAD_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
|
||||||
|
/// The lifetime that applies to a given kind.
|
||||||
|
fn ttl_for(kind: TicketKind) -> Duration {
|
||||||
|
match kind {
|
||||||
|
TicketKind::Download(_) => DOWNLOAD_TTL,
|
||||||
|
TicketKind::Sse => TTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ceiling on outstanding tickets across the whole process.
|
||||||
|
///
|
||||||
|
/// Not really about the bytes (~120 each) — about `issue` having had no bound of any kind.
|
||||||
|
/// Sized well above a real event: ~1000 concurrent clients each holding one live 30 s ticket.
|
||||||
|
const MAX_TICKETS: usize = 4096;
|
||||||
|
|
||||||
|
/// Live tickets one session may hold. Above 1 because two tabs sharing a token open their
|
||||||
|
/// EventSources concurrently; 4 absorbs that without letting a reconnect loop accumulate.
|
||||||
|
const MAX_TICKETS_PER_SESSION: usize = 4;
|
||||||
|
|
||||||
|
/// How many times one download ticket may be redeemed.
|
||||||
|
///
|
||||||
|
/// Making the ticket non-consuming is what lets a dropped transfer resume without spending another
|
||||||
|
/// of the guest's three daily downloads — but unbounded it also meant a single mint was an
|
||||||
|
/// unlimited download key for six hours, at BOTH archive endpoints, with the per-day limiter
|
||||||
|
/// (charged only at mint) never moving. On a 40 GB box serving ~1.4 GB archives that is the one
|
||||||
|
/// resource an ordinary guest could exhaust without doing anything obviously wrong.
|
||||||
|
///
|
||||||
|
/// 20 is far more than a resumed transfer needs (a browser retries a handful of times, not dozens)
|
||||||
|
/// and turns "unbounded until the ticket expires" into a bounded multiple. It does not make the
|
||||||
|
/// daily limit exact — that would mean charging per redemption, which would bill a client that
|
||||||
|
/// restarts from byte 0 instead of sending a `Range`, i.e. re-break the thing this exists to fix.
|
||||||
|
const MAX_DOWNLOAD_REDEMPTIONS: u32 = 20;
|
||||||
|
|
||||||
|
/// What a ticket may be redeemed for.
|
||||||
|
///
|
||||||
|
/// The store began life serving only SSE and stayed untyped when the export download started
|
||||||
|
/// reusing it, which silently made the two interchangeable. That is not a theoretical mixing
|
||||||
|
/// concern: `POST /stream/ticket` is rate-limited at 60/min per user and charges nothing, while
|
||||||
|
/// `POST /export/ticket` charges one of three PER-DAY downloads. An untyped ticket let any guest
|
||||||
|
/// mint at the cheap endpoint and redeem at the expensive one, so the daily export limit was
|
||||||
|
/// bypassable ~60×/minute — each redemption streaming the whole multi-GB keepsake, `no-store`,
|
||||||
|
/// off the same filesystem Postgres writes WAL to.
|
||||||
|
///
|
||||||
|
/// `consume` therefore requires the kind to MATCH. A ticket is only ever valid for the thing it
|
||||||
|
/// was minted for.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum TicketKind {
|
||||||
|
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
|
||||||
|
Sse,
|
||||||
|
/// Downloads ONE export archive. Expensive, rate-limited per day.
|
||||||
|
///
|
||||||
|
/// The archive is part of the ticket, not incidental to it. A bare `Download` ticket was
|
||||||
|
/// accepted by BOTH `/export/zip` and `/export/html` — they share one authenticator — so with
|
||||||
|
/// the redemption budget that makes a ticket resumable, a single mint authorised 20 transfers
|
||||||
|
/// spread across both archives. Three mints a day therefore bought 60 full downloads of a
|
||||||
|
/// ~1.4 GB keepsake, while the per-day limiter (charged only at mint) never moved.
|
||||||
|
Download(ExportKind),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which archive a [`TicketKind::Download`] is good for.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum ExportKind {
|
||||||
|
/// `Gallery.<event>.<n>.zip` — the original media.
|
||||||
|
Zip,
|
||||||
|
/// `Memories.<event>.<n>.zip` — the offline HTML viewer.
|
||||||
|
Html,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SseTicketStore {
|
pub struct SseTicketStore {
|
||||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||||
@@ -22,6 +104,10 @@ pub struct SseTicketStore {
|
|||||||
struct Entry {
|
struct Entry {
|
||||||
token_hash: String,
|
token_hash: String,
|
||||||
issued_at: Instant,
|
issued_at: Instant,
|
||||||
|
kind: TicketKind,
|
||||||
|
/// Times this ticket has been redeemed. Only meaningful for `Download` — see
|
||||||
|
/// [`MAX_DOWNLOAD_REDEMPTIONS`].
|
||||||
|
redemptions: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SseTicketStore {
|
impl SseTicketStore {
|
||||||
@@ -39,26 +125,129 @@ impl SseTicketStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
||||||
pub fn issue(&self, token_hash: String) -> String {
|
///
|
||||||
|
/// `None` when the store is at capacity — the caller should answer 503, not evict.
|
||||||
|
///
|
||||||
|
/// Three bounds, because `issue` had none: no size cap, no per-caller cap, and no rate
|
||||||
|
/// limit on the endpoint, while `prune` ran only hourly against a 30-second TTL. So any
|
||||||
|
/// authenticated session could loop the endpoint and grow the map for an hour.
|
||||||
|
pub fn issue(&self, token_hash: String, kind: TicketKind) -> Option<String> {
|
||||||
let ticket = random_ticket();
|
let ticket = random_ticket();
|
||||||
let mut map = self.inner.lock().unwrap();
|
let mut map = self.inner.lock().unwrap();
|
||||||
|
|
||||||
|
// Prune on issue rather than only hourly. This alone changes the bound from "tickets
|
||||||
|
// minted since the last maintenance tick" to "tickets live at once", which is what the
|
||||||
|
// 30 s TTL was always meant to express.
|
||||||
|
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
|
||||||
|
|
||||||
|
// Cap the caller's own outstanding tickets, evicting their oldest. NOT one-per-session:
|
||||||
|
// two tabs sharing a token open their EventSources concurrently, and having tab B
|
||||||
|
// invalidate tab A's unconsumed ticket looks exactly like a flaky SSE connection.
|
||||||
|
//
|
||||||
|
// SCOPED TO THE SAME KIND, which matters now that `Download` tickets live 6 h instead of
|
||||||
|
// being consumed on first use. A long-lived download ticket is ALWAYS the oldest entry for
|
||||||
|
// its session, so a kind-blind cap made it the first thing ordinary SSE churn threw away —
|
||||||
|
// and `/export` itself opens an SSE connection on the very session that just minted it.
|
||||||
|
// A couple of reconnects during the download (a wifi flap is enough; each attempt that
|
||||||
|
// returns early abandons an unconsumed ticket) evicted the ticket out from under a running
|
||||||
|
// transfer, so the next `Range` resume 401'd and the guest had to spend another of their
|
||||||
|
// three daily downloads. Two flaps and they were locked out of their own keepsake for a day.
|
||||||
|
//
|
||||||
|
// Per-kind, an SSE reconnect storm can still only evict SSE tickets, which is what the cap
|
||||||
|
// was written for; the guest's in-flight keepsake is no longer collateral.
|
||||||
|
let mut mine: Vec<(String, Instant)> = map
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, e)| e.token_hash == token_hash && e.kind == kind)
|
||||||
|
.map(|(k, e)| (k.clone(), e.issued_at))
|
||||||
|
.collect();
|
||||||
|
if mine.len() >= MAX_TICKETS_PER_SESSION {
|
||||||
|
mine.sort_by_key(|(_, issued)| *issued);
|
||||||
|
for (key, _) in mine.iter().take(mine.len() - MAX_TICKETS_PER_SESSION + 1) {
|
||||||
|
map.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At capacity, REFUSE — never evict a stranger's ticket. Evicting would let one
|
||||||
|
// misbehaving client deny SSE to the whole venue, which is worse than failing the
|
||||||
|
// request that hit the ceiling.
|
||||||
|
if map.len() >= MAX_TICKETS {
|
||||||
|
tracing::warn!(
|
||||||
|
outstanding = map.len(),
|
||||||
|
"SSE ticket store at capacity; refusing to mint"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
map.insert(
|
map.insert(
|
||||||
ticket.clone(),
|
ticket.clone(),
|
||||||
Entry {
|
Entry {
|
||||||
token_hash,
|
token_hash,
|
||||||
issued_at: Instant::now(),
|
issued_at: Instant::now(),
|
||||||
|
kind,
|
||||||
|
redemptions: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ticket
|
Some(ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is
|
/// Consume a ticket minted for `kind`. Returns `Some(token_hash)` if the ticket exists, is
|
||||||
/// not expired. Single-use: the ticket is removed regardless of whether it
|
/// not expired, and was minted for this purpose. Single-use: the ticket is removed regardless
|
||||||
/// was still fresh, so a replay can't slip through after expiry.
|
/// of whether it was still fresh, so a replay can't slip through after expiry.
|
||||||
pub fn consume(&self, ticket: &str) -> Option<String> {
|
///
|
||||||
|
/// A ticket of the WRONG kind is also removed. It was a valid ticket the caller legitimately
|
||||||
|
/// held, so this is not punitive — but leaving it would let a redemption loop probe the store
|
||||||
|
/// without ever spending anything, and the client has no legitimate reason to present a
|
||||||
|
/// ticket at the wrong endpoint.
|
||||||
|
/// Redeem a `Download` ticket WITHOUT consuming it.
|
||||||
|
///
|
||||||
|
/// Downloads must be resumable — see [`DOWNLOAD_TTL`]. A browser resumes by re-issuing the same
|
||||||
|
/// GET with a `Range` header, so a single-use ticket made `Accept-Ranges` a lie: the retry
|
||||||
|
/// authenticated against a ticket that the interrupted attempt had already spent, 401'd, and
|
||||||
|
/// the guest had to mint a new one, spending another of their three daily downloads. Two
|
||||||
|
/// dropped connections and they were locked out of their own keepsake for ~24 hours.
|
||||||
|
///
|
||||||
|
/// Still bound to a live session: the caller re-checks the session on every request, so
|
||||||
|
/// revoking a session (logout, "sign out everywhere", a host PIN reset) kills the download too.
|
||||||
|
pub fn redeem_download(&self, ticket: &str, want: ExportKind) -> Option<String> {
|
||||||
|
let mut map = self.inner.lock().unwrap();
|
||||||
|
let entry = map.get_mut(ticket)?;
|
||||||
|
// The archive must match the one this ticket was minted for. Both download routes share
|
||||||
|
// this authenticator, so without the payload check a ZIP ticket opened the HTML archive
|
||||||
|
// too and the redemption budget was spent across both.
|
||||||
|
if entry.kind != TicketKind::Download(want) {
|
||||||
|
tracing::warn!(
|
||||||
|
found = ?entry.kind,
|
||||||
|
?want,
|
||||||
|
"ticket presented for the wrong archive (or wrong kind); rejected"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if entry.issued_at.elapsed() > DOWNLOAD_TTL {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if entry.redemptions >= MAX_DOWNLOAD_REDEMPTIONS {
|
||||||
|
tracing::warn!(
|
||||||
|
redemptions = entry.redemptions,
|
||||||
|
"download ticket exceeded its redemption budget; refusing"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry.redemptions += 1;
|
||||||
|
Some(entry.token_hash.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consume(&self, ticket: &str, kind: TicketKind) -> Option<String> {
|
||||||
let mut map = self.inner.lock().unwrap();
|
let mut map = self.inner.lock().unwrap();
|
||||||
let entry = map.remove(ticket)?;
|
let entry = map.remove(ticket)?;
|
||||||
if entry.issued_at.elapsed() > TTL {
|
if entry.issued_at.elapsed() > ttl_for(entry.kind) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if entry.kind != kind {
|
||||||
|
tracing::warn!(
|
||||||
|
expected = ?kind,
|
||||||
|
found = ?entry.kind,
|
||||||
|
"ticket presented at the wrong endpoint; rejected"
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(entry.token_hash)
|
Some(entry.token_hash)
|
||||||
@@ -68,7 +257,7 @@ impl SseTicketStore {
|
|||||||
/// long-running process doesn't accumulate stale tickets.
|
/// long-running process doesn't accumulate stale tickets.
|
||||||
pub fn prune(&self) {
|
pub fn prune(&self) {
|
||||||
let mut map = self.inner.lock().unwrap();
|
let mut map = self.inner.lock().unwrap();
|
||||||
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
|
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +273,66 @@ fn random_ticket() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// `issue` now returns `Option`; in every test below the store is far from capacity, so an
|
||||||
|
/// `expect` here documents that refusing is exceptional rather than routine.
|
||||||
|
fn issue(store: &SseTicketStore, hash: &str) -> String {
|
||||||
|
store
|
||||||
|
.issue(hash.into(), TicketKind::Sse)
|
||||||
|
.expect("store has capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store is shared by two endpoints with wildly different costs: `/stream/ticket` is
|
||||||
|
/// 60/min per user and free, `/export/ticket` charges one of three PER-DAY downloads. While
|
||||||
|
/// entries were untyped, a ticket minted at the cheap endpoint opened the expensive one — so
|
||||||
|
/// the daily export limit could be bypassed ~60×/minute, each redemption streaming the whole
|
||||||
|
/// multi-GB keepsake off the disk Postgres writes WAL to.
|
||||||
|
///
|
||||||
|
/// Asserted in BOTH directions so this cannot be "fixed" by a check that only guards one.
|
||||||
|
#[test]
|
||||||
|
fn a_ticket_is_only_valid_for_the_purpose_it_was_minted_for() {
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
|
||||||
|
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.consume(&sse, TicketKind::Download(ExportKind::Zip)),
|
||||||
|
None,
|
||||||
|
"an SSE ticket must not open the export download"
|
||||||
|
);
|
||||||
|
|
||||||
|
let dl = store
|
||||||
|
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.consume(&dl, TicketKind::Sse),
|
||||||
|
None,
|
||||||
|
"a download ticket must not open the SSE stream"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the matching cases still work, so the guard is not simply rejecting everything.
|
||||||
|
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||||
|
assert_eq!(store.consume(&sse, TicketKind::Sse).as_deref(), Some("h"));
|
||||||
|
let dl = store
|
||||||
|
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.consume(&dl, TicketKind::Download(ExportKind::Zip))
|
||||||
|
.as_deref(),
|
||||||
|
Some("h")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issue_then_consume_returns_the_hash_exactly_once() {
|
fn issue_then_consume_returns_the_hash_exactly_once() {
|
||||||
let store = SseTicketStore::new();
|
let store = SseTicketStore::new();
|
||||||
let ticket = store.issue("hash-1".into());
|
let ticket = issue(&store, "hash-1");
|
||||||
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
assert_eq!(
|
||||||
|
store.consume(&ticket, TicketKind::Sse).as_deref(),
|
||||||
|
Some("hash-1")
|
||||||
|
);
|
||||||
// Single-use: a replay of the same ticket is rejected.
|
// Single-use: a replay of the same ticket is rejected.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.consume(&ticket),
|
store.consume(&ticket, TicketKind::Sse),
|
||||||
None,
|
None,
|
||||||
"a consumed ticket must not be reusable"
|
"a consumed ticket must not be reusable"
|
||||||
);
|
);
|
||||||
@@ -100,14 +341,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unknown_ticket_consumes_to_none() {
|
fn unknown_ticket_consumes_to_none() {
|
||||||
let store = SseTicketStore::new();
|
let store = SseTicketStore::new();
|
||||||
assert_eq!(store.consume("never-issued"), None);
|
assert_eq!(store.consume("never-issued", TicketKind::Sse), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issued_tickets_are_unique_and_hex() {
|
fn issued_tickets_are_unique_and_hex() {
|
||||||
let store = SseTicketStore::new();
|
let store = SseTicketStore::new();
|
||||||
let a = store.issue("h".into());
|
let a = issue(&store, "h");
|
||||||
let b = store.issue("h".into());
|
let b = issue(&store, "h");
|
||||||
assert_ne!(a, b, "each ticket must be unique");
|
assert_ne!(a, b, "each ticket must be unique");
|
||||||
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
|
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
|
||||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
@@ -116,29 +357,210 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fresh_ticket_survives_prune() {
|
fn fresh_ticket_survives_prune() {
|
||||||
let store = SseTicketStore::new();
|
let store = SseTicketStore::new();
|
||||||
let ticket = store.issue("h".into());
|
let ticket = issue(&store, "h");
|
||||||
store.prune(); // not expired → kept
|
store.prune(); // not expired → kept
|
||||||
assert_eq!(store.consume(&ticket).as_deref(), Some("h"));
|
assert_eq!(
|
||||||
|
store.consume(&ticket, TicketKind::Sse).as_deref(),
|
||||||
|
Some("h")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an entry that is already past the TTL.
|
||||||
|
fn insert_stale(store: &SseTicketStore, key: &str, token_hash: &str) {
|
||||||
|
store.inner.lock().unwrap().insert(
|
||||||
|
key.to_string(),
|
||||||
|
Entry {
|
||||||
|
kind: TicketKind::Sse,
|
||||||
|
token_hash: token_hash.into(),
|
||||||
|
issued_at: Instant::now()
|
||||||
|
.checked_sub(TTL + Duration::from_secs(1))
|
||||||
|
.expect("host uptime should exceed the ticket TTL"),
|
||||||
|
redemptions: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expired_ticket_consumes_to_none() {
|
fn expired_ticket_consumes_to_none() {
|
||||||
// Construct an entry that is already past the TTL and confirm consume() rejects it.
|
|
||||||
let store = SseTicketStore::new();
|
let store = SseTicketStore::new();
|
||||||
let stale = "stale-ticket".to_string();
|
insert_stale(&store, "stale-ticket", "h");
|
||||||
store.inner.lock().unwrap().insert(
|
|
||||||
stale.clone(),
|
|
||||||
Entry {
|
|
||||||
token_hash: "h".into(),
|
|
||||||
issued_at: Instant::now()
|
|
||||||
.checked_sub(TTL + Duration::from_secs(1))
|
|
||||||
.expect("host uptime should exceed the ticket TTL"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.consume(&stale),
|
store.consume("stale-ticket", TicketKind::Sse),
|
||||||
None,
|
None,
|
||||||
"an expired ticket must not authenticate"
|
"an expired ticket must not authenticate"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The TTL is 30 s but `prune` only ran hourly, so the map was really bounded by "tickets
|
||||||
|
/// minted in the last hour" — which is unbounded for a client in a loop.
|
||||||
|
#[test]
|
||||||
|
fn issuing_prunes_expired_entries() {
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
insert_stale(&store, "stale-a", "someone-else");
|
||||||
|
insert_stale(&store, "stale-b", "someone-else");
|
||||||
|
issue(&store, "h");
|
||||||
|
assert_eq!(
|
||||||
|
store.inner.lock().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"issue must reclaim expired slots, not merely add to them"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two tabs sharing a token is normal, so the per-session cap must be above 1 — but a
|
||||||
|
/// reconnect loop must not accumulate. The caller's OWN oldest is what gets evicted.
|
||||||
|
#[test]
|
||||||
|
fn a_session_is_capped_and_evicts_only_its_own_oldest() {
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
let stranger = issue(&store, "other-session");
|
||||||
|
|
||||||
|
let mut mine: Vec<String> = Vec::new();
|
||||||
|
for _ in 0..MAX_TICKETS_PER_SESSION + 2 {
|
||||||
|
mine.push(issue(&store, "mine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = mine
|
||||||
|
.iter()
|
||||||
|
.filter(|t| store.inner.lock().unwrap().contains_key(*t))
|
||||||
|
.count();
|
||||||
|
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&mine[mine.len() - 1]),
|
||||||
|
"the newest ticket is the one the caller is about to use"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.consume(&stranger, TicketKind::Sse).as_deref(),
|
||||||
|
Some("other-session"),
|
||||||
|
"another session's ticket must survive — evicting it would let one client deny \
|
||||||
|
SSE to the venue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At capacity the store REFUSES rather than evicting a stranger. Refusing fails the one
|
||||||
|
/// request that hit the ceiling; evicting would break an unrelated client's live stream.
|
||||||
|
#[test]
|
||||||
|
fn at_capacity_the_store_refuses_instead_of_evicting() {
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
{
|
||||||
|
let mut map = store.inner.lock().unwrap();
|
||||||
|
for i in 0..MAX_TICKETS {
|
||||||
|
map.insert(
|
||||||
|
format!("filler-{i}"),
|
||||||
|
Entry {
|
||||||
|
kind: TicketKind::Sse,
|
||||||
|
token_hash: format!("session-{i}"),
|
||||||
|
issued_at: Instant::now(),
|
||||||
|
redemptions: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
store.issue("newcomer".into(), TicketKind::Sse),
|
||||||
|
None,
|
||||||
|
"a full store must refuse, so the caller can answer 503"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store.inner.lock().unwrap().contains_key("filler-0"),
|
||||||
|
"no existing ticket may be sacrificed to make room"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A download ticket is deliberately non-consuming so a dropped 1.4 GB transfer can resume
|
||||||
|
/// without spending one of the guest's three daily downloads. Unbounded, though, that made a
|
||||||
|
/// single mint an unlimited download key for six hours while the per-day limiter — charged
|
||||||
|
/// only at mint — never moved. This pins the bound without breaking resumption.
|
||||||
|
#[test]
|
||||||
|
fn a_download_ticket_resumes_freely_but_not_forever() {
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
let ticket = store
|
||||||
|
.issue("session-a".into(), TicketKind::Download(ExportKind::Zip))
|
||||||
|
.expect("fresh store should issue");
|
||||||
|
|
||||||
|
// Every redemption inside the budget returns the session, so a resumed transfer works.
|
||||||
|
for i in 0..MAX_DOWNLOAD_REDEMPTIONS {
|
||||||
|
assert_eq!(
|
||||||
|
store.redeem_download(&ticket, ExportKind::Zip).as_deref(),
|
||||||
|
Some("session-a"),
|
||||||
|
"redemption {i} should still be honoured"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Past it the ticket is spent: the guest re-mints (and is charged) rather than holding
|
||||||
|
// an open-ended key.
|
||||||
|
assert_eq!(store.redeem_download(&ticket, ExportKind::Zip), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_download_ticket_opens_only_the_archive_it_was_minted_for() {
|
||||||
|
// Both download routes share one authenticator, so without the archive in the ticket a
|
||||||
|
// single mint was good for BOTH. Combined with the resume budget that made one mint worth
|
||||||
|
// 2 x MAX_DOWNLOAD_REDEMPTIONS transfers of a multi-GB keepsake, while the per-day limit —
|
||||||
|
// charged only at mint — never moved.
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
let zip = store
|
||||||
|
.issue("s".into(), TicketKind::Download(ExportKind::Zip))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.redeem_download(&zip, ExportKind::Html),
|
||||||
|
None,
|
||||||
|
"a ZIP ticket must not open the HTML archive"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.redeem_download(&zip, ExportKind::Zip).as_deref(),
|
||||||
|
Some("s"),
|
||||||
|
"...and the refusal above must be about the archive, not a spent ticket"
|
||||||
|
);
|
||||||
|
|
||||||
|
let html = store
|
||||||
|
.issue("s".into(), TicketKind::Download(ExportKind::Html))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.redeem_download(&html, ExportKind::Zip), None);
|
||||||
|
assert_eq!(
|
||||||
|
store.redeem_download(&html, ExportKind::Html).as_deref(),
|
||||||
|
Some("s")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sse_churn_cannot_evict_a_running_download() {
|
||||||
|
// A `Download` ticket lives 6 h, so it is ALWAYS the oldest entry for its session — and a
|
||||||
|
// kind-blind per-session cap therefore threw it away first. `/export` opens its own SSE
|
||||||
|
// connection on the same session, so a couple of reconnects during the transfer evicted
|
||||||
|
// the ticket out from under it: the next `Range` resume 401'd and the guest spent another
|
||||||
|
// of their three daily downloads. Two wifi flaps and they lost their keepsake for a day.
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
let download = store
|
||||||
|
.issue("one-session".into(), TicketKind::Download(ExportKind::Zip))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Far more SSE churn than the per-session cap, all on the same session.
|
||||||
|
for _ in 0..(MAX_TICKETS_PER_SESSION * 3) {
|
||||||
|
store.issue("one-session".into(), TicketKind::Sse).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.redeem_download(&download, ExportKind::Zip).as_deref(),
|
||||||
|
Some("one-session"),
|
||||||
|
"an in-flight keepsake download must survive an SSE reconnect storm"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind split is what stops a free SSE ticket from redeeming a rate-limited download.
|
||||||
|
#[test]
|
||||||
|
fn an_sse_ticket_is_never_redeemable_as_a_download() {
|
||||||
|
let store = SseTicketStore::new();
|
||||||
|
let sse = store
|
||||||
|
.issue("session-b".into(), TicketKind::Sse)
|
||||||
|
.expect("fresh store should issue");
|
||||||
|
assert_eq!(store.redeem_download(&sse, ExportKind::Zip), None);
|
||||||
|
// And it is still usable for what it IS, so the rejection above is about kind, not
|
||||||
|
// the ticket having been quietly spent.
|
||||||
|
assert_eq!(
|
||||||
|
store.consume(&sse, TicketKind::Sse).as_deref(),
|
||||||
|
Some("session-b")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
150
backend/src/services/upload_admission.rs
Normal file
150
backend/src/services/upload_admission.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
//! Admission control for upload bodies, budgeted in BYTES rather than requests.
|
||||||
|
//!
|
||||||
|
//! ## Why this has to exist
|
||||||
|
//!
|
||||||
|
//! The keepsake headroom gate in `handlers::upload` cannot bound a burst, and the reason is
|
||||||
|
//! structural rather than a bug in the gate: the request body is streamed to a temp file during
|
||||||
|
//! multipart parsing, so the bytes are already on disk by the time any check runs. The gate can
|
||||||
|
//! only refuse to COMMIT them. Nothing upstream limited how many bodies stream at once — axum has
|
||||||
|
//! no such limit, the tower stack is just `TraceLayer`, and Caddy passes requests straight
|
||||||
|
//! through.
|
||||||
|
//!
|
||||||
|
//! So the failure mode is the ordinary one, not an attack: the ceremony ends, ~100 guests tap
|
||||||
|
//! "upload all", and ~100 bodies stream concurrently. At phone-video sizes that is 10-20 GB of
|
||||||
|
//! `.tmp` files on a 40 GB volume, none of it visible to the gate, and `DISK_RESERVE_BYTES` — the
|
||||||
|
//! 10 GB standing between the party and Postgres losing the volume it writes WAL to — is consumed
|
||||||
|
//! by transient files. The `.tmp` sweeper only reclaims files idle for an hour, correctly, which
|
||||||
|
//! means nothing reclaims a burst on this timescale.
|
||||||
|
//!
|
||||||
|
//! ## Why bytes and not a request count
|
||||||
|
//!
|
||||||
|
//! A flat "N concurrent uploads" limit has to be sized for the worst case (a 500 MB video), which
|
||||||
|
//! makes it absurdly restrictive for the common case (a 3 MB photo). Budgeting bytes lets one
|
||||||
|
//! 500 MB video and two hundred photos coexist under the same ceiling, and it means the ceiling is
|
||||||
|
//! stated in the unit the disk actually cares about.
|
||||||
|
//!
|
||||||
|
//! The reservation is the streaming CAP, not the real size — the real size is unknowable until the
|
||||||
|
//! body has been read, which is far too late. Reserving the cap is deliberately pessimistic; that
|
||||||
|
//! pessimism is the safety margin.
|
||||||
|
//!
|
||||||
|
//! ## Why a permit and not a counter
|
||||||
|
//!
|
||||||
|
//! `OwnedSemaphorePermit` releases on drop. Every path out of the upload handler — success, error,
|
||||||
|
//! a client vanishing mid-body, a panic — therefore returns the reservation without any explicit
|
||||||
|
//! bookkeeping. A hand-rolled `AtomicI64` would need a decrement on each of those paths, and the
|
||||||
|
//! one that gets missed is the one that leaks the budget until restart.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||||
|
|
||||||
|
/// Total transient upload bytes allowed on disk at once, in MiB.
|
||||||
|
///
|
||||||
|
/// Sized against `DISK_RESERVE_BYTES` (10 GB): the reserve must survive a full burst with room to
|
||||||
|
/// spare, since Postgres is writing WAL to the same filesystem throughout. 4 GiB leaves ~6 GB of
|
||||||
|
/// the reserve untouched at the worst moment.
|
||||||
|
///
|
||||||
|
/// It is NOT a throughput limit. On 2 vCPU the box cannot usefully ingest more than this at once
|
||||||
|
/// anyway — compression, ffmpeg, Postgres and TLS all contend for the same two cores — so the
|
||||||
|
/// budget mostly converts "everything is slow and the disk fills" into "a few uploads wait".
|
||||||
|
const BUDGET_MIB: u32 = 4096;
|
||||||
|
|
||||||
|
/// How long an upload waits for room before being told to come back.
|
||||||
|
///
|
||||||
|
/// Long enough to absorb the burst (a photo holds its reservation for well under a second), short
|
||||||
|
/// enough that a guest is not left staring at a spinner. On timeout the handler answers 503 with
|
||||||
|
/// `Retry-After`, which the client queue already treats as transient and retries with backoff.
|
||||||
|
const WAIT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UploadAdmission {
|
||||||
|
permits: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploadAdmission {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
permits: Arc::new(Semaphore::new(BUDGET_MIB as usize)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserve room for a body capped at `cap_bytes`. The returned permit must be held for as long
|
||||||
|
/// as the temp file exists.
|
||||||
|
///
|
||||||
|
/// `None` means the wait timed out and the caller should shed the request.
|
||||||
|
///
|
||||||
|
/// A cap larger than the whole budget is clamped rather than refused. Otherwise an operator
|
||||||
|
/// raising `max_video_size_mb` above the budget would make `acquire_many` unsatisfiable and
|
||||||
|
/// every video upload would hang until timeout — a config change silently disabling video for
|
||||||
|
/// the event. Clamped, such an upload simply gets the whole budget to itself, which is the
|
||||||
|
/// honest interpretation of "one file may fill the machine".
|
||||||
|
pub async fn reserve(&self, cap_bytes: usize) -> Option<OwnedSemaphorePermit> {
|
||||||
|
let mib = cap_bytes.div_ceil(1024 * 1024).max(1);
|
||||||
|
let want = u32::try_from(mib).unwrap_or(BUDGET_MIB).min(BUDGET_MIB);
|
||||||
|
match tokio::time::timeout(WAIT, self.permits.clone().acquire_many_owned(want)).await {
|
||||||
|
Ok(Ok(permit)) => Some(permit),
|
||||||
|
// The semaphore is never closed, so `Err` here is unreachable in practice; treat it
|
||||||
|
// the same as a timeout rather than panicking on the upload path.
|
||||||
|
Ok(Err(_)) => None,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
requested_mib = want,
|
||||||
|
"upload admission timed out; shedding to keep transient temp files bounded"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UploadAdmission {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The budget must actually block once exhausted — otherwise this whole module is decoration.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_full_budget_sheds_instead_of_admitting() {
|
||||||
|
let admission = UploadAdmission::new();
|
||||||
|
let whole = admission
|
||||||
|
.reserve(BUDGET_MIB as usize * 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.expect("first reservation takes the whole budget");
|
||||||
|
|
||||||
|
// Nothing left: a second reservation must not be granted. Raced against a short timeout so
|
||||||
|
// the test does not sit for the full WAIT.
|
||||||
|
let blocked =
|
||||||
|
tokio::time::timeout(Duration::from_millis(150), admission.reserve(1024 * 1024)).await;
|
||||||
|
assert!(
|
||||||
|
blocked.is_err(),
|
||||||
|
"budget exhausted, yet a reservation was granted"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and releasing the permit makes room again, so the budget is not a one-way latch.
|
||||||
|
drop(whole);
|
||||||
|
assert!(
|
||||||
|
admission.reserve(1024 * 1024).await.is_some(),
|
||||||
|
"budget did not recover after the permit was dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cap above the whole budget must be clamped, not left unsatisfiable. Unclamped,
|
||||||
|
/// `acquire_many` for more permits than exist never completes, so raising
|
||||||
|
/// `max_video_size_mb` past the budget would silently hang every video upload for 20s and
|
||||||
|
/// then shed it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_cap_larger_than_the_budget_is_clamped_rather_than_unsatisfiable() {
|
||||||
|
let admission = UploadAdmission::new();
|
||||||
|
let oversized = (BUDGET_MIB as usize + 4096) * 1024 * 1024;
|
||||||
|
assert!(
|
||||||
|
admission.reserve(oversized).await.is_some(),
|
||||||
|
"an over-budget cap must still be admittable on an idle server"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,12 @@ use anyhow::{Context, Result};
|
|||||||
/// the semaphore permit and the pool eventually deadlocks; in the export worker it strands the job
|
/// the semaphore permit and the pool eventually deadlocks; in the export worker it strands the job
|
||||||
/// at `running` so the keepsake never completes. `export.rs` had NO timeout at all before this
|
/// at `running` so the keepsake never completes. `export.rs` had NO timeout at all before this
|
||||||
/// module — sharing the spawn fixes that too.
|
/// module — sharing the spawn fixes that too.
|
||||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(120);
|
/// 45s, not the 120s this started at. The timeout is not a budget for honest work — a poster
|
||||||
|
/// frame from a phone clip takes well under a second, and `-ss` before `-i` means even a 500 MB
|
||||||
|
/// file seeks rather than scans. It is purely the ceiling on how long a pathological input may
|
||||||
|
/// hold a compression permit that guests' photos are queued behind, so it should be as tight as
|
||||||
|
/// it can be without ever cutting off real work.
|
||||||
|
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(45);
|
||||||
|
|
||||||
/// Seek positions to try, in order.
|
/// Seek positions to try, in order.
|
||||||
///
|
///
|
||||||
@@ -71,7 +76,7 @@ pub async fn extract_poster_frame(src: &Path, dest: &Path, width: u32) -> Result
|
|||||||
/// Run one ffmpeg attempt. A non-zero exit is NOT an error here — the artifact check above is the
|
/// Run one ffmpeg attempt. A non-zero exit is NOT an error here — the artifact check above is the
|
||||||
/// authority, and a corrupt input that fails at 1 s may still yield a frame at 0.
|
/// authority, and a corrupt input that fails at 1 s may still yield a frame at 0.
|
||||||
async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<()> {
|
async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<()> {
|
||||||
let mut child = tokio::process::Command::new("ffmpeg")
|
let child = tokio::process::Command::new("ffmpeg")
|
||||||
.args([
|
.args([
|
||||||
// BEFORE -i: an input-side seek. See SEEK_POSITIONS.
|
// BEFORE -i: an input-side seek. See SEEK_POSITIONS.
|
||||||
"-ss",
|
"-ss",
|
||||||
@@ -85,28 +90,76 @@ async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<(
|
|||||||
"-y",
|
"-y",
|
||||||
dest.to_str().unwrap_or_default(),
|
dest.to_str().unwrap_or_default(),
|
||||||
])
|
])
|
||||||
.stdout(std::process::Stdio::piped())
|
// ffmpeg writes the poster to `dest` itself; nothing here ever reads stdout, so
|
||||||
|
// giving it a pipe only created something that could fill.
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
// stderr IS piped — it is the only diagnostic when a clip yields no frame — but it
|
||||||
|
// must be DRAINED, which is the whole point of `wait_with_output` below.
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()
|
.spawn()
|
||||||
.context("failed to spawn ffmpeg")?;
|
.context("failed to spawn ffmpeg")?;
|
||||||
|
|
||||||
match tokio::time::timeout(FFMPEG_TIMEOUT, child.wait()).await {
|
// `wait_with_output`, NOT `wait`. ffmpeg is verbose on stderr (banner, stream info,
|
||||||
Ok(res) => {
|
// per-frame progress) and `wait()` reads neither pipe — so once the ~64 KiB pipe buffer
|
||||||
res.context("ffmpeg wait failed")?;
|
// filled, ffmpeg blocked writing, `wait()` never returned, and the call burned the full
|
||||||
}
|
// timeout. That is not merely slow: the timeout is an `Err`, so after 2 seek positions x
|
||||||
Err(_) => {
|
// 3 compression attempts the caller soft-deletes a perfectly playable video for a
|
||||||
let _ = child.kill().await;
|
// poster-frame failure. `wait_with_output` polls the pipe and the exit status together.
|
||||||
anyhow::bail!("ffmpeg timed out after {}s", FFMPEG_TIMEOUT.as_secs());
|
//
|
||||||
}
|
// It also CONSUMES the child, so the explicit `child.kill()` that used to sit on the
|
||||||
|
// timeout arm cannot exist here — and is not needed: `kill_on_drop(true)` is set above,
|
||||||
|
// and dropping the future on timeout drops the child with it.
|
||||||
|
let out = match tokio::time::timeout(FFMPEG_TIMEOUT, child.wait_with_output()).await {
|
||||||
|
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||||
|
Err(_) => anyhow::bail!("ffmpeg timed out after {}s", FFMPEG_TIMEOUT.as_secs()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// A non-zero exit is not an error (see the doc comment) — the artifact check in
|
||||||
|
// `extract_poster_frame` is the authority. Log the tail so a systematically failing
|
||||||
|
// format is diagnosable without turning it into data loss.
|
||||||
|
if !out.status.success() {
|
||||||
|
tracing::debug!(
|
||||||
|
seek,
|
||||||
|
status = ?out.status,
|
||||||
|
stderr = %tail_lines(&out.stderr, 10),
|
||||||
|
"ffmpeg exited non-zero; the artifact check decides"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Last `n` lines of a child's stderr, lossily decoded.
|
||||||
|
///
|
||||||
|
/// Bounded on purpose: ffmpeg's stderr is unbounded, and the reason we now drain it is that
|
||||||
|
/// unbounded output used to be a hazard. Emitting all of it into a log line — into container
|
||||||
|
/// logs that are themselves size-capped — would just move the problem.
|
||||||
|
fn tail_lines(bytes: &[u8], n: usize) -> String {
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||||
|
lines[lines.len().saturating_sub(n)..].join(" | ")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Is there a usable `ffmpeg` on PATH?
|
||||||
|
///
|
||||||
|
/// The poster-frame path shells out, and `extract_poster_frame` documents `Err` as meaning
|
||||||
|
/// "a hang or a SPAWN failure" — which is exactly what a missing binary produces. So on a
|
||||||
|
/// machine without ffmpeg the test below stops exercising the case it names (missing INPUT)
|
||||||
|
/// and instead reports a code defect that isn't there. The runtime image installs ffmpeg
|
||||||
|
/// (see backend/Dockerfile), so this only ever skips on a bare developer machine.
|
||||||
|
fn ffmpeg_available() -> bool {
|
||||||
|
std::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// The order is the whole fix. `-ss` must precede `-i`, and 0 must be tried after 1 s.
|
/// The order is the whole fix. `-ss` must precede `-i`, and 0 must be tried after 1 s.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_fallback_seek_exists_and_comes_last() {
|
fn the_fallback_seek_exists_and_comes_last() {
|
||||||
@@ -121,6 +174,15 @@ mod tests {
|
|||||||
/// poster", never fail the upload. `Err` is reserved for a hang or a spawn failure.
|
/// poster", never fail the upload. `Err` is reserved for a hang or a spawn failure.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_missing_source_yields_no_frame_rather_than_an_error() {
|
async fn a_missing_source_yields_no_frame_rather_than_an_error() {
|
||||||
|
if !ffmpeg_available() {
|
||||||
|
eprintln!(
|
||||||
|
"SKIP a_missing_source_yields_no_frame_rather_than_an_error: no ffmpeg on PATH. \
|
||||||
|
A missing binary is a spawn failure, which this function returns Err for by \
|
||||||
|
design, so the missing-INPUT case cannot be exercised here. Install ffmpeg to \
|
||||||
|
run it (the runtime image already has it)."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dir = std::env::temp_dir().join(format!("es-video-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("es-video-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let dest = dir.join("out.jpg");
|
let dest = dir.join("out.jpg");
|
||||||
@@ -137,4 +199,65 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stderr_tail_is_bounded_and_survives_invalid_utf8() {
|
||||||
|
let noisy: Vec<u8> = (0..500)
|
||||||
|
.map(|i| format!("line {i}\n"))
|
||||||
|
.collect::<String>()
|
||||||
|
.into_bytes();
|
||||||
|
let got = tail_lines(&noisy, 3);
|
||||||
|
assert_eq!(got, "line 497 | line 498 | line 499");
|
||||||
|
|
||||||
|
// ffmpeg emits filenames verbatim, so its stderr is not guaranteed to be UTF-8.
|
||||||
|
assert_eq!(tail_lines(&[b'o', b'k', 0xff], 5), "ok\u{fffd}");
|
||||||
|
assert_eq!(tail_lines(b"", 5), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real extraction must finish in a small fraction of `FFMPEG_TIMEOUT`.
|
||||||
|
///
|
||||||
|
/// Wall-clock is the ONLY observable of the bug this guards: piping stderr and then
|
||||||
|
/// calling `wait()` (which drains nothing) blocks ffmpeg on a full pipe buffer until the
|
||||||
|
/// timeout fires, and the timeout is an `Err`, so the upload is soft-deleted. The
|
||||||
|
/// assertion is deliberately on elapsed time, not on the exit status.
|
||||||
|
///
|
||||||
|
/// Honest limitation: our fixture is quiet enough not to fill a 64 KiB pipe on its own,
|
||||||
|
/// so this catches a regression to `wait()` only in combination with a verbose input. It
|
||||||
|
/// is still worth pinning — a reverted drain plus any chatty clip is data loss.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_real_clip_yields_a_poster_well_inside_the_timeout() {
|
||||||
|
if tokio::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
eprintln!("skipping: ffmpeg not on PATH");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let src = Path::new("../e2e/fixtures/media/sample.mp4");
|
||||||
|
if !src.exists() {
|
||||||
|
eprintln!("skipping: {} missing", src.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("es-video-ok-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let dest = dir.join("poster.jpg");
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let got = extract_poster_frame(src, &dest, 400).await;
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert!(matches!(got, Ok(true)), "expected a poster, got {got:?}");
|
||||||
|
assert!(dest.metadata().unwrap().len() > 0);
|
||||||
|
assert!(
|
||||||
|
elapsed < FFMPEG_TIMEOUT / 4,
|
||||||
|
"extraction took {elapsed:?}; a drained stderr finishes in well under \
|
||||||
|
{FFMPEG_TIMEOUT:?} — this is the pipe-deadlock regression guard"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ use crate::config::AppConfig;
|
|||||||
use crate::services::compression::CompressionWorker;
|
use crate::services::compression::CompressionWorker;
|
||||||
use crate::services::config::ConfigCache;
|
use crate::services::config::ConfigCache;
|
||||||
use crate::services::disk::DiskCache;
|
use crate::services::disk::DiskCache;
|
||||||
|
use crate::services::media_total::MediaTotalCache;
|
||||||
use crate::services::rate_limiter::RateLimiter;
|
use crate::services::rate_limiter::RateLimiter;
|
||||||
use crate::services::sse_tickets::SseTicketStore;
|
use crate::services::sse_tickets::SseTicketStore;
|
||||||
|
use crate::services::upload_admission::UploadAdmission;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct SseEvent {
|
pub struct SseEvent {
|
||||||
@@ -38,6 +40,12 @@ pub struct AppState {
|
|||||||
pub config_cache: ConfigCache,
|
pub config_cache: ConfigCache,
|
||||||
/// Cached total/free bytes for the media filesystem (quota + admin stats).
|
/// Cached total/free bytes for the media filesystem (quota + admin stats).
|
||||||
pub disk_cache: DiskCache,
|
pub disk_cache: DiskCache,
|
||||||
|
/// Cached sum of all media bytes, for the upload gate's keepsake-headroom check.
|
||||||
|
pub media_total: MediaTotalCache,
|
||||||
|
/// Byte budget for upload bodies currently streaming to temp files. The headroom gate can
|
||||||
|
/// only refuse to COMMIT bytes that are already on disk; this is what bounds how many get
|
||||||
|
/// there at once.
|
||||||
|
pub upload_admission: UploadAdmission,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -63,6 +71,8 @@ impl AppState {
|
|||||||
sse_tickets: SseTicketStore::new(),
|
sse_tickets: SseTicketStore::new(),
|
||||||
config_cache,
|
config_cache,
|
||||||
disk_cache: DiskCache::new(),
|
disk_cache: DiskCache::new(),
|
||||||
|
media_total: MediaTotalCache::new(),
|
||||||
|
upload_admission: UploadAdmission::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,9 @@ use common::*;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// SRC: `handlers/upload.rs:313-322` — the guarded quota increment, verbatim.
|
/// SRC: `handlers/upload.rs::create_upload` — the guarded quota increment, verbatim.
|
||||||
|
/// (Named, not line-numbered: the previous pointer drifted by ~130 lines and landed in unrelated
|
||||||
|
/// code, which is how a hand-copied fixture silently stops matching its original.)
|
||||||
/// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0.
|
/// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0.
|
||||||
async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
|
async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -146,7 +148,8 @@ async fn quota_guard_is_atomic_under_concurrent_transactions(pool: PgPool) {
|
|||||||
// 6. The `FOR SHARE` upload lock vs. the release
|
// 6. The `FOR SHARE` upload lock vs. the release
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// SRC: `handlers/upload.rs:297-303` — the in-transaction re-check under a row lock, verbatim.
|
/// SRC: `handlers/upload.rs::create_upload` — the in-transaction `FOR SHARE` re-check, verbatim.
|
||||||
|
/// (Named, not line-numbered — see the note on `quota_inc`.)
|
||||||
async fn lock_and_read_event(
|
async fn lock_and_read_event(
|
||||||
tx: &mut sqlx::PgConnection,
|
tx: &mut sqlx::PgConnection,
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
@@ -254,12 +257,19 @@ async fn for_share_upload_lock_serializes_against_release(pool: PgPool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE`
|
/// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE`
|
||||||
/// re-read sees `export_released_at` set and the handler rejects it with `UploadsLocked`.
|
/// re-read sees `export_released_at` set and the handler bails out.
|
||||||
///
|
///
|
||||||
/// PREVENTS: the same lost photo, on the losing side of the race — a photo committing AFTER the
|
/// PREVENTS: the same lost photo, on the losing side of the race — a photo committing AFTER the
|
||||||
/// export snapshot would be in the live feed but missing from the keepsake. Rejecting is the correct
|
/// export snapshot would be in the live feed but missing from the keepsake. Rejecting is the correct
|
||||||
/// outcome, and it is reversible: `UploadsLocked` (not Forbidden) tells the client to keep the blob
|
/// outcome, and it is reversible: the client keeps the blob and resumes when the host reopens.
|
||||||
/// and resume when the host reopens.
|
///
|
||||||
|
/// SCOPE, because the name overstates it: this asserts only what the LOCKED READ observes. It does
|
||||||
|
/// not go through the handler, so it says nothing about which error the handler picks. That
|
||||||
|
/// distinction is load-bearing — `create_upload` answers a released gallery with `GalleryReleased`
|
||||||
|
/// and a plain lock with `UploadsLocked`, in that order, and the two drive different client
|
||||||
|
/// behaviour (a `reopen` park vs. a retry). The ordering is covered end-to-end by
|
||||||
|
/// `e2e/specs/10-flow-review/upload-lock-code.spec.ts` and `02-upload/retry-after-release.spec.ts`;
|
||||||
|
/// this test's doc used to claim `UploadsLocked` outright and was simply wrong after that split.
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool) {
|
async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool) {
|
||||||
let event_id = seed_event(&pool, "wedding").await;
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
@@ -295,3 +305,99 @@ async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool
|
|||||||
);
|
);
|
||||||
tx.rollback().await.unwrap();
|
tx.rollback().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SRC: `handlers/me.rs::delete_account` — the last-operator guard, verbatim.
|
||||||
|
///
|
||||||
|
/// Returns the ids of the OTHER live operators, holding a row lock on each. The handler refuses the
|
||||||
|
/// deletion when this is empty.
|
||||||
|
async fn other_operators(tx: &mut sqlx::PgConnection, event_id: Uuid, self_id: Uuid) -> Vec<Uuid> {
|
||||||
|
sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
|
||||||
|
.bind(event_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.expect("advisory lock");
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT id FROM \"user\"
|
||||||
|
WHERE event_id = $1 AND id != $2
|
||||||
|
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(self_id)
|
||||||
|
.fetch_all(tx)
|
||||||
|
.await
|
||||||
|
.expect("other_operators")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two hosts deleting themselves at the same moment must not both succeed.
|
||||||
|
///
|
||||||
|
/// The guard used to run on the pool BEFORE the transaction opened, so each deleter saw the other,
|
||||||
|
/// both passed, and the event was left with no operator at all — nobody to moderate, nobody to
|
||||||
|
/// release the gallery, and no way to appoint anyone, because appointing requires a host. Not
|
||||||
|
/// recoverable from inside the app.
|
||||||
|
///
|
||||||
|
/// A transaction-scoped ADVISORY lock serialises them. A row lock on the other operators would
|
||||||
|
/// deadlock instead — each deleter locks the other's row and then tries to delete its own, so
|
||||||
|
/// Postgres kills one with a deadlock error; the invariant survives but the loser gets a 500. A
|
||||||
|
/// lock on the `event` row would serialise cleanly but inverts the order every moderation path
|
||||||
|
/// takes (upload/user rows first, event last). The advisory lock is a separate space, so it cannot
|
||||||
|
/// interact with the row-lock graph at all: the loser waits, then counts zero once the winner's row
|
||||||
|
/// is gone, and is refused with a sentence instead of an error.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn two_hosts_deleting_at_once_cannot_both_leave_the_event(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let a = seed_user(&pool, event_id, "Gastgeber Anton").await;
|
||||||
|
let b = seed_user(&pool, event_id, "Gastgeberin Berta").await;
|
||||||
|
for id in [a, b] {
|
||||||
|
sqlx::query("UPDATE \"user\" SET role = 'host' WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("promote");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A opens first and takes the lock on B's row.
|
||||||
|
let mut tx_a = pool.begin().await.expect("tx a");
|
||||||
|
let a_sees = other_operators(&mut tx_a, event_id, a).await;
|
||||||
|
assert_eq!(a_sees, vec![b], "A must see B as the remaining operator");
|
||||||
|
|
||||||
|
// B now tries the same and blocks on A's row. Spawned, because it cannot return until A
|
||||||
|
// commits — which is precisely the serialisation under test.
|
||||||
|
let pool_b = pool.clone();
|
||||||
|
let b_task = tokio::spawn(async move {
|
||||||
|
let mut tx_b = pool_b.begin().await.expect("tx b");
|
||||||
|
let seen = other_operators(&mut tx_b, event_id, b).await;
|
||||||
|
tx_b.commit().await.expect("commit b");
|
||||||
|
seen
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give B a moment to actually reach the lock rather than racing past it.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
// A completes its deletion.
|
||||||
|
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
|
||||||
|
.bind(a)
|
||||||
|
.execute(&mut *tx_a)
|
||||||
|
.await
|
||||||
|
.expect("delete a");
|
||||||
|
tx_a.commit().await.expect("commit a");
|
||||||
|
|
||||||
|
let b_sees = b_task.await.expect("b task");
|
||||||
|
assert!(
|
||||||
|
b_sees.is_empty(),
|
||||||
|
"B unblocked and must now see NO remaining operator (A is gone), so its deletion is \
|
||||||
|
refused — it saw {b_sees:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The event still has exactly one operator: B.
|
||||||
|
let remaining: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM \"user\" WHERE event_id = $1 AND role IN ('host','admin')",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(
|
||||||
|
remaining, 1,
|
||||||
|
"the event must never be left without an operator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
268
backend/tests/upload_idempotency.rs
Normal file
268
backend/tests/upload_idempotency.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//! DB-backed tests for the upload idempotency key (migration 022).
|
||||||
|
//!
|
||||||
|
//! The guarantee under test is the one thing standing between a lost response and a duplicated
|
||||||
|
//! wedding photo: a retry of an upload that already committed must NOT create a second row, and
|
||||||
|
//! must not charge the guest's storage quota twice. The whole mechanism is SQL — a partial unique
|
||||||
|
//! index plus `ON CONFLICT DO NOTHING` — so it is tested against a real database with the real
|
||||||
|
//! migrations applied, using the same statements `src/` runs.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::*;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// SRC: `models/upload.rs::Upload::create` — the insert, verbatim.
|
||||||
|
///
|
||||||
|
/// Returns the new row's id, or `None` when the key was already stored. The handler treats
|
||||||
|
/// `None` as "a concurrent retry won" and replays the stored row instead of committing.
|
||||||
|
async fn create_upload(
|
||||||
|
pool: &PgPool,
|
||||||
|
event_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
original_path: &str,
|
||||||
|
client_upload_id: Option<Uuid>,
|
||||||
|
) -> Option<Uuid> {
|
||||||
|
let row: Option<(Uuid,)> = sqlx::query_as(
|
||||||
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host) DO NOTHING
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(original_path)
|
||||||
|
.bind("image/jpeg")
|
||||||
|
.bind(1_000i64)
|
||||||
|
.bind(Option::<String>::None)
|
||||||
|
.bind(client_upload_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.expect("create_upload");
|
||||||
|
row.map(|(id,)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SRC: `models/upload.rs::Upload::find_by_client_upload_id` — the lookup, verbatim.
|
||||||
|
async fn find_by_key(pool: &PgPool, user_id: Uuid, client_upload_id: Uuid) -> Option<Uuid> {
|
||||||
|
let row: Option<(Uuid,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM upload
|
||||||
|
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(client_upload_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.expect("find_by_key");
|
||||||
|
row.map(|(id,)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_count(pool: &PgPool) -> i64 {
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM upload")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("upload_count")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core guarantee. A phone that loses the response and re-sends the same photo gets the
|
||||||
|
/// original row back, not a second copy in the gallery and a second charge against its quota.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_same_key_can_only_ever_store_one_upload(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Wackelige Wanda").await;
|
||||||
|
let key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let first = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
|
||||||
|
assert!(first.is_some(), "the first attempt must store the upload");
|
||||||
|
|
||||||
|
// The retry: same key, and (as after a real re-send) a different file on disk.
|
||||||
|
let second = create_upload(&pool, event_id, user_id, "originals/b.jpg", Some(key)).await;
|
||||||
|
assert!(
|
||||||
|
second.is_none(),
|
||||||
|
"a retry of a committed upload must not insert a second row"
|
||||||
|
);
|
||||||
|
assert_eq!(upload_count(&pool).await, 1);
|
||||||
|
|
||||||
|
// And the handler can find the winner to replay it.
|
||||||
|
assert_eq!(find_by_key(&pool, user_id, key).await, first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index must not over-reach. Two genuinely different photos carry different keys and must
|
||||||
|
/// both land — this is the ordinary case, and breaking it would silently drop uploads.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn different_keys_are_different_uploads(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Fleißige Frieda").await;
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
assert!(
|
||||||
|
create_upload(
|
||||||
|
&pool,
|
||||||
|
event_id,
|
||||||
|
user_id,
|
||||||
|
"originals/x.jpg",
|
||||||
|
Some(Uuid::new_v4())
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(upload_count(&pool).await, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index is PARTIAL, and this is why. Every upload that predates migration 022, and any
|
||||||
|
/// client that doesn't send a key, carries NULL — if those collided, the first such upload would
|
||||||
|
/// block every subsequent one and the whole event would fail after one photo.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn uploads_without_a_key_never_collide(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Alte Anna").await;
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
assert!(
|
||||||
|
create_upload(&pool, event_id, user_id, "originals/legacy.jpg", None)
|
||||||
|
.await
|
||||||
|
.is_some(),
|
||||||
|
"a NULL key must never be treated as a duplicate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(upload_count(&pool).await, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lookup is scoped to the owner. The key alone is unique, so this can only matter if a key
|
||||||
|
/// ever repeated across users — but a replay that handed one guest another guest's upload row
|
||||||
|
/// would be a data leak, so the scope is asserted rather than assumed.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn the_replay_lookup_never_crosses_users(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let owner = seed_user(&pool, event_id, "Besitzerin Bea").await;
|
||||||
|
let other = seed_user(&pool, event_id, "Fremder Franz").await;
|
||||||
|
let key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let id = create_upload(&pool, event_id, owner, "originals/a.jpg", Some(key)).await;
|
||||||
|
|
||||||
|
assert_eq!(find_by_key(&pool, owner, key).await, id);
|
||||||
|
assert_eq!(
|
||||||
|
find_by_key(&pool, other, key).await,
|
||||||
|
None,
|
||||||
|
"another guest's retry must not resolve to this upload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deleted photo must not be resurrected by a stale queue item. If the guest uploaded, deleted,
|
||||||
|
/// and their queue then retried the original request, replaying the deleted row would put the
|
||||||
|
/// photo they removed back in the gallery — so the lookup excludes soft-deleted rows and the
|
||||||
|
/// retry becomes a fresh upload instead.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Reumütige Rita").await;
|
||||||
|
let key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
|
||||||
|
.await
|
||||||
|
.expect("first insert");
|
||||||
|
// `taken_down_by_host = FALSE` — the GUEST deleted their own photo. See migration 031 and the
|
||||||
|
// sibling test below: the two cases must behave differently, and this is the one that frees
|
||||||
|
// the key.
|
||||||
|
sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = FALSE WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("soft delete");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
find_by_key(&pool, user_id, key).await,
|
||||||
|
None,
|
||||||
|
"a soft-deleted upload must not be replayed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The other half of that rule, and the half that was missing (H9). Asserting only that the
|
||||||
|
// lookup returns None left the index free to disagree with it: migration 022's predicate
|
||||||
|
// covered soft-deleted rows, so the retry's INSERT hit `ON CONFLICT DO NOTHING` against the
|
||||||
|
// dead row, the replay lookup above then found nothing, and the handler answered 409 — which
|
||||||
|
// the client classifies terminal and purges the blob for. The photo was gone from the phone
|
||||||
|
// AND absent from the gallery, with no way back.
|
||||||
|
//
|
||||||
|
// Migration 026 narrowed the index to live rows so a retry after a delete inserts a FRESH
|
||||||
|
// upload, which is what `find_by_client_upload_id`'s own doc comment always claimed happened.
|
||||||
|
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
|
||||||
|
assert!(
|
||||||
|
retried.is_some(),
|
||||||
|
"a retry after the guest deleted the photo must create a fresh upload, not 409 forever"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
retried,
|
||||||
|
Some(id),
|
||||||
|
"the retry must be a new row, not the dead one"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
find_by_key(&pool, user_id, key).await,
|
||||||
|
retried,
|
||||||
|
"the live row is the one the replay lookup must now find"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mirror of the test above, and the case migration 026's rationale did not consider.
|
||||||
|
///
|
||||||
|
/// `deleted_at` is set by the guest deleting their own photo AND by `host_delete_upload`. Freeing
|
||||||
|
/// the idempotency key on both meant a takedown could be silently undone: the guest's response was
|
||||||
|
/// lost, so their queue still holds the item; the host removes the photo (bumping the keepsake
|
||||||
|
/// epoch and rebuilding the archive without it); the phone reconnects ten minutes later and
|
||||||
|
/// retries; the key is free, the INSERT succeeds, and the photo is back in the feed and in the next
|
||||||
|
/// keepsake — under a NEW uuid that matches nothing in the host's moderation history, with nothing
|
||||||
|
/// logged to say a takedown was reversed. The host has to find and delete it a second time.
|
||||||
|
///
|
||||||
|
/// Migration 031 keeps the key claimed for a host takedown, so the retry resolves to the duplicate
|
||||||
|
/// path and is refused. Refusing is the correct answer here: the photo was deliberately removed.
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn a_host_takedown_is_not_undone_by_a_late_retry(pool: PgPool) {
|
||||||
|
let event_id = seed_event(&pool, "wedding").await;
|
||||||
|
let user_id = seed_user(&pool, event_id, "Übermütiger Uwe").await;
|
||||||
|
let key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
|
||||||
|
.await
|
||||||
|
.expect("first insert");
|
||||||
|
|
||||||
|
// SRC: `models/upload.rs::Upload::soft_delete_in_event` with `by_host = true`.
|
||||||
|
sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = TRUE WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("host takedown");
|
||||||
|
|
||||||
|
let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
|
||||||
|
assert_eq!(
|
||||||
|
retried, None,
|
||||||
|
"a retry after a HOST takedown must be suppressed — otherwise the phone silently \
|
||||||
|
reinstates a photo the hosts removed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let live: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT count(*) FROM upload WHERE client_upload_id = $1 AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(live, 0, "the taken-down photo must stay gone");
|
||||||
|
|
||||||
|
// And the handler must be able to tell the guest WHY, rather than "already processed".
|
||||||
|
// SRC: `models/upload.rs::Upload::taken_down_by_client_upload_id`.
|
||||||
|
let was_taken_down: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS (
|
||||||
|
SELECT 1 FROM upload
|
||||||
|
WHERE client_upload_id = $1 AND user_id = $2
|
||||||
|
AND deleted_at IS NOT NULL AND taken_down_by_host
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("takedown lookup");
|
||||||
|
assert!(
|
||||||
|
was_taken_down,
|
||||||
|
"the refusal must be attributable to a takedown so the queue can say so"
|
||||||
|
);
|
||||||
|
}
|
||||||
24
docker-compose.build.yml
Normal file
24
docker-compose.build.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Build overlay. NOT loaded automatically (same convention as docker-compose.dev.yml — this
|
||||||
|
# repo deliberately never uses the auto-loaded `docker-compose.override.yml` name).
|
||||||
|
#
|
||||||
|
# Used ONLY on a workstation that builds and pushes images. Never on the production server,
|
||||||
|
# which pulls: see the note on the `app` service in docker-compose.yml.
|
||||||
|
#
|
||||||
|
# Restores the `build:` keys that production omits, so `docker compose build` still works from
|
||||||
|
# a single source of truth for the build context paths:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.build.yml build
|
||||||
|
#
|
||||||
|
# For the actual release build use buildx directly instead — it is what produces linux/amd64
|
||||||
|
# images from an arm64 Mac and pushes them in one step (see DEPLOYMENT_RUNBOOK.md §6):
|
||||||
|
#
|
||||||
|
# docker buildx build --platform linux/amd64 -t registry.mc02.dev/eventsnap/app:vX.Y.Z --push ./backend
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
@@ -21,11 +21,15 @@ services:
|
|||||||
# app user can't create it from `/`, so every upload 500s with EACCES. The media
|
# 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.
|
# volume is mounted at /media (see docker-compose.yml) — point the app there.
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
# Recent Docker Compose interpolates env_file values, so the `$` segments of the
|
# Set here purely so local dev needs no `.env` at all. The `$` are doubled because
|
||||||
# bcrypt ADMIN_PASSWORD_HASH in .env get eaten (the salt reads as an unset var) —
|
# THIS is a `docker-compose.yml` `environment:` value, where Compose does interpolate.
|
||||||
# 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
|
# DO NOT COPY THE DOUBLING INTO `.env`. An earlier version of this comment claimed
|
||||||
# bug — escape the hash as `$$` in .env, or set it via `environment:` there too.
|
# production had "the same bug" and told you to escape the hash as `$$` there — that is
|
||||||
|
# wrong and it breaks a working deployment. Compose uses SINGLE-QUOTED `env_file` values
|
||||||
|
# literally, which is the form `.env.example` ships, so the `$` segments survive intact;
|
||||||
|
# doubling them produces a 74-character string that `looks_bcrypt` rejects and the app
|
||||||
|
# refuses to boot on. Verified with `docker compose exec app printenv`.
|
||||||
ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye"
|
ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye"
|
||||||
# Smoke-testing the comment kill-switch: boot-time flag, so it needs a restart
|
# 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
|
# (not an admin-UI toggle). Backend rejects new comments (403) and the frontend
|
||||||
@@ -33,9 +37,10 @@ services:
|
|||||||
COMMENTS_ENABLED: "false"
|
COMMENTS_ENABLED: "false"
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
# The prod caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` expands
|
# The caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` would expand to empty
|
||||||
# to empty and the site block collapses into a malformed global block. Supply it
|
# and the site block would collapse into a malformed global block. Supply it for local dev
|
||||||
# for local dev (from .env → localhost, which Caddy serves with a local self-signed
|
# (from .env → localhost, which Caddy serves with a local self-signed cert). Production
|
||||||
# cert). NOTE: the prod compose likely needs DOMAIN wired to caddy too.
|
# already wires DOMAIN into caddy's `environment:`, guarded with `:?` so an unset value
|
||||||
|
# fails the command instead of silently producing a site with no address.
|
||||||
environment:
|
environment:
|
||||||
DOMAIN: ${DOMAIN}
|
DOMAIN: ${DOMAIN}
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
|
# Docker's default json-file driver has NO rotation at all, and every container writes to the
|
||||||
|
# same filesystem as postgres_data, media_data and exports_data. Filling that filesystem does
|
||||||
|
# not degrade one subsystem — Postgres stops being able to write and the whole event goes down
|
||||||
|
# (see README "Sizing the disk"). This caps logs at 30 MB per service, permanently.
|
||||||
|
#
|
||||||
|
# Paired with RUST_LOG in .env: without it the app falls back to `eventsnap_backend=debug,
|
||||||
|
# tower_http=debug` (main.rs), which is a debug line per HTTP request including every preview.
|
||||||
|
x-logging: &default-logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
# `:?` for the same reason EVENTSNAP_VERSION and DOMAIN use it, and this is the worst place
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
# to omit it. These are interpolated into `environment:`, which OVERRIDES `env_file` — so an
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
# unset value does not fall back to `.env`, it resolves to the empty string and initdb
|
||||||
|
# creates a role and database literally named "". `DATABASE_URL` still points at `eventsnap`,
|
||||||
|
# so the app hits `FATAL: role "eventsnap" does not exist` forever, `pg_isready -U "" -d ""`
|
||||||
|
# never passes, `app` never turns healthy, and Caddy — gated on `service_healthy` — never
|
||||||
|
# starts, so port 443 is dead for the whole event. The only clean exit is `down -v`, which
|
||||||
|
# destroys the volume. The runbook's §3 secrets list omitted both of these, so an operator
|
||||||
|
# writing `.env` from the runbook rather than from `.env.example` walked straight into it.
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env (see .env.example)}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env (see .env.example)}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -17,35 +40,130 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
# 1G, not 512M. DATABASE_MAX_CONNECTIONS defaults to 30 for a ~100-guest event
|
# 1G, not 512M. Postgres 16's default shared_buffers plus a pool of backends
|
||||||
# (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's
|
# leaves very little headroom at 512M, and an OOM here does not degrade one
|
||||||
# default shared_buffers leaves very little headroom at 512M. An OOM here does
|
# feature — it takes the event down, because every request path touches the
|
||||||
# not degrade one feature — it takes the event down, because every request
|
# database.
|
||||||
# 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.
|
# `.env.example` now sets DATABASE_MAX_CONNECTIONS=15, sized to the 2 vCPU this
|
||||||
|
# box has rather than to the guest count: since migration 024 replaced the feed
|
||||||
|
# view's GROUP BY with scalar subqueries, a feed page costs well under a
|
||||||
|
# millisecond, so connections are no longer spent waiting. Raising it back
|
||||||
|
# toward 30 means raising this limit with it.
|
||||||
memory: 1G
|
memory: 1G
|
||||||
|
# CPU ceiling. Postgres is the one service that must never be starved: every request
|
||||||
|
# path touches it, so a CPU-bound image resize elsewhere degrades the whole event
|
||||||
|
# rather than one feature. 1.5 of 2 cores is a ceiling, not a reservation — it only
|
||||||
|
# binds when something else is competing.
|
||||||
|
cpus: '1.5'
|
||||||
|
reservations:
|
||||||
|
# Memory floor only. `reservations.cpus` USED TO BE HERE and did nothing: outside
|
||||||
|
# Swarm, `docker compose up` silently drops it — verified by inspecting a running
|
||||||
|
# container, where CpuShares, CpuQuota and CpusetCpus were all unset while
|
||||||
|
# `limits.cpus` and `reservations.memory` came through as NanoCpus and
|
||||||
|
# MemoryReservation. So the comment claiming it was "the piece that actually
|
||||||
|
# protects the database" described a guarantee the box never had.
|
||||||
|
#
|
||||||
|
# It matters on a CX22: the ceilings below sum to 1.2 + 0.6 + 0.5 = 2.3 on 2 vCPU,
|
||||||
|
# so the other services CAN oversubscribe the machine, and with every container on
|
||||||
|
# the default weight Postgres competes on equal footing with two image resizes and
|
||||||
|
# an ffmpeg poster. `cpu_shares` is the knob that survives the translation — see the
|
||||||
|
# weights on each service.
|
||||||
|
memory: 256M
|
||||||
|
# Relative CPU weight under contention (Docker default is 1024). Only consulted when the
|
||||||
|
# CPU is actually saturated, which is exactly the moment the database must not lose.
|
||||||
|
cpu_shares: 2048
|
||||||
|
# Caps memory+swap together, so the `memory` limit above stays the real ceiling.
|
||||||
|
#
|
||||||
|
# Compose sets `Memory` but leaves `MemorySwap` unset, and Docker then permits swap EQUAL to
|
||||||
|
# the memory limit — so following the runbook's "add 2 GB of swap" step silently DOUBLES every
|
||||||
|
# container ceiling, to 5 GiB of ceilings on a 3.82 GiB box. Nothing OOMs; instead Postgres's
|
||||||
|
# working set becomes swap-eligible on a shared-tenancy VPS SSD, turning a bounded OOM-kill
|
||||||
|
# (which restarts in seconds) into unbounded latency everywhere with no signal but "it's slow".
|
||||||
|
#
|
||||||
|
# The runbook used to tell the operator to add this BY HAND, which also broke its own §0 gate
|
||||||
|
# requiring docker-compose.yml to be unmodified. Shipped here instead. 1152m against a 1G limit
|
||||||
|
# leaves 128 MB of swap — enough to absorb a spike, not enough to hide one.
|
||||||
|
#
|
||||||
|
# Verified rather than assumed: service-level `memswap_limit` DOES compose with
|
||||||
|
# `deploy.resources.limits.memory` — `docker inspect` reports Memory=1073741824
|
||||||
|
# MemorySwap=1207959552.
|
||||||
|
memswap_limit: 1152m
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
# Production PULLS a prebuilt image; it never compiles. A release build of this crate is
|
||||||
context: ./backend
|
# fat-LTO over 427 dependencies (see backend/Cargo.toml [profile.release]) and peaks well
|
||||||
dockerfile: Dockerfile
|
# above the RAM a 4 GB box has spare with the stack running — and a rollback would be a
|
||||||
|
# second build under pressure. Images are built on a workstation and pushed; see
|
||||||
|
# docker-compose.build.yml and DEPLOYMENT_RUNBOOK.md.
|
||||||
|
#
|
||||||
|
# There is deliberately NO `build:` key here: without one, a wrong tag fails instantly with
|
||||||
|
# "manifest unknown" instead of silently starting a 45-minute compile on the event server.
|
||||||
|
# The `:?` form fails loudly on an unset variable rather than resolving to an empty tag.
|
||||||
|
image: registry.mc02.dev/eventsnap/app:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
|
# Default to info. Without this the code fallback in main.rs applies, which is
|
||||||
|
# `eventsnap_backend=debug,tower_http=debug` — a line per HTTP request AND per
|
||||||
|
# response, including every preview fetch, for a multi-day run. The x-logging cap
|
||||||
|
# above bounds the disk cost but not the CPU/IO one.
|
||||||
|
#
|
||||||
|
# Set here rather than only in `.env` because a stock deploy sets RUST_LOG nowhere,
|
||||||
|
# and this is the layer an operator will actually find when they need to raise it
|
||||||
|
# for a single event (`RUST_LOG=eventsnap_backend=debug docker compose up -d app`).
|
||||||
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
# 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
|
||||||
|
# Pinned beside MEDIA_PATH for the same reason, and because nothing validates it:
|
||||||
|
# config.rs defaults it to /exports but never checks that it is a mount or that it
|
||||||
|
# differs from media_path. A stray EXPORT_PATH in .env builds the keepsake into the
|
||||||
|
# container's writable layer, where it passes every health check and disk preflight
|
||||||
|
# and then evaporates on the next `up -d`.
|
||||||
|
EXPORT_PATH: /exports
|
||||||
# The media volume is mounted at /media (below), so the app MUST write there.
|
# 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
|
# 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
|
# (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`,
|
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
|
||||||
# so this is authoritative for the container.
|
# so this is authoritative for the container.
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
|
# Third member of the MEDIA_PATH / EXPORT_PATH family, and the nastiest of the three because
|
||||||
|
# the app itself reports nothing wrong. The healthcheck below hardcodes 127.0.0.1:3000 and
|
||||||
|
# the Caddyfile hardcodes app:3000, while `.env.example` presents APP_PORT as an ordinary
|
||||||
|
# editable line. Change it there and the app boots and serves happily on the new port, the
|
||||||
|
# healthcheck fails forever, `app` never turns healthy — and because caddy is gated on
|
||||||
|
# `service_healthy`, CADDY NEVER STARTS AT ALL. Port 443 is dead for the whole event and the
|
||||||
|
# only diagnostic is `dependency failed to start`.
|
||||||
|
APP_PORT: "3000"
|
||||||
|
# Fourth member of the family, pinned for a reason the other three don't have: this one is
|
||||||
|
# boot-FATAL. `db.rs` rejects an unparseable value with `bail!` rather than falling back to
|
||||||
|
# the default (right call — an operator tuning a knob that silently never applied is worse),
|
||||||
|
# which means a stray quote, a trailing inline comment, or a smart-quote pasted into `.env`
|
||||||
|
# no longer degrades anything: it exits 1, and `restart: unless-stopped` crash-loops the app
|
||||||
|
# behind a live Caddy. `.trim()` covers whitespace and CRLF; it cannot cover those.
|
||||||
|
#
|
||||||
|
# Sized to the 2 vCPU this box has, not to the guest count — see `.env.example` and the
|
||||||
|
# `db` memory limit, which must be raised together with this.
|
||||||
|
DATABASE_MAX_CONNECTIONS: "15"
|
||||||
|
# Pinned for the same reason as MEDIA_PATH: `environment` beats `env_file`, so this cannot
|
||||||
|
# be lost by an operator who copies `.env.example` and edits only the secrets — which is
|
||||||
|
# the likely path, and `.env.example` ships the generic default of `true`.
|
||||||
|
#
|
||||||
|
# This is a product decision for this event, not a technical one: guests should be present
|
||||||
|
# at the party, not in a comment thread. Likes and captions stay on and are unaffected.
|
||||||
|
# Boot-time only, so changing it means `docker compose up -d`, not an admin toggle.
|
||||||
|
# To re-enable comments, delete this line and set COMMENTS_ENABLED in .env.
|
||||||
|
COMMENTS_ENABLED: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
# Longer than the app's own 10s shutdown backstop (main.rs SHUTDOWN_GRACE), because Docker's
|
||||||
|
# default stop timeout is ALSO 10s — so a redeploy raced the graceful drain and could SIGKILL
|
||||||
|
# the process at the exact moment it was finishing, truncating the in-flight upload the
|
||||||
|
# graceful shutdown exists to protect. The app always exits well before 20s.
|
||||||
|
stop_grace_period: 20s
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/media
|
- media_data:/media
|
||||||
# Export archives live OUTSIDE /media so the public media ServeDir can't
|
# Export archives live OUTSIDE /media so the public media ServeDir can't
|
||||||
@@ -69,17 +187,54 @@ services:
|
|||||||
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
|
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
|
||||||
# OOM the single box and take down Postgres.
|
# OOM the single box and take down Postgres.
|
||||||
memory: 1G
|
memory: 1G
|
||||||
|
# CPU ceiling for the WHOLE app container. Bounded below 2.0 so it can never take both
|
||||||
|
# cores on its own, which is what protects Postgres.
|
||||||
|
# COMPRESSION_WORKER_CONCURRENCY=2 is the memory bound; this is the CPU one.
|
||||||
|
#
|
||||||
|
# It does NOT cap "the two image workers + ffmpeg" separately from the request path, as
|
||||||
|
# this used to claim. `compression.rs` runs that work in `tokio::task::spawn_blocking` —
|
||||||
|
# same process, same cgroup as every Axum handler — and `cpus`/`cpu_shares` are
|
||||||
|
# per-container, so nothing here can tell them apart. Concretely: `cpu.max` is
|
||||||
|
# `120000 100000`, so two CPU-pegged blocking workers exhaust the 120 ms quota after
|
||||||
|
# ~60 ms of each 100 ms period and the kernel then freezes the ENTIRE cgroup — uploads,
|
||||||
|
# feed and SSE included — for the remainder. Across a 100-photo burst (~210 s of
|
||||||
|
# draining) every request in that window can eat up to 40 ms of throttle stall.
|
||||||
|
#
|
||||||
|
# Kept anyway: an app that can take both cores starves Postgres, and every request path
|
||||||
|
# goes through Postgres. A slightly stalled request beats a starved database. If the
|
||||||
|
# backlog needs to drain faster, the knob is COMPRESSION_WORKER_CONCURRENCY, not this.
|
||||||
|
cpus: '1.2'
|
||||||
|
# Half the default weight, and this is the ceiling's other half: the cap alone leaves
|
||||||
|
# 0.8 vCPU for db + frontend + caddy, which frontend and caddy can consume between them.
|
||||||
|
# Compression is throughput work with no guest waiting on it, so it yields to Postgres —
|
||||||
|
# which every request path, including the app's own, is blocked on.
|
||||||
|
cpu_shares: 512
|
||||||
|
# See the `db` service for why this is shipped rather than hand-added: without it, the
|
||||||
|
# runbook's swap step doubles this ceiling. 1152m against a 1G limit.
|
||||||
|
memswap_limit: 1152m
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
# Pulled, not built — see the note on `app` above.
|
||||||
context: ./frontend
|
image: registry.mc02.dev/eventsnap/frontend:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
# adapter-node behind Caddy TLS needs the public origin for CSRF checks on
|
# adapter-node behind Caddy TLS needs the public origin for CSRF checks on
|
||||||
# POST form actions — without it they fail only in production.
|
# POST form actions — without it they fail only in production.
|
||||||
ORIGIN: "https://${DOMAIN}"
|
# `:?` for the same reason EVENTSNAP_VERSION uses it. A blank DOMAIN doesn't fail — it
|
||||||
|
# produces `https://` here and collapses the Caddyfile's site block below, so the stack
|
||||||
|
# comes up with no TLS and no site and the only symptom is a browser error.
|
||||||
|
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
|
||||||
|
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
|
||||||
|
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
|
||||||
|
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response
|
||||||
|
# buffers, which are external memory V8 doesn't count at all. The practical effect is that
|
||||||
|
# V8 can never reach its own limit and run an emergency GC, so the only backpressure is a
|
||||||
|
# kernel SIGKILL: an arrival burst of ~100 guests SSR-rendering /join and /feed OOM-kills
|
||||||
|
# node, guests get the "Wir sind gleich zurück" page, it restarts, and the burst is still
|
||||||
|
# there. Setting the ceiling below the cgroup limit restores GC as the first line of defence.
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=160"
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
expose:
|
expose:
|
||||||
@@ -96,16 +251,27 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 256M
|
memory: 256M
|
||||||
|
# Node SSR is bursty and not latency-critical for guests (the app is CSR after the
|
||||||
|
# first paint), so it yields first under contention.
|
||||||
|
cpus: '0.6'
|
||||||
|
# Lowest weight of the four, for the reason above: `ssr = false`, so this serves the shell
|
||||||
|
# and then guests talk to `app` directly. A slow shell delays a reload; a slow database
|
||||||
|
# breaks the event.
|
||||||
|
cpu_shares: 256
|
||||||
|
# See the `db` service for why this is shipped rather than hand-added: without it, the
|
||||||
|
# runbook's swap step doubles this ceiling. 320m against a 256M limit.
|
||||||
|
memswap_limit: 320m
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
# The Caddyfile's site address is `{$DOMAIN}`, read from THIS container's env.
|
# 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
|
# 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 —
|
# 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.
|
# Caddy needs it in `environment`, and this keeps the Caddyfile the single source.
|
||||||
DOMAIN: ${DOMAIN}
|
DOMAIN: ${DOMAIN:?set DOMAIN in .env}
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -121,6 +287,15 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 256M
|
memory: 256M
|
||||||
|
# TLS termination and static serving. Small but must stay responsive — a starved
|
||||||
|
# reverse proxy makes every service look down.
|
||||||
|
cpus: '0.5'
|
||||||
|
# Left at the Docker default (1024). Caddy is cheap but sits in front of everything, so
|
||||||
|
# it must not be the bottleneck; it is capped at 0.5 vCPU regardless.
|
||||||
|
cpu_shares: 1024
|
||||||
|
# See the `db` service for why this is shipped rather than hand-added: without it, the
|
||||||
|
# runbook's swap step doubles this ceiling. 320m against a 256M limit.
|
||||||
|
memswap_limit: 320m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
|||||||
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
|
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
|
||||||
| **Onboarding & sessions** | | | | |
|
| **Onboarding & sessions** | | | | |
|
||||||
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
|
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
|
||||||
| First-visit guided tour (4 steps) | ✓ | ✓ | ✓ | Dismissed once, flag in `localStorage` |
|
| First-visit guided tour (6 steps) | ✓ | ✓ | ✓ | Welcome, upload, hashtags, long-press, hell/dunkel, PIN (+ privacy-note pointer when one is set). Dismissed once, flag in `localStorage` |
|
||||||
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
|
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
|
||||||
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
|
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
|
||||||
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
|
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
|
||||||
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
|
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
|
||||||
| Log out / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
|
| Log out ("Abmelden" / "Auf allen Geräten abmelden") | ✓ | ✓ | ✓ | Confirmation bottom-sheet; `DELETE /session` (this device) or `DELETE /sessions` (all devices). Nothing is deleted — account, PIN and uploads stay |
|
||||||
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
|
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
|
||||||
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
|
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
|
||||||
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
|
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
|
||||||
@@ -42,7 +42,7 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
|||||||
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
|
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
|
||||||
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
|
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
|
||||||
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
|
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
|
||||||
| Fullscreen lightbox with swipe | ✓ | ✓ | ✓ | Swipe navigates the filtered set |
|
| Fullscreen lightbox with prev/next navigation | ✓ | ✓ | ✓ | Navigates the *filtered* set. On-screen prev/next controls, ← / → arrow keys, and swipe left/right on touch |
|
||||||
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
|
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
|
||||||
| Read comments on any post | ✓ | ✓ | ✓ | |
|
| Read comments on any post | ✓ | ✓ | ✓ | |
|
||||||
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
|
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
|
||||||
@@ -56,11 +56,11 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
|||||||
| | | | | |
|
| | | | | |
|
||||||
| **Moderation (Host)** | | | | |
|
| **Moderation (Host)** | | | | |
|
||||||
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
|
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
|
||||||
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
|
| Ban / unban a user | | ✓ | ✓ | Ban **always hides** their existing uploads — no opt-out, the modal is a plain confirm. Unban restores them. Both invalidate and rebuild a released keepsake. See USER_JOURNEYS §9/§10 |
|
||||||
| Delete any upload | | ✓ | ✓ | |
|
| Delete any upload | | ✓ | ✓ | |
|
||||||
| Delete any comment | | ✓ | ✓ | |
|
| Delete any comment | | ✓ | ✓ | |
|
||||||
| Promote guest to Host | | ✓ | ✓ | |
|
| Promote guest to Host | | ✓ | ✓ | |
|
||||||
| Demote Host to guest | | ✓ | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
|
| Demote Host to guest | | | ✓ | **Admin only** — the backend 403s a Host demoting a peer Host (F1: it would launder past the ban / PIN-reset peer guards). Nobody may change their own role; Admins cannot be demoted at all. The button is hidden for non-admin Hosts |
|
||||||
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
|
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
|
||||||
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
|
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
|
||||||
| Unlock new uploads | | ✓ | ✓ | |
|
| Unlock new uploads | | ✓ | ✓ | |
|
||||||
@@ -78,18 +78,18 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
|||||||
| Edit compression-worker concurrency | | | ✓ | |
|
| Edit compression-worker concurrency | | | ✓ | |
|
||||||
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
|
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
|
||||||
| Inspect export job list & progress | | | ✓ | |
|
| Inspect export job list & progress | | | ✓ | |
|
||||||
| Low-disk alert (< 10 GB free) | | | ◯ | Planned |
|
| Low-disk alert | | ✓ | ✓ | Red banner at the top of the Host dashboard when `disk_low`, naming free space and the keepsake's required bytes. Fails closed to "not low" on an unreadable mount |
|
||||||
| Event banner / cover image | | | ◯ | DB column exists, no UI |
|
| Event banner / cover image | | | ◯ | DB column exists, no UI |
|
||||||
| | | | | |
|
| | | | | |
|
||||||
| **Quota visibility (Guest-facing)** | | | | |
|
| **Quota visibility (staff-only)** | | | | |
|
||||||
| Show current per-user quota estimate | ✓ | ✓ | ✓ | "Du hast X MB von Y MB genutzt." in My Account and on the upload screen. Computed from the live formula. Hidden when quota enforcement is toggled off |
|
| Show current per-user quota estimate | | ✓ | ✓ | **Deliberately not shown to guests** — the storage widget in My Account and on the upload screen is gated on host/admin. Computed from the live formula; also hidden when quota enforcement is toggled off |
|
||||||
| | | | | |
|
| | | | | |
|
||||||
| **Export** | | | | |
|
| **Export** | | | | |
|
||||||
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
|
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
|
||||||
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
|
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
|
||||||
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
|
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
|
||||||
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
|
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
|
||||||
| Per-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
|
| Per-**user** export download rate limit (3 / day) | ✓ | ✓ | ✓ | Keyed on the user id, not the IP — a venue behind one NAT would otherwise share a 3/day budget across every guest |
|
||||||
| | | | | |
|
| | | | | |
|
||||||
| **Banned guest** (subset) | | | | |
|
| **Banned guest** (subset) | | | | |
|
||||||
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
|
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
|
||||||
@@ -143,7 +143,7 @@ email, no password, no account portal.
|
|||||||
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
|
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
|
||||||
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
|
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
|
||||||
link routing to `/recover` for users who already know they want to sign in.
|
link routing to `/recover` for users who already know they want to sign in.
|
||||||
- **PIN reset by Host / Admin.** Planned. If a guest loses their PIN and `localStorage` is
|
- **PIN reset by Host / Admin.** If a guest loses their PIN and `localStorage` is
|
||||||
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
|
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
|
||||||
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
|
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
|
||||||
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
|
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
|
||||||
@@ -153,9 +153,13 @@ email, no password, no account portal.
|
|||||||
Admins can.
|
Admins can.
|
||||||
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
|
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
|
||||||
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
|
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
|
||||||
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by an admin. **Hosts
|
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by a host or an admin.
|
||||||
may also demote other Hosts to guests** (planned) — but never themselves, to avoid
|
**Only an Admin may change a Host's role** — a plain Host may promote guests but may not
|
||||||
locking the event out of moderation. Admins can demote anyone except admins.
|
demote a peer Host, because demoting one would then let them ban or PIN-reset (→
|
||||||
|
`/recover` takeover) that ex-peer, the guards keying off the target's *current* role (F1).
|
||||||
|
Nobody may change their own role, so the event can't be locked out of moderation, and
|
||||||
|
Admins are un-demotable and un-bannable. Enforced in `handlers::host::set_role`; the
|
||||||
|
button is hidden for non-admin Hosts.
|
||||||
|
|
||||||
### 2.2 Posting pipeline
|
### 2.2 Posting pipeline
|
||||||
|
|
||||||
@@ -188,8 +192,9 @@ The upload pipeline is built for flaky mobile networks:
|
|||||||
the loaded uploads, so typing never hits the server.
|
the loaded uploads, so typing never hits the server.
|
||||||
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
|
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
|
||||||
hashtag + uploader combine with AND. Matches the redesign concept exactly.
|
hashtag + uploader combine with AND. Matches the redesign concept exactly.
|
||||||
- **Lightbox** — fullscreen view, swipe navigates the *filtered* set, with embedded
|
- **Lightbox** — fullscreen view with embedded like/comment UI. Navigation across the
|
||||||
like/comment UI.
|
*filtered* set is available three ways: on-screen prev/next controls, the ← / → arrow
|
||||||
|
keys, and a left/right swipe on touch.
|
||||||
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
|
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
|
||||||
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
|
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
|
||||||
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
|
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
|
||||||
@@ -197,15 +202,18 @@ The upload pipeline is built for flaky mobile networks:
|
|||||||
### 2.4 Host / Admin tooling
|
### 2.4 Host / Admin tooling
|
||||||
|
|
||||||
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
|
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
|
||||||
Nutzerverwaltung. Ban modal asks explicitly whether to hide the user's existing uploads
|
Nutzerverwaltung. The ban modal is a plain confirm: banning **always hides** the user's
|
||||||
from the public feed. Promote/demote, lock/unlock, release-gallery are one-tap.
|
existing uploads, there is no keep-visible option. Promote/demote/unban/release are each
|
||||||
- **Admin dashboard** — same dashboard plus three more inner tabs (Stats, Config, Export,
|
behind a confirmation sheet that spells out the consequence, not one-tap.
|
||||||
|
- **Admin dashboard** — same user list plus four inner tabs (Stats, Config, Export,
|
||||||
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
|
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
|
||||||
guest count, and compression concurrency — all stored in the `config` table and read on
|
guest count and the colour theme — all stored in the `config` table and read on
|
||||||
each request, so changes take effect without a restart. Disk widget pulls from the
|
each request, so changes take effect without a restart. Disk widget pulls from the
|
||||||
`sysinfo` crate live.
|
`sysinfo` crate live. The Export tab mirrors the host dashboard's release controls:
|
||||||
|
the release button is disabled once released, the live keepsake status (progress /
|
||||||
|
ready / failure reason) is shown, and a rebuild is offered as the recovery path.
|
||||||
|
|
||||||
### 2.5 Data mode (planned)
|
### 2.5 Data mode
|
||||||
|
|
||||||
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
|
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
|
||||||
guest can be on Saver on their phone and Original on their laptop.
|
guest can be on Saver on their phone and Original on their laptop.
|
||||||
@@ -218,7 +226,7 @@ guest can be on Saver on their phone and Original on their laptop.
|
|||||||
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
|
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
|
||||||
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
|
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
|
||||||
|
|
||||||
### 2.6 Rate limits and quotas — toggleable (planned)
|
### 2.6 Rate limits and quotas — toggleable
|
||||||
|
|
||||||
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
|
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
|
||||||
|
|
||||||
@@ -230,13 +238,14 @@ The Admin Config tab gains explicit on/off toggles in addition to the numeric in
|
|||||||
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
|
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
|
||||||
independently.
|
independently.
|
||||||
|
|
||||||
When a feature is toggled off, the relevant UI in the guest-facing app should adapt: e.g.
|
When a feature is toggled off, the dependent UI adapts: the "Speicher: X / Y" widget hides
|
||||||
the "Du hast X von Y MB genutzt" widget hides itself when storage quota is disabled. The
|
itself when the storage quota is disabled. The quota estimate is computed from the same
|
||||||
quota estimate is computed from the same formula the server uses
|
formula the server uses (`(free_disk × tolerance) / max(active_uploaders, 1)`) and is
|
||||||
(`(free_disk × tolerance) / max(active_uploaders, 1)`) — surfaced in My Account *and* on
|
surfaced in My Account *and* on the upload preview screen — but **only to hosts and
|
||||||
the upload preview screen so guests know before they pick files.
|
admins**. Guests never see it: the number moves as other people upload and as disk frees
|
||||||
|
up, which reads as a broken or unfair limit to someone who can't see why.
|
||||||
|
|
||||||
### 2.7 Privacy note (Datenschutzhinweis, planned)
|
### 2.7 Privacy note (Datenschutzhinweis)
|
||||||
|
|
||||||
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
|
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
|
||||||
Config). It's stored as a single config key (plain text, whitespace and newlines
|
Config). It's stored as a single config key (plain text, whitespace and newlines
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
|
|||||||
this PIN is the only way to sign in on another device. PIN is also written to
|
this PIN is the only way to sign in on another device. PIN is also written to
|
||||||
`localStorage`.
|
`localStorage`.
|
||||||
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
|
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
|
||||||
7. The **first-visit onboarding overlay** appears: dismissible steps (welcome, upload,
|
7. The **first-visit onboarding overlay** appears: six dismissible steps (welcome, upload,
|
||||||
hashtags, PIN, and a brief pointer to the **Datenschutzhinweis** in My Account).
|
hashtags, long-press for more actions, hell/dunkel design pick, and PIN — the PIN step
|
||||||
|
also points at the **Datenschutzhinweis** in My Account when the admin has set one).
|
||||||
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
|
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
|
||||||
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
|
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
|
|||||||
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
|
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
|
||||||
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
|
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
|
||||||
|
|
||||||
## 4. PIN forgotten — Host or Admin resets it (planned)
|
## 4. PIN forgotten — Host or Admin resets it
|
||||||
|
|
||||||
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
|
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
|
||||||
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
|
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
|
||||||
@@ -126,14 +127,23 @@ the Host can clean up later).
|
|||||||
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
|
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
|
||||||
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
|
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
|
||||||
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
|
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
|
||||||
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
|
viewer). Progress is visible on the Host dashboard *and* in the Admin dashboard's Export
|
||||||
`export-progress` keeps it live; `export-available` notifies all guests when ready.
|
tab; SSE `export-progress` keeps it live; `export-available` notifies all guests when
|
||||||
|
ready. Once released the button is disabled and reads *"Galerie bereits freigegeben"* —
|
||||||
|
a second release is a 409. If a keepsake half fails, both dashboards show the reason and
|
||||||
|
offer **Erneut versuchen** (rebuild); a ready keepsake can also be rebuilt behind a
|
||||||
|
confirm, during which guest downloads are briefly unavailable.
|
||||||
5. **Nutzerverwaltung** — search users; per-user controls:
|
5. **Nutzerverwaltung** — search users; per-user controls:
|
||||||
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
|
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
|
||||||
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
||||||
calls `POST /host/users/{id}/ban` (no body).
|
calls `POST /host/users/{id}/ban` (no body).
|
||||||
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
|
- **Entsperren** lifts the ban — and does two further things the confirm sheet now spells
|
||||||
only unban Guests; only an Admin may unban a Host.
|
out, because both are visible to the whole party: it clears `uploads_hidden`, so **all**
|
||||||
|
of that user's previously hidden uploads reappear in the gallery, the diashow and the
|
||||||
|
export; and it invalidates and rebuilds a released keepsake (`invalidate_and_arm`), so
|
||||||
|
every guest's download is unavailable for as long as that takes. A ban does the mirror
|
||||||
|
image of both. Same authority boundary as ban: a plain Host may only unban Guests; only
|
||||||
|
an Admin may unban a Host.
|
||||||
- **Host** promotes a guest to host (Hosts and Admins may do this).
|
- **Host** promotes a guest to host (Hosts and Admins may do this).
|
||||||
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
|
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
|
||||||
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
|
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
|
||||||
@@ -181,14 +191,18 @@ the Host can clean up later).
|
|||||||
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
|
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
|
||||||
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
|
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
|
||||||
export), quota tolerance, estimated guest count, compression-worker concurrency,
|
export), quota tolerance, estimated guest count, compression-worker concurrency,
|
||||||
plus the **Datenschutzhinweis** free-text editor and **on/off toggles** for the rate
|
plus the colour-theme picker, the **Datenschutzhinweis** free-text editor and
|
||||||
limiters and quotas (planned — see §16). Whitelist on the server side rejects
|
**on/off toggles** for the rate limiters and quotas (see §18). Whitelist on the
|
||||||
unknown keys. Values are read from the `config` table on each request — no restart
|
server side rejects unknown keys. Values are read from the `config` table on each
|
||||||
needed.
|
request — no restart needed.
|
||||||
- **Export**: list of past export jobs with status badges (pending / running / done /
|
- **Export**: the gallery-release control plus the list of export jobs with status
|
||||||
failed) and progress bars; refresh button re-polls.
|
badges (pending / running / done / failed) and progress bars; refresh button
|
||||||
- **Nutzer**: same user list as Host, with the additional Demote action and (planned)
|
re-polls. The release control mirrors the Host dashboard exactly — disabled once
|
||||||
PIN-reset on host rows.
|
released (a second tap would only 409), live keepsake status with progress and the
|
||||||
|
failure reason, and a **Neu erstellen / Erneut versuchen** rebuild as the recovery
|
||||||
|
path for a failed or stale keepsake.
|
||||||
|
- **Nutzer**: same user list as Host, with the additional Demote action (admin-only,
|
||||||
|
see §9) and PIN-reset on host rows.
|
||||||
|
|
||||||
## 12. Releasing the export and downloading
|
## 12. Releasing the export and downloading
|
||||||
|
|
||||||
@@ -224,11 +238,14 @@ the Host can clean up later).
|
|||||||
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
|
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
|
||||||
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
|
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
|
||||||
browser download.
|
browser download.
|
||||||
7. Downloads are rate-limited per IP (default 3 / day).
|
7. Downloads are rate-limited **per user** (default 3 / day), keyed on the user id in
|
||||||
|
`enforce_export_rate` — deliberately not per IP. At a venue where a hundred guests share
|
||||||
|
one NAT'd wifi, a per-IP budget would be exhausted by the third person to tap
|
||||||
|
**Herunterladen** and lock everyone else out of their own keepsake.
|
||||||
|
|
||||||
## 13. Diashow (planned)
|
## 13. Diashow
|
||||||
|
|
||||||
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the flow:
|
||||||
|
|
||||||
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
|
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
|
||||||
on mobile).
|
on mobile).
|
||||||
@@ -240,7 +257,7 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
|||||||
immediately.
|
immediately.
|
||||||
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
|
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
|
||||||
|
|
||||||
## 14. Picking a data mode (planned)
|
## 14. Picking a data mode
|
||||||
|
|
||||||
1. Guest opens **My Account** → scrolls to **Datennutzung**.
|
1. Guest opens **My Account** → scrolls to **Datennutzung**.
|
||||||
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
|
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
|
||||||
@@ -252,14 +269,24 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
|||||||
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
|
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
|
||||||
bundled `_thumb` / `_full` variants.
|
bundled `_thumb` / `_full` variants.
|
||||||
|
|
||||||
## 15. Leaving an event
|
## 15. Signing out
|
||||||
|
|
||||||
1. User opens **My Account** → taps **🚪 Event verlassen**.
|
The wording matters here: nothing is *left* and nothing is deleted. The account, the PIN
|
||||||
2. Bottom-sheet confirmation: "Event verlassen?" with **Abmelden** and **Bleiben**.
|
and every uploaded photo survive — the user signs back in with name + PIN whenever they
|
||||||
3. Confirming calls `DELETE /api/v1/session` (invalidates the session row), clears the JWT
|
like. ("Event verlassen" read as leaving for good, which the confirmation sheet then
|
||||||
and PIN from `localStorage`, and redirects to the join page.
|
contradicted by saying you can come back.)
|
||||||
|
|
||||||
## 16. Reading the Datenschutzhinweis (planned)
|
1. User opens **My Account** → Konto section. Two separate actions:
|
||||||
|
- **Abmelden** — this device only (`DELETE /api/v1/session`).
|
||||||
|
- **Auf allen Geräten abmelden** — every session of theirs (`DELETE /api/v1/sessions`),
|
||||||
|
for a lost or borrowed phone.
|
||||||
|
2. Bottom-sheet confirmation naming which of the two it is, with **Abmelden** / **Abbrechen**.
|
||||||
|
3. Confirming invalidates the session row(s), wipes the local JWT, PIN and the IndexedDB
|
||||||
|
upload queue (so the next guest on a shared phone doesn't inherit pending uploads), and
|
||||||
|
redirects to the join page. The session-delete call is best-effort: the token is gone
|
||||||
|
locally either way, so a network failure never traps the user on the page.
|
||||||
|
|
||||||
|
## 16. Reading the Datenschutzhinweis
|
||||||
|
|
||||||
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
|
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
|
||||||
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
|
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
|
||||||
@@ -270,11 +297,15 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
|||||||
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
|
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
|
||||||
tall textarea with a save button. Saved to a single `config` key.
|
tall textarea with a save button. Saved to a single `config` key.
|
||||||
|
|
||||||
## 17. Mobile-first gestures (planned)
|
## 17. Mobile-first gestures (partly shipped)
|
||||||
|
|
||||||
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
|
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
|
||||||
ergonomic. Buttons are always present as fallback for desktop and accessibility.
|
ergonomic. Buttons are always present as fallback for desktop and accessibility.
|
||||||
|
|
||||||
|
Shipped today: long-press context sheets on posts and comments, and lightbox navigation
|
||||||
|
(on-screen prev/next controls, ← / → arrow keys, and left/right swipe). The remaining rows
|
||||||
|
below are still planned.
|
||||||
|
|
||||||
| Gesture | Action |
|
| Gesture | Action |
|
||||||
|-------------------------------------------|-------------------------------------------------------|
|
|-------------------------------------------|-------------------------------------------------------|
|
||||||
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
|
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
|
||||||
@@ -294,7 +325,7 @@ Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press f
|
|||||||
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
|
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
|
||||||
land).
|
land).
|
||||||
|
|
||||||
## 18. Admin toggles a rate limit or quota off (planned)
|
## 18. Admin toggles a rate limit or quota off
|
||||||
|
|
||||||
1. Admin opens **Admin Dashboard → Config**.
|
1. Admin opens **Admin Dashboard → Config**.
|
||||||
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
|
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
|
||||||
@@ -305,8 +336,9 @@ land).
|
|||||||
limiter entirely.
|
limiter entirely.
|
||||||
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
|
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
|
||||||
bytes / upload count).
|
bytes / upload count).
|
||||||
6. When the storage-quota toggle is off, the **"X von Y MB genutzt"** widget in the
|
6. When the storage-quota toggle is off, the **"Speicher: X / Y"** widget in My Account and
|
||||||
guest's My Account and upload screen hides itself (no quota → no number to show).
|
on the upload screen hides itself (no quota → no number to show). That widget is
|
||||||
|
staff-only in any case — guests never see it.
|
||||||
|
|
||||||
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
|
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
|
||||||
Toggling off is the explicit escape hatch for testing or trusted internal events.
|
Toggling off is the explicit escape hatch for testing or trusted internal events.
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ export default ts.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['node_modules/', 'playwright-report/', 'test-results/', '*.config.js'],
|
ignores: [
|
||||||
|
'node_modules/',
|
||||||
|
'playwright-report/',
|
||||||
|
'test-results/',
|
||||||
|
'*.config.js',
|
||||||
|
// Standalone dev/load-test scripts, run directly with `node`. They are not part of the
|
||||||
|
// Playwright tsconfig project, so `projectService: true` cannot type them and every one of
|
||||||
|
// them failed with "was not found by the project service" — which meant `npm run lint` had
|
||||||
|
// been exiting non-zero on main, i.e. the e2e lint gate was not gating at all.
|
||||||
|
//
|
||||||
|
// Ignoring is the honest fix rather than widening the tsconfig: these are throwaway harness
|
||||||
|
// scripts, and the type-aware rules that justify the project service (no-floating-promises)
|
||||||
|
// exist to protect TEST code.
|
||||||
|
'*.mjs',
|
||||||
|
'loadtest/',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,52 @@ export const db = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this user's account currently PIN-locked?
|
||||||
|
*
|
||||||
|
* Distinguishes the two ways /recover can answer 429 — the per-(IP, name) throttle, which
|
||||||
|
* costs the attacker, and the account lock, which costs the VICTIM. Only the second one is
|
||||||
|
* weaponizable, so a test asserting "a single IP cannot lock a guest out" has to look at the
|
||||||
|
* row, not at the status code.
|
||||||
|
*/
|
||||||
|
async isPinLocked(userId: string): Promise<boolean> {
|
||||||
|
return withClient(async (c) => {
|
||||||
|
const r = await c.query<{ locked: boolean }>(
|
||||||
|
`SELECT (pin_locked_until IS NOT NULL AND pin_locked_until > NOW()) AS locked
|
||||||
|
FROM "user" WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return r.rows[0]?.locked ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload the wrong-PIN streak, standing in for failures that arrived from other IPs.
|
||||||
|
*
|
||||||
|
* The account lock is deliberately out of reach of any single source, so a test that wants to
|
||||||
|
* exercise it has to simulate the distributed case rather than hammer from one address.
|
||||||
|
* `last_failed_pin_at` is set to now so the 15-minute decay does not immediately reset it.
|
||||||
|
*/
|
||||||
|
async setFailedPinAttempts(userId: string, attempts: number) {
|
||||||
|
await withClient((c) =>
|
||||||
|
c.query(
|
||||||
|
`UPDATE "user" SET failed_pin_attempts = $2, last_failed_pin_at = NOW() WHERE id = $1`,
|
||||||
|
[userId, attempts]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Current wrong-PIN streak. Decays after 15 minutes — see User::increment_failed_pin. */
|
||||||
|
async failedPinAttempts(userId: string): Promise<number> {
|
||||||
|
return withClient(async (c) => {
|
||||||
|
const r = await c.query<{ failed_pin_attempts: number }>(
|
||||||
|
`SELECT failed_pin_attempts FROM "user" WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return r.rows[0]?.failed_pin_attempts ?? 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async expireSession(userId: string) {
|
async expireSession(userId: string) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
||||||
|
|||||||
4
e2e/fixtures/media/not-an-image.jpg
Normal file
4
e2e/fixtures/media/not-an-image.jpg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
This is plain text, not an image at all.
|
||||||
|
This is plain text, not an image at all.
|
||||||
|
This is plain text, not an image at all.
|
||||||
|
This is plain text, not an image at all.
|
||||||
@@ -7,6 +7,7 @@ import { BASE } from './env';
|
|||||||
*
|
*
|
||||||
* Field shape matches [backend/src/handlers/upload.rs]:
|
* Field shape matches [backend/src/handlers/upload.rs]:
|
||||||
* - file (binary; carries filename + content_type in the part headers)
|
* - file (binary; carries filename + content_type in the part headers)
|
||||||
|
* - client_upload_id (uuid, optional; also sent as the X-Client-Upload-Id header)
|
||||||
* - caption (text, optional)
|
* - caption (text, optional)
|
||||||
* - hashtags (CSV text, optional)
|
* - hashtags (CSV text, optional)
|
||||||
*/
|
*/
|
||||||
@@ -17,6 +18,12 @@ export type UploadOptions = {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
hashtags?: string;
|
hashtags?: string;
|
||||||
|
/**
|
||||||
|
* Idempotency key. Sent BOTH as the `X-Client-Upload-Id` header and as the multipart field,
|
||||||
|
* exactly as the real client does — the header is what lets the server replay a stored upload
|
||||||
|
* before it evaluates the lock/release gate, and the field covers the concurrent case.
|
||||||
|
*/
|
||||||
|
clientUploadId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function uploadRaw(
|
export async function uploadRaw(
|
||||||
@@ -29,9 +36,12 @@ export async function uploadRaw(
|
|||||||
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
||||||
if (opts.caption !== undefined) form.append('caption', opts.caption);
|
if (opts.caption !== undefined) form.append('caption', opts.caption);
|
||||||
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
|
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
|
||||||
|
if (opts.clientUploadId !== undefined) form.append('client_upload_id', opts.clientUploadId);
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
||||||
|
if (opts.clientUploadId !== undefined) headers['X-Client-Upload-Id'] = opts.clientUploadId;
|
||||||
return fetch(`${BASE}/api/v1/upload`, {
|
return fetch(`${BASE}/api/v1/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers,
|
||||||
body: form as any,
|
body: form as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ export class AccountPage {
|
|||||||
this.page = page;
|
this.page = page;
|
||||||
this.displayName = page.locator('[data-testid="account-display-name"]');
|
this.displayName = page.locator('[data-testid="account-display-name"]');
|
||||||
this.pinDisplay = page.locator('[data-testid="account-pin"]');
|
this.pinDisplay = page.locator('[data-testid="account-pin"]');
|
||||||
this.leaveButton = page.getByRole('button', { name: /event verlassen/i });
|
// Keyed on testids, not visible copy. The button was renamed "Event verlassen" ->
|
||||||
this.leaveConfirmButton = page.getByRole('button', { name: /^abmelden$/i });
|
// "Abmelden" and these locators silently went stale for a week — the smoke spec that
|
||||||
|
// guards eight of nine UA projects runs through `leaveEvent()` below.
|
||||||
|
this.leaveButton = page.getByTestId('account-logout');
|
||||||
|
this.leaveConfirmButton = page.getByTestId('confirm-sheet-confirm');
|
||||||
this.privacyNote = page.locator('[data-testid="privacy-note"]');
|
this.privacyNote = page.locator('[data-testid="privacy-note"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ test.describe('Auth — join flow', () => {
|
|||||||
expect(storage.pin).toBe(original.pin);
|
expect(storage.pin).toBe(original.pin);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wrong PIN three times locks the account for 15 minutes', async ({ page, guest, db }) => {
|
test('repeated wrong PINs are throttled without locking the guest out', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
const dave = await guest('Dave');
|
const dave = await guest('Dave');
|
||||||
await clearAllStorage(page);
|
await clearAllStorage(page);
|
||||||
|
|
||||||
@@ -85,22 +89,33 @@ test.describe('Auth — join flow', () => {
|
|||||||
await join.submit();
|
await join.submit();
|
||||||
await expect(join.recoveryPinInput).toBeVisible();
|
await expect(join.recoveryPinInput).toBeVisible();
|
||||||
|
|
||||||
// Wrong PIN (real one is dave.pin)
|
// Wrong PIN (real one is dave.pin), four times — one more than the OLD lock threshold of 3.
|
||||||
|
// Typed digit by digit so the 4th character auto-submits (see pin-auto-submit.spec.ts);
|
||||||
|
// clicking as well would double-submit and race the disabled state of the button.
|
||||||
const wrong = dave.pin === '0000' ? '1111' : '0000';
|
const wrong = dave.pin === '0000' ? '1111' : '0000';
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
await join.recoveryPinInput.fill(wrong);
|
await join.recoveryPinInput.fill('');
|
||||||
await join.recoverySubmit.click();
|
await join.recoveryPinInput.pressSequentially(wrong, { delay: 30 });
|
||||||
await expect(join.recoveryError).toBeVisible();
|
await expect(join.recoveryError).toBeVisible();
|
||||||
|
await expect(join.recoverySubmit).toBeEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fourth attempt should hit the 429 lockout (even with the correct PIN now)
|
// THE PROPERTY THIS TEST EXISTS FOR, stated the way a guest experiences it: Dave can still
|
||||||
await join.recoveryPinInput.fill(dave.pin);
|
// get into his own account.
|
||||||
await join.recoverySubmit.click();
|
//
|
||||||
await expect(join.recoveryError).toContainText(/15 Minuten/);
|
// The lock threshold used to be 3, BELOW the per-(IP, name) ceiling — so these very
|
||||||
|
// keystrokes locked Dave out for 15 minutes, and anyone who can read his name off the feed
|
||||||
|
// could do it to him on repeat. Rate limits are disabled in this environment (see
|
||||||
|
// config `rate_limits_enabled`), so what is exercised here is purely the account-lock tier;
|
||||||
|
// the throttle tier is covered in 07-adversarial/auth-tampering.spec.ts.
|
||||||
|
expect(
|
||||||
|
await db.isPinLocked(dave.userId),
|
||||||
|
'four wrong PINs from one device must not lock a guest out of their own account'
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
// Sanity: DB row reflects the lock
|
await join.recoveryPinInput.fill('');
|
||||||
// (The handler sets pin_locked_until directly — verify via API "recover" returning 429)
|
await join.recoveryPinInput.pressSequentially(dave.pin, { delay: 30 });
|
||||||
void db; // unused for now, documenting that db.lockUserPin exists if we want shortcut path
|
await page.waitForURL('**/feed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('"Anderen Namen wählen" returns to the normal join form', async ({ page, guest }) => {
|
test('"Anderen Namen wählen" returns to the normal join form', async ({ page, guest }) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { BASE } from '../../helpers/env';
|
import { BASE } from '../../helpers/env';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
|
||||||
test.describe('Rate limits — guests behind a shared NAT', () => {
|
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 ({
|
test('a dozen guests can all join from one IP, and 429s carry Retry-After', async ({
|
||||||
@@ -100,46 +101,139 @@ test.describe('Rate limits — guests behind a shared NAT', () => {
|
|||||||
expect((await read(b.jwt)).status, 'B must not inherit A’s exhausted bucket').toBe(200);
|
expect((await read(b.jwt)).status, 'B must not inherit A’s exhausted bucket').toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('one guest sweeping /recover cannot lock the venue — or the host — out of PIN recovery', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
|
// The sharpest version of this file's whole premise. `/recover` has a cross-name failure
|
||||||
|
// budget keyed on IP, meant to catch someone sweeping the public name list. Behind the venue
|
||||||
|
// NAT that budget is SHARED BY THE ENTIRE PARTY, and it used to be checked before the account
|
||||||
|
// was even looked up — so it refused a correct PIN.
|
||||||
|
//
|
||||||
|
// That is the host's problem specifically: hosts are promoted guests whose only credential is
|
||||||
|
// a 4-digit PIN, so /recover is their only way back in after losing a session. A guest posting
|
||||||
|
// invented names could deny it to everyone, indefinitely, for the price of ~2 requests/minute.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
recover_rate_enabled: 'true',
|
||||||
|
// Raise the per-IP VOLUME ceiling out of the way. It defaults to 30/min, and the
|
||||||
|
// cross-name FAILURE budget under test is also 30 — so the sweep below would trip the
|
||||||
|
// volume limiter first and this test would pass for the wrong reason (a 429 that proves
|
||||||
|
// nothing about whether a correct PIN survives a spent failure budget).
|
||||||
|
recover_ip_rate_per_min: '500',
|
||||||
|
});
|
||||||
|
|
||||||
|
const victim = await guest('RecoverVictim');
|
||||||
|
|
||||||
|
// Burn the shared per-IP budget with names that do not exist — the cheapest sweep, and the
|
||||||
|
// one that needs no knowledge of the guest list at all.
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
await fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: `Ghost${i}-${Date.now()}`, pin: '0000' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real guest, on that same IP, with their REAL PIN, must still get in.
|
||||||
|
const res = await fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: 'RecoverVictim', pin: victim.pin }),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
res.status,
|
||||||
|
'a correct PIN must survive a spent cross-name budget — otherwise any guest can lock the ' +
|
||||||
|
'host out of the only login path they have'
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
// ...and the sweep is still answered as a sweep: a WRONG pin gets 429, not a bare 401, so the
|
||||||
|
// budget still does its job on the traffic it was built for.
|
||||||
|
const wrong = await fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: 'RecoverVictim', pin: '0001' }),
|
||||||
|
});
|
||||||
|
expect(wrong.status, 'wrong PINs from an exhausted IP are still throttled').toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
test('the export limit is per-user — one guest cannot spend the whole venue’s quota', async ({
|
test('the export limit is per-user — one guest cannot spend the whole venue’s quota', async ({
|
||||||
api,
|
api,
|
||||||
adminToken,
|
adminToken,
|
||||||
guest,
|
guest,
|
||||||
host,
|
host,
|
||||||
db,
|
|
||||||
}) => {
|
}) => {
|
||||||
// The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch
|
// The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch
|
||||||
// their keepsake was locked out until tomorrow.
|
// their keepsake was locked out until tomorrow.
|
||||||
await db.setExportReleased('e2e-test-event', true);
|
//
|
||||||
|
// A REAL release, not `setExportReleased`. `/export/ticket` pre-validates that the archive is
|
||||||
|
// actually servable and answers 404 without charging the limiter — deliberately, so a guest
|
||||||
|
// never spends one of their three daily downloads on an archive that cannot be served. With
|
||||||
|
// only the released FLAG set and no archive on disk, every mint here 404'd and the per-day
|
||||||
|
// limiter under test was never reached at all.
|
||||||
|
await seedUpload(host.jwt, { caption: 'for the keepsake' });
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(204);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const s = await (
|
||||||
|
await fetch(`${BASE}/api/v1/export/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
return s.released === true && s.zip?.status === 'done';
|
||||||
|
},
|
||||||
|
{ timeout: 90_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
await api.patchConfig(adminToken, {
|
await api.patchConfig(adminToken, {
|
||||||
rate_limits_enabled: 'true',
|
rate_limits_enabled: 'true',
|
||||||
export_rate_enabled: 'true',
|
export_rate_enabled: 'true',
|
||||||
export_rate_per_day: '1',
|
export_rate_per_day: '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The per-day export limit is charged at the MINT, not at the download: the ticket endpoint is
|
||||||
|
// the authenticated chokepoint, while `/export/zip` authenticates by ticket alone so a resumed
|
||||||
|
// transfer doesn't spend another of the guest's daily allowance. So a throttled guest is
|
||||||
|
// refused with 429 at `/export/ticket` and never reaches the archive.
|
||||||
|
//
|
||||||
|
// This helper used to destructure `ticket` from that 429 body regardless, then fetch with
|
||||||
|
// `ticket=undefined` — turning the 429 under test into an unrelated 401 from the download
|
||||||
|
// endpoint. Surface the mint's refusal instead; that IS the throttle.
|
||||||
const mintAndFetch = async (jwt: string) => {
|
const mintAndFetch = async (jwt: string) => {
|
||||||
const res = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const minted = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
});
|
});
|
||||||
const { ticket } = await res.json();
|
if (!minted.ok) return minted;
|
||||||
|
const { ticket } = await minted.json();
|
||||||
return fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
return fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const a = await guest('ExportFirst');
|
const a = await guest('ExportFirst');
|
||||||
const b = await guest('ExportSecond');
|
const b = await guest('ExportSecond');
|
||||||
|
|
||||||
// A spends their single daily allowance. The archive itself may not exist (404) —
|
// A spends their single daily allowance — on a real archive, so this is a genuine 200 rather
|
||||||
// what matters is that the limiter admitted the request rather than 429ing it.
|
// than merely "not 429", which would have been satisfied by any error at all.
|
||||||
expect((await mintAndFetch(a.jwt)).status).not.toBe(429);
|
expect((await mintAndFetch(a.jwt)).status, 'A’s first download must succeed').toBe(200);
|
||||||
expect((await mintAndFetch(a.jwt)).status, 'A’s second download is throttled').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.
|
// 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(
|
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by A’s download').toBe(
|
||||||
429
|
200
|
||||||
);
|
);
|
||||||
|
|
||||||
// And the host too, for good measure.
|
// And the host too, for good measure.
|
||||||
expect((await mintAndFetch(host.jwt)).status).not.toBe(429);
|
expect((await mintAndFetch(host.jwt)).status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,5 +296,22 @@ test.describe('Rate limits — /recover name cycling', () => {
|
|||||||
const codes: number[] = [];
|
const codes: number[] = [];
|
||||||
for (let i = 0; i < 7; i++) codes.push((await attempt()).status);
|
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);
|
expect(codes.at(-1), 'guessing one name must still be throttled').toBe(429);
|
||||||
|
|
||||||
|
// AND THE OTHER HALF, which is the whole reason this bucket counts failures instead of
|
||||||
|
// requests: the victim's own CORRECT PIN must still let them in, from the same IP, while that
|
||||||
|
// budget is spent. Behind the venue's NAT the "attacker" and the victim are the same address,
|
||||||
|
// so a bucket that refused before verifying handed any guest a fifteen-minute lockout of any
|
||||||
|
// named person — the host included, whose only credential is a 4-digit PIN and whose only way
|
||||||
|
// back after losing a session is this endpoint. Four wrong guesses did it, and four more every
|
||||||
|
// fifteen minutes sustained it indefinitely.
|
||||||
|
const rightful = await fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: victim.displayName, pin: victim.pin }),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
rightful.status,
|
||||||
|
'a correct PIN must authenticate even when this IP has spent the name budget'
|
||||||
|
).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -199,9 +199,12 @@ test.describe('Upload — client queue under a burst', () => {
|
|||||||
// a closed tab / killed PWA. The remaining pending items live only in
|
// a closed tab / killed PWA. The remaining pending items live only in
|
||||||
// IndexedDB now.
|
// IndexedDB now.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
// The queue only resumes where loadQueue() runs — the /upload route's
|
// Deliberately NOT navigating to /upload. Rehydration is now module-level and
|
||||||
// onMount. Navigating there is the "reopen the composer" recovery path.
|
// auth-gated (upload-queue.ts `hydrateQueue`), so the queue resumes wherever the
|
||||||
await page.goto('/upload');
|
// reload lands. This assertion is the regression guard for the defect it replaced:
|
||||||
|
// `loadQueue()` used to have a single call site in the whole app — the /upload
|
||||||
|
// route's onMount — so a guest who reloaded anywhere else saw a 0 badge and their
|
||||||
|
// staged photos never left the phone, having already been shown a success.
|
||||||
|
|
||||||
// (4) RESUME: every file ends up server-side without re-staging anything.
|
// (4) RESUME: every file ends up server-side without re-staging anything.
|
||||||
// `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload
|
// `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
await api.patchConfig(adminToken, {
|
await api.patchConfig(adminToken, {
|
||||||
quota_enabled: 'true',
|
quota_enabled: 'true',
|
||||||
storage_quota_enabled: 'true',
|
storage_quota_enabled: 'true',
|
||||||
|
// The per-user ceiling divides the disk budget by
|
||||||
|
// `max(active_uploaders, estimated_guest_count, 1)` — the operator's expected headcount is a
|
||||||
|
// FLOOR on the divisor, so the ceiling settles early instead of sliding down all evening as
|
||||||
|
// guests arrive. It is seeded at 100, and `setLimitTo` below solves for a target using the
|
||||||
|
// OBSERVED uploader count, so every limit it aimed for came out 100x too small and every
|
||||||
|
// "within the quota" upload 413'd. Pin it to 1 so the divisor is the count the helper
|
||||||
|
// actually controls; the floor itself is exercised by the Rust unit tests.
|
||||||
|
estimated_guest_count: '1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,23 @@ test.describe('Upload — a rejected upload is surfaced', () => {
|
|||||||
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
|
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
// Both the status chip ("Gesperrt") and the server's reason ("Du bist gesperrt.") must
|
// The server's reason ("Du bist gesperrt.") must render — it had no UI at all before.
|
||||||
// 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();
|
await expect(page.getByText('Du bist gesperrt.')).toBeVisible();
|
||||||
|
// The chip reads "Fehler", NOT "Gesperrt", and that is the fix rather than a regression.
|
||||||
|
// A ban used to come back as a generic `forbidden`, which purged the blob and moved the row
|
||||||
|
// to `blocked` — a terminal state with no retry button. So an unban restored everything
|
||||||
|
// except the photo that was actually in flight, which is the one the guest cares about.
|
||||||
|
// It is now a distinct `user_banned` code that PARKS the row (status `error`, blob kept,
|
||||||
|
// `parkedFor: 'unban'`) and resumes it when `user-shown` arrives.
|
||||||
|
await expect(page.getByText('Gesperrt', { exact: true })).toHaveCount(0);
|
||||||
|
// Positively, not just negatively: a chip that rendered empty would satisfy the line above.
|
||||||
|
await expect(page.getByText('Fehler', { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
// 3. The badge must not read as success. It counted only pending/uploading before, so a
|
// 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.
|
// rejected item dropped it to 0 — indistinguishable from a completed upload.
|
||||||
|
// The row is now parked (`error` + `parkedFor: 'unban'`) rather than terminally `blocked`,
|
||||||
|
// so it is the parked count that must be exactly one — and critically the blob must still
|
||||||
|
// be there, since that is what an unban replays.
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() =>
|
() =>
|
||||||
@@ -74,7 +84,10 @@ test.describe('Upload — a rejected upload is surfaced', () => {
|
|||||||
const all = tx.objectStore('queue').getAll();
|
const all = tx.objectStore('queue').getAll();
|
||||||
all.onsuccess = () =>
|
all.onsuccess = () =>
|
||||||
resolve(
|
resolve(
|
||||||
all.result.filter((r: { status: string }) => r.status === 'blocked').length
|
all.result.filter(
|
||||||
|
(r: { status: string; parkedFor?: string; blob?: Blob }) =>
|
||||||
|
r.status === 'error' && r.parkedFor === 'unban' && !!r.blob
|
||||||
|
).length
|
||||||
);
|
);
|
||||||
all.onerror = () => reject(all.error);
|
all.onerror = () => reject(all.error);
|
||||||
};
|
};
|
||||||
|
|||||||
95
e2e/specs/02-upload/retry-after-release.spec.ts
Normal file
95
e2e/specs/02-upload/retry-after-release.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* A retry of a photo that was ALREADY STORED must return that photo — even after the gallery has
|
||||||
|
* been released.
|
||||||
|
*
|
||||||
|
* The idempotency key arrives as a multipart field as well, and there is a replay for it in the
|
||||||
|
* handler; but a field cannot be read until the body is being parsed, which happens after the
|
||||||
|
* lock/release pre-flight. So the replay was unreachable in precisely the case that matters:
|
||||||
|
*
|
||||||
|
* the photo commits → the response is lost on the way back (the flaky-wifi failure the key
|
||||||
|
* exists for) → the host releases the gallery at the end of the night → the phone retries →
|
||||||
|
* `gallery_released`.
|
||||||
|
*
|
||||||
|
* The guest is then told a photo that is sitting in the gallery was never sent, and the remedy the
|
||||||
|
* client offers — ask the hosts to reopen — bumps `export_epoch`, retiring the whole keepsake and
|
||||||
|
* forcing a rebuild, to re-send something that was never missing.
|
||||||
|
*
|
||||||
|
* The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
const SLUG = 'e2e-test-event';
|
||||||
|
|
||||||
|
function sample(): Buffer {
|
||||||
|
return readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Upload — a retry after release replays instead of refusing', () => {
|
||||||
|
test('the stored photo comes back, and the guest is not told to reopen the gallery', async ({
|
||||||
|
guest,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
const g = await guest('WiederholerWilli');
|
||||||
|
const key = crypto.randomUUID();
|
||||||
|
|
||||||
|
// 1. The upload commits. In the real failure the guest never sees this response.
|
||||||
|
const first = await uploadRaw(g.jwt, sample(), {
|
||||||
|
filename: 'a.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
clientUploadId: key,
|
||||||
|
});
|
||||||
|
expect(first.status).toBe(201);
|
||||||
|
const original = await first.json();
|
||||||
|
|
||||||
|
// 2. The host releases the gallery — the end-of-event action every queue runs into.
|
||||||
|
await db.setExportReleased(SLUG, true);
|
||||||
|
|
||||||
|
// A DIFFERENT photo must still be refused: this is the control that proves the release is
|
||||||
|
// actually in effect, so the replay below is not just "the gate was open all along".
|
||||||
|
const stranger = await uploadRaw(g.jwt, sample(), {
|
||||||
|
filename: 'b.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
clientUploadId: crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
expect(stranger.status, 'a genuinely new upload must still be refused after release').toBe(403);
|
||||||
|
expect((await stranger.json()).error).toBe('gallery_released');
|
||||||
|
|
||||||
|
// 3. The phone retries the FIRST photo. It is already in the gallery, so the honest answer is
|
||||||
|
// the stored row — not "the gallery is closed".
|
||||||
|
const retry = await uploadRaw(g.jwt, sample(), {
|
||||||
|
filename: 'a.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
clientUploadId: key,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
retry.status,
|
||||||
|
'a retry of an already-stored photo must be replayed, not refused with gallery_released'
|
||||||
|
).toBe(200);
|
||||||
|
const replayed = await retry.json();
|
||||||
|
expect(replayed.id, 'the replay must return the ORIGINAL upload, not a new one').toBe(
|
||||||
|
original.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. And no second row was created — the whole point of the key.
|
||||||
|
//
|
||||||
|
// Counted by UPLOADER, not by `id`. Filtering on `u.id === original.id` looks like a duplicate
|
||||||
|
// check and is not one: a duplicate row gets a fresh uuid, so it could never match, and the
|
||||||
|
// filter yields exactly 1 whether the gallery holds one copy or five. This guest uploaded once
|
||||||
|
// successfully ('a.jpg'); 'b.jpg' was refused at step 2 and the retry must have replayed rather
|
||||||
|
// than stored, so their total must be exactly one.
|
||||||
|
const feed = await fetch(`${BASE}/api/v1/feed?limit=100`, {
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
});
|
||||||
|
const items: any[] = (await feed.json()).uploads ?? [];
|
||||||
|
const mine = items.filter((u) => u.user_id === g.userId);
|
||||||
|
expect(
|
||||||
|
mine.length,
|
||||||
|
`the retry must not have stored a second copy; got ${mine.map((u) => u.id).join(', ')}`
|
||||||
|
).toBe(1);
|
||||||
|
expect(mine[0].id, 'and the one that exists is the original').toBe(original.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,31 +108,25 @@ test.describe('Host — moderation from the UI', () => {
|
|||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a host can remove the comment of a guest they have already banned', async ({
|
test('a host removes a guest comment through the lightbox, via the confirm sheet', async ({
|
||||||
page,
|
page,
|
||||||
api,
|
|
||||||
host,
|
host,
|
||||||
guest,
|
guest,
|
||||||
signIn,
|
signIn,
|
||||||
}) => {
|
}) => {
|
||||||
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
|
// The UI leg of host comment moderation, and the ONLY test that clicks it. The affordance is
|
||||||
// on the author gets 403 on their own delete, so if the host has no removal affordance
|
// rendered solely by LightboxModal (`$isStaff` gates the trash button, and it routes through a
|
||||||
// the comment is stuck on screen forever.
|
// ConfirmSheet rather than deleting on first tap). Without this, `pendingCommentDelete` could
|
||||||
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
|
// stop being wired to the sheet's onConfirm, or the staff gate could invert, and every
|
||||||
// uploads, so if the comment sat on their own photo the whole card would vanish and
|
// remaining comment-moderation test would still pass — they all call the API directly.
|
||||||
// there would be nothing left to moderate.
|
//
|
||||||
const victim = await guest('PhotoOwner');
|
// The author is NOT banned here, deliberately. A ban hides the comment from every reader
|
||||||
|
// including the host (see the next test), so a banned author's comment is unreachable in the
|
||||||
|
// UI by construction and cannot exercise this path.
|
||||||
|
const victim = await guest('LightboxPhotoOwner');
|
||||||
const uploadId = await seedUpload(victim.jwt);
|
const uploadId = await seedUpload(victim.jwt);
|
||||||
const author = await guest('CommentOffender');
|
const author = await guest('LightboxCommenter');
|
||||||
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
|
await seedComment(author.jwt, uploadId, 'bitte entfernen');
|
||||||
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 signIn(page, host);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -141,9 +135,88 @@ test.describe('Host — moderation from the UI', () => {
|
|||||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||||
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||||
|
|
||||||
const comment = page.getByText('unangebrachter Kommentar');
|
const comment = page.getByText('bitte entfernen');
|
||||||
await expect(comment).toBeVisible({ timeout: 10_000 });
|
await expect(comment).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// "entfernen" (host removing someone else's) rather than "löschen" (deleting your own) —
|
||||||
|
// the aria-label distinguishes them and the host must get the host one.
|
||||||
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
|
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
|
||||||
|
// It must NOT delete on first tap; the comment is still there behind the sheet.
|
||||||
|
await expect(comment).toBeVisible();
|
||||||
|
await page.getByTestId('confirm-sheet-confirm').click();
|
||||||
await expect(comment).toHaveCount(0, { timeout: 10_000 });
|
await expect(comment).toHaveCount(0, { timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('banning hides a comment for everyone, and the host can still delete it permanently', async ({
|
||||||
|
page,
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
|
// This used to assert that the host could remove a banned author's comment FROM THE FEED,
|
||||||
|
// on the premise that a ban leaves the comment "stuck on screen forever". That premise no
|
||||||
|
// longer holds: `Comment::list_for_upload` filters `NOT u.is_banned`, so a ban hides the
|
||||||
|
// comment from every reader — host included — which is why there was nothing on screen to
|
||||||
|
// click. The export and hashtag-count queries already filtered banned authors, so this
|
||||||
|
// brought the live read path in line with them.
|
||||||
|
//
|
||||||
|
// But hiding is derived AT READ TIME, and a ban is reversible. Unbanning a guest — because
|
||||||
|
// the host was hasty, or the guest apologised — would republish the abusive comment. So the
|
||||||
|
// property worth pinning is the pair: the ban hides it immediately, and the host's permanent
|
||||||
|
// removal outlives the ban.
|
||||||
|
// 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 with it.
|
||||||
|
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');
|
||||||
|
|
||||||
|
const listFor = async (jwt: string) =>
|
||||||
|
(await (
|
||||||
|
await fetch(`${BASE}/api/v1/upload/${uploadId}/comments`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
})
|
||||||
|
).json()) as Array<{ id: string }>;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await listFor(host.jwt)).map((c) => c.id),
|
||||||
|
'before the ban the comment is live'
|
||||||
|
).toContain(commentId);
|
||||||
|
|
||||||
|
await api.banUser(host.jwt, author.userId);
|
||||||
|
|
||||||
|
// The author cannot retract it themselves — so removal has to be the host's to make.
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Gone for the host and the photo's owner alike, with no further action.
|
||||||
|
expect((await listFor(host.jwt)).map((c) => c.id)).not.toContain(commentId);
|
||||||
|
expect((await listFor(victim.jwt)).map((c) => c.id)).not.toContain(commentId);
|
||||||
|
|
||||||
|
// ...and gone from the rendered feed, which is what the host actually looks at.
|
||||||
|
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();
|
||||||
|
await expect(page.getByText('unangebrachter Kommentar')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The permanent removal the host still needs: soft-delete survives an unban, so letting the
|
||||||
|
// guest back in does not republish what they were banned for.
|
||||||
|
const removed = await fetch(`${BASE}/api/v1/host/comment/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
});
|
||||||
|
expect(removed.status, 'the host can delete a banned author’s comment outright').toBe(204);
|
||||||
|
|
||||||
|
await api.unbanUser(host.jwt, author.userId);
|
||||||
|
expect(
|
||||||
|
(await listFor(host.jwt)).map((c) => c.id),
|
||||||
|
'an unban must not resurrect a comment the host deleted'
|
||||||
|
).not.toContain(commentId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ test.describe('Role — follows the identity across a same-tab switch', () => {
|
|||||||
await expect(page.getByRole('button', { name: REMOVE })).toBeVisible();
|
await expect(page.getByRole('button', { name: REMOVE })).toBeVisible();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// 2. Host leaves, in-app — no reload. This is the path "Event verlassen" takes.
|
// 2. Host leaves, in-app — no reload. This is the path the "Abmelden" button takes.
|
||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
await page.getByRole('button', { name: /event verlassen/i }).click();
|
await page.getByTestId('account-logout').click();
|
||||||
const confirm = page.getByTestId('confirm-sheet-confirm');
|
const confirm = page.getByTestId('confirm-sheet-confirm');
|
||||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||||
await page.waitForURL('**/join', { timeout: 10_000 });
|
await page.waitForURL('**/join', { timeout: 10_000 });
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test.describe('Export — the archives extract to readable files', () => {
|
|||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
for (const kind of ['zip', 'html'] as const) {
|
for (const kind of ['zip', 'html'] as const) {
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=${kind}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer,
|
headers: bearer,
|
||||||
});
|
});
|
||||||
|
|||||||
85
e2e/specs/06-export/download-resume-validator.spec.ts
Normal file
85
e2e/specs/06-export/download-resume-validator.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* A resumed keepsake download must never splice two different archives together.
|
||||||
|
*
|
||||||
|
* The download endpoint re-resolves `export_current` on EVERY request, and a download ticket
|
||||||
|
* outlives several redemptions. So the dangerous sequence was:
|
||||||
|
*
|
||||||
|
* guest's 500 MB download drops at 500 MB
|
||||||
|
* → host takes a photo down (epoch bumps, rebuild lands, old generation pruned)
|
||||||
|
* → client resumes with `Range: bytes=500000000-`
|
||||||
|
* → server seeks 500 MB into a DIFFERENT file of a different length and streams it
|
||||||
|
* → the client concatenates the two halves into a structurally corrupt ZIP
|
||||||
|
*
|
||||||
|
* Nothing anywhere logged an error. The archive is the one artifact the whole event exists to
|
||||||
|
* produce, so a partial is now served only against a matching `If-Range` validator.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
test.describe('Export — a resume cannot splice two archives', () => {
|
||||||
|
test('partial content requires a matching If-Range; a blind Range restarts instead', async ({
|
||||||
|
host,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||||
|
|
||||||
|
await seedUpload(host.jwt, { caption: 'resumable' });
|
||||||
|
|
||||||
|
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()).zip?.status;
|
||||||
|
},
|
||||||
|
{ timeout: 45_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe('done');
|
||||||
|
|
||||||
|
const mint = async () => {
|
||||||
|
const r = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
|
return (await r.json()).ticket as string;
|
||||||
|
};
|
||||||
|
const url = async () => `${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(await mint())}`;
|
||||||
|
|
||||||
|
// 1. The full download advertises a validator. Without one a browser will not even attempt a
|
||||||
|
// resume, so this header is what makes the feature work at all — and it is what the
|
||||||
|
// partial below is checked against.
|
||||||
|
const full = await fetch(await url());
|
||||||
|
expect(full.status).toBe(200);
|
||||||
|
const etag = full.headers.get('etag');
|
||||||
|
expect(etag, 'the archive must carry an ETag or no client can resume safely').toBeTruthy();
|
||||||
|
expect(full.headers.get('accept-ranges')).toBe('bytes');
|
||||||
|
|
||||||
|
// 2. A resume that PROVES continuity gets its partial.
|
||||||
|
const resumed = await fetch(await url(), {
|
||||||
|
headers: { Range: 'bytes=0-99', 'If-Range': etag! },
|
||||||
|
});
|
||||||
|
expect(resumed.status, 'a matching If-Range must still get 206').toBe(206);
|
||||||
|
expect(resumed.headers.get('content-range')).toMatch(/^bytes 0-99\/\d+$/);
|
||||||
|
|
||||||
|
// 3. A resume that cannot prove it — `curl -C -`, `wget -c`, the Android download manager —
|
||||||
|
// gets the whole file instead of a silently spliced one. Restarting a download is a cost;
|
||||||
|
// a corrupt keepsake is not recoverable.
|
||||||
|
const blind = await fetch(await url(), { headers: { Range: 'bytes=0-99' } });
|
||||||
|
expect(blind.status, 'a Range with no If-Range must NOT be served as a partial').toBe(200);
|
||||||
|
expect(blind.headers.get('content-range')).toBeNull();
|
||||||
|
|
||||||
|
// 4. And a stale validator — the exact case that used to splice — is refused a partial too.
|
||||||
|
const stale = await fetch(await url(), {
|
||||||
|
headers: { Range: 'bytes=0-99', 'If-Range': '"Gallery.some-other-event.99.zip-123"' },
|
||||||
|
});
|
||||||
|
expect(stale.status, 'an If-Range from a different generation must not get a partial').toBe(
|
||||||
|
200
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
e2e/specs/06-export/download-retired-epoch.spec.ts
Normal file
73
e2e/specs/06-export/download-retired-epoch.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* A ticket minted against a LIVE archive must stop working the moment that archive is retired.
|
||||||
|
*
|
||||||
|
* This is the download-side half of the stale-keepsake guarantee, and it was left unasserted. Both
|
||||||
|
* tests in `export.spec.ts` are named "ZIP download 404s…" but now assert only that the *mint*
|
||||||
|
* refuses — correctly, since `/export/ticket` pre-validates and refusing there spends none of the
|
||||||
|
* guest's three daily downloads. The consequence is that nothing exercised `resolve_export_file`
|
||||||
|
* on the download path itself, so removing that check would not have turned anything red.
|
||||||
|
*
|
||||||
|
* It cannot be tested by minting against an already-dead archive (the mint refuses first), so the
|
||||||
|
* order has to be: real release → mint while healthy → retire → download. Which is also exactly
|
||||||
|
* what a host taking a photo down mid-download does to a ticket already in flight.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
const SLUG = 'e2e-test-event';
|
||||||
|
|
||||||
|
test.describe('Export — a retired generation cannot be downloaded', () => {
|
||||||
|
test('a ticket minted before the epoch moved is refused at the download, not served stale', async ({
|
||||||
|
host,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||||
|
|
||||||
|
await seedUpload(host.jwt, { caption: 'about to go stale' });
|
||||||
|
|
||||||
|
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()).zip?.status;
|
||||||
|
},
|
||||||
|
{ timeout: 45_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe('done');
|
||||||
|
|
||||||
|
// Mint while everything is healthy. This must succeed, or the assertion below proves nothing —
|
||||||
|
// a 404 on a ticket that was never valid would be green for the wrong reason.
|
||||||
|
const mint = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
|
expect(mint.status, 'the ticket must be mintable while the archive is live').toBe(200);
|
||||||
|
const { ticket } = await mint.json();
|
||||||
|
expect(ticket).toBeTruthy();
|
||||||
|
|
||||||
|
// And it genuinely works right now — the positive control for the negative below.
|
||||||
|
const before = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
expect(before.status, 'the ticket must serve the archive while it is current').toBe(200);
|
||||||
|
|
||||||
|
// Now retire the generation, which is what a reopen or a post-release takedown does.
|
||||||
|
await db.setExportZipReady(SLUG, false);
|
||||||
|
|
||||||
|
// The SAME ticket — still unexpired, still under its redemption cap, session still valid —
|
||||||
|
// must now be refused. Readiness is derived at READ time from `job.epoch = event.export_epoch`,
|
||||||
|
// so this is the check that stops a superseded archive being served to a guest who happened to
|
||||||
|
// hold a ticket when the host moderated.
|
||||||
|
const after = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||||
|
expect(
|
||||||
|
after.status,
|
||||||
|
'a retired archive must 404 on the download path, not be served from a still-valid ticket'
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,7 +80,7 @@ test.describe('Export — EXIF orientation in the keepsake', () => {
|
|||||||
)
|
)
|
||||||
.toBe('done');
|
.toBe('done');
|
||||||
|
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer,
|
headers: bearer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ test.describe('Export — no public leak (CR2)', () => {
|
|||||||
|
|
||||||
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
||||||
// 404 above means "not public", not merely "no file was produced".
|
// 404 above means "not public", not merely "no file was produced".
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer,
|
headers: bearer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ test.describe('Export — video streaming (P4)', () => {
|
|||||||
.toBe('done');
|
.toBe('done');
|
||||||
|
|
||||||
// Download Memories.zip via the gated single-use ticket.
|
// Download Memories.zip via the gated single-use ticket.
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer,
|
headers: bearer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,12 +88,14 @@ test.describe('Export — release and download', () => {
|
|||||||
|
|
||||||
// Browser downloads stream to disk via a top-level navigation, so the download
|
// Browser downloads stream to disk via a top-level navigation, so the download
|
||||||
// endpoint authenticates with a single-use ticket (no Bearer header).
|
// endpoint authenticates with a single-use ticket (no Bearer header).
|
||||||
async function mintTicket(jwt: string): Promise<string> {
|
/** The raw mint response — `/export/ticket` pre-validates that the archive is actually
|
||||||
const res = await fetch(base + '/api/v1/export/ticket', {
|
* servable, so an unavailable keepsake is refused HERE rather than after charging one of the
|
||||||
|
* guest's three daily downloads. */
|
||||||
|
async function mintTicketResponse(jwt: string, kind: 'zip' | 'html' = 'zip') {
|
||||||
|
return fetch(base + `/api/v1/export/ticket?kind=${kind}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
});
|
});
|
||||||
return (await res.json()).ticket;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('ZIP download 404s for a `done` job at a RETIRED epoch', async ({ guest, db }) => {
|
test('ZIP download 404s for a `done` job at a RETIRED epoch', async ({ guest, db }) => {
|
||||||
@@ -105,10 +107,12 @@ test.describe('Export — release and download', () => {
|
|||||||
await db.setExportReleased(SLUG, true);
|
await db.setExportReleased(SLUG, true);
|
||||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||||
await db.setExportZipReady(SLUG, false); // retire the job to a dead epoch
|
await db.setExportZipReady(SLUG, false); // retire the job to a dead epoch
|
||||||
const ticket = await mintTicket(g.jwt);
|
|
||||||
|
|
||||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
// Refused at the MINT. This used to be asserted one step later, on the download, because the
|
||||||
expect(res.status).toBe(404);
|
// spec did not send `kind` and so skipped the pre-check entirely — now that a ticket is bound
|
||||||
|
// to an archive the kind is always known, and the guest is told the truth before a daily
|
||||||
|
// download is spent on an archive that cannot be served.
|
||||||
|
expect((await mintTicketResponse(g.jwt)).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ZIP download 404s when the job is current but the file is missing on disk', async ({
|
test('ZIP download 404s when the job is current but the file is missing on disk', async ({
|
||||||
@@ -122,9 +126,9 @@ test.describe('Export — release and download', () => {
|
|||||||
await db.setExportReleased(SLUG, true);
|
await db.setExportReleased(SLUG, true);
|
||||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||||
await db.setExportZipReady(SLUG, true);
|
await db.setExportZipReady(SLUG, true);
|
||||||
const ticket = await mintTicket(g.jwt);
|
|
||||||
|
|
||||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
// Same as above: the pre-check resolves the file on disk, so a `done` job whose archive is
|
||||||
expect(res.status).toBe(404);
|
// missing is refused at the mint rather than 404ing mid-download.
|
||||||
|
expect((await mintTicketResponse(g.jwt)).status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
99
e2e/specs/06-export/ticket-refused-mint.spec.ts
Normal file
99
e2e/specs/06-export/ticket-refused-mint.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* A mint that is REFUSED must not cost the guest the download they already have running.
|
||||||
|
*
|
||||||
|
* `/export/ticket` issues the ticket before charging the daily limit, deliberately: charging first
|
||||||
|
* meant a store-capacity 503 — a server-side condition the guest cannot see or cause — still spent
|
||||||
|
* one of their three daily downloads, with no refund path.
|
||||||
|
*
|
||||||
|
* But the ticket a refused mint created was left in the store, and it occupies a slot there for six
|
||||||
|
* hours, because a download ticket is long-lived so a multi-GB transfer can resume with `Range`.
|
||||||
|
* The per-session cap is four tickets of the same kind, so:
|
||||||
|
*
|
||||||
|
* the 1.4 GB transfer starts on ticket A → the progress bar looks stuck on venue wifi → the guest
|
||||||
|
* taps "Herunterladen" again → mints 2 and 3 succeed, 4 and 5 are refused with 429 but STILL mint
|
||||||
|
* → the fifth evicts the oldest download ticket for the session, which is A
|
||||||
|
* → the transfer drops, resumes with `Range`, and 401s
|
||||||
|
* → re-minting is impossible: they are at the daily limit
|
||||||
|
*
|
||||||
|
* The keepsake is then unreachable until the next day, for tapping a button that appeared to do
|
||||||
|
* nothing. A refused mint now discards its own ticket.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
|
test.describe('Export — a refused mint does not evict a running download', () => {
|
||||||
|
test('the first ticket still works after the daily limit has refused later mints', async ({
|
||||||
|
host,
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||||
|
|
||||||
|
await seedUpload(host.jwt, { caption: 'keepsake' });
|
||||||
|
|
||||||
|
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()).zip?.status;
|
||||||
|
},
|
||||||
|
{ timeout: 45_000, intervals: [500] }
|
||||||
|
)
|
||||||
|
.toBe('done');
|
||||||
|
|
||||||
|
// The e2e reseed forces every limiter OFF, so without this the daily limit never bites and the
|
||||||
|
// whole test passes vacuously. `export_rate_per_day` is seeded at 3 (migration 005).
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
export_rate_enabled: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mint = async () =>
|
||||||
|
fetch(`${BASE}/api/v1/export/ticket?kind=zip`, { method: 'POST', headers: bearer });
|
||||||
|
|
||||||
|
// 1. The ticket the "running" transfer holds.
|
||||||
|
const first = await mint();
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
const ticketA = (await first.json()).ticket as string;
|
||||||
|
expect(ticketA).toBeTruthy();
|
||||||
|
|
||||||
|
// 2. Two more legitimate mints, exhausting the day's three.
|
||||||
|
for (const i of [2, 3]) {
|
||||||
|
const r = await mint();
|
||||||
|
expect(r.status, `mint ${i} is still within the daily allowance`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Two refused mints — the impatient taps. This is the CONTROL: if these came back 200 the
|
||||||
|
// limit was not in force and step 4 would prove nothing.
|
||||||
|
for (const i of [4, 5]) {
|
||||||
|
const r = await mint();
|
||||||
|
expect(r.status, `mint ${i} must be refused — the daily limit is spent`).toBe(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The running transfer resumes. Its ticket must have survived the refused mints: it is the
|
||||||
|
// guest's only remaining way to reach the keepsake today.
|
||||||
|
const resumed = await fetch(
|
||||||
|
`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticketA)}`
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resumed.status,
|
||||||
|
'a refused mint must not evict the ticket an in-flight download is holding'
|
||||||
|
).toBe(200);
|
||||||
|
await resumed.arrayBuffer();
|
||||||
|
} finally {
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'false',
|
||||||
|
export_rate_enabled: 'false',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,7 +69,7 @@ test.describe('Export — a caption cannot brick the keepsake viewer', () => {
|
|||||||
)
|
)
|
||||||
.toBe('done');
|
.toBe('done');
|
||||||
|
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer,
|
headers: bearer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ test.describe('Export — the keepsake has no broken tiles', () => {
|
|||||||
)
|
)
|
||||||
.toBe('done');
|
.toBe('done');
|
||||||
|
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer,
|
headers: bearer,
|
||||||
});
|
});
|
||||||
@@ -98,8 +98,18 @@ test.describe('Export — the keepsake has no broken tiles', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// THE assertion: nothing rendered broken. Give the images a moment to settle first.
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// POSITIVE anchor FIRST, and it is load-bearing. The "nothing is broken" assertion below
|
||||||
|
// filters `img` elements, so a viewer that rendered NOTHING AT ALL yields `[]` and passes —
|
||||||
|
// this file, whose whole subject is that the images resolve, was the one spec that would
|
||||||
|
// have stayed green through a total viewer regression. Everything else it checks
|
||||||
|
// (`__EXPORT_DATA__`, the archive entries) comes from the backend and the classic head
|
||||||
|
// script, neither of which needs the viewer bundle to have run at all.
|
||||||
|
const rendered = await page.locator('img').count();
|
||||||
|
expect(rendered, 'the keepsake viewer rendered no images at all').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// THE assertion: nothing rendered broken.
|
||||||
const broken = await page.evaluate(() =>
|
const broken = await page.evaluate(() =>
|
||||||
Array.from(document.querySelectorAll('img'))
|
Array.from(document.querySelectorAll('img'))
|
||||||
.filter((i) => i.complete && i.naturalWidth === 0)
|
.filter((i) => i.complete && i.naturalWidth === 0)
|
||||||
|
|||||||
@@ -89,15 +89,43 @@ test.describe('Adversarial — JWT', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Adversarial — PIN brute-force', () => {
|
test.describe('Adversarial — PIN brute-force', () => {
|
||||||
test('sequential wrong-PIN attempts lock the account after 3 attempts', async ({ guest }) => {
|
/**
|
||||||
|
* The PIN defence has two tiers, and telling them apart is the whole point of these tests:
|
||||||
|
*
|
||||||
|
* - the per-(IP, name) throttle, which refuses the ATTACKER; and
|
||||||
|
* - the account lock, which refuses the VICTIM — the only tier that can be weaponised.
|
||||||
|
*
|
||||||
|
* Both answer 429, so the status code alone proves nothing. The regression these guard is that
|
||||||
|
* the lock threshold used to sit BELOW the throttle ceiling (3 vs 5), so three requests from a
|
||||||
|
* single IP locked any guest whose display name is readable off the feed, every 15 minutes,
|
||||||
|
* indefinitely. The tier meant to protect a guest was the cheapest way to attack them.
|
||||||
|
*/
|
||||||
|
// Restore the default. The first test turns the limiter on for the whole instance, and leaving
|
||||||
|
// it on would throttle unrelated specs sharing this stack. Runs even on failure.
|
||||||
|
test.afterEach(async ({ api, adminToken }) => {
|
||||||
|
await api.patchConfig(adminToken, { rate_limits_enabled: 'false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single IP is throttled without ever locking the victim out', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
// Rate limits are off by default in this environment, and the throttle IS the tier under
|
||||||
|
// test — without it the run would silently assert only half the property.
|
||||||
|
await api.patchConfig(adminToken, {
|
||||||
|
rate_limits_enabled: 'true',
|
||||||
|
recover_rate_enabled: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
const g = await guest('Brute');
|
const g = await guest('Brute');
|
||||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||||
|
|
||||||
// Do them serially so the failed_pin_attempts counter increments
|
// Serially, so the failed-PIN counter increments monotonically. Well past the per-(IP, name)
|
||||||
// monotonically. Parallel attempts race and may never accumulate to 3 in
|
// ceiling of 4, and past the OLD lock threshold of 3.
|
||||||
// the current handler implementation — that's a separate finding.
|
|
||||||
const statuses: number[] = [];
|
const statuses: number[] = [];
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const r = await fetch(`${BASE}/api/v1/recover`, {
|
const r = await fetch(`${BASE}/api/v1/recover`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -105,26 +133,56 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
});
|
});
|
||||||
statuses.push(r.status);
|
statuses.push(r.status);
|
||||||
}
|
}
|
||||||
// First three are 401, fourth (or later) is 429.
|
expect(
|
||||||
expect(statuses.filter((s) => s === 200)).toHaveLength(0);
|
statuses.filter((s) => s === 200),
|
||||||
expect(statuses.some((s) => s === 429)).toBe(true);
|
'a wrong PIN must never authenticate'
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
statuses.some((s) => s === 429),
|
||||||
|
'the attacker must be throttled'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
// Now even the correct PIN fails until lockout expires.
|
expect(
|
||||||
|
await db.isPinLocked(g.userId),
|
||||||
|
'one IP must not be able to lock a guest out of their own account'
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the account still locks once the failure count is reached', async ({ guest, db }) => {
|
||||||
|
const g = await guest('BruteDistributed');
|
||||||
|
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||||
|
|
||||||
|
// The per-IP throttle is what a single source hits first, so drive the counter the way a
|
||||||
|
// DISTRIBUTED attacker would — the tier this test covers is the last line against exactly
|
||||||
|
// that, and it must not have been removed while fixing the weaponisation above.
|
||||||
|
await db.setFailedPinAttempts(g.userId, 11);
|
||||||
|
|
||||||
|
const r = await fetch(`${BASE}/api/v1/recover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.77' },
|
||||||
|
body: JSON.stringify({ display_name: g.displayName, pin: wrong }),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(401);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.isPinLocked(g.userId),
|
||||||
|
'a distributed guesser must still trip the account lock'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// And the lock holds even against the correct PIN, which is what makes it a real control.
|
||||||
const correct = await fetch(`${BASE}/api/v1/recover`, {
|
const correct = await fetch(`${BASE}/api/v1/recover`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.78' },
|
||||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||||
});
|
});
|
||||||
expect(correct.status).toBe(429);
|
expect(correct.status).toBe(429);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
|
test('the wrong-PIN streak is atomic under concurrency', async ({ guest, db }) => {
|
||||||
guest,
|
|
||||||
}) => {
|
|
||||||
const g = await guest('BruteParallel');
|
const g = await guest('BruteParallel');
|
||||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||||
|
|
||||||
const attempts = await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: 10 }, () =>
|
Array.from({ length: 10 }, () =>
|
||||||
fetch(`${BASE}/api/v1/recover`, {
|
fetch(`${BASE}/api/v1/recover`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -133,29 +191,14 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const statuses = attempts.map((r) => r.status);
|
|
||||||
expect(
|
|
||||||
statuses.filter((s) => s === 200),
|
|
||||||
'a wrong PIN must never authenticate'
|
|
||||||
).toHaveLength(0);
|
|
||||||
|
|
||||||
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
// How many of the 10 get past the throttle is genuinely racy and cannot be asserted. What is
|
||||||
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
// NOT racy is that every one that DID reach the handler incremented the counter — it is a
|
||||||
// racy — and is the property this test exists to guard — is the state left behind:
|
// single `SET x = x + 1 ... RETURNING`, so none of them can be lost to the race.
|
||||||
// `failed_pin_attempts` is incremented with an atomic `SET x = x + 1 ... RETURNING`, so
|
|
||||||
// 10 wrong PINs must push it past the 3-strike threshold and leave the account locked.
|
|
||||||
//
|
|
||||||
// We prove that with a follow-up request using the CORRECT pin: it must be refused with
|
|
||||||
// 429 (locked), not 200. Delete the lockout counter and this line goes 200 → red.
|
|
||||||
const correct = await fetch(`${BASE}/api/v1/recover`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
|
||||||
});
|
|
||||||
expect(
|
expect(
|
||||||
correct.status,
|
await db.failedPinAttempts(g.userId),
|
||||||
'after 10 wrong PINs the account must be locked, even for the right PIN'
|
'concurrent wrong PINs must all be counted'
|
||||||
).toBe(429);
|
).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,29 +259,46 @@ test.describe('Adversarial — admin password brute-force', () => {
|
|||||||
expect(statuses.some((s) => s === 200)).toBe(false);
|
expect(statuses.some((s) => s === 200)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('once throttled, even the CORRECT admin password is refused (it is an IP limit, not a password check)', async ({
|
test('the FAILURE bucket never refuses a correct admin password', async ({ api, adminToken }) => {
|
||||||
api,
|
// This asserted the OPPOSITE — that a throttled IP is refused even with the right password —
|
||||||
adminToken,
|
// and that contract was deliberately removed, because at a real event it is a denial of
|
||||||
}) => {
|
// service against the operator. Every guest at the venue shares one public IP behind NAT and
|
||||||
// This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the
|
// `/admin/login` is a publicly linkable page, so a single tight IP bucket charged before the
|
||||||
// password logic. If the throttle were removed, the correct password would return 200 here.
|
// password check meant any phone in the room could keep it full and the host, on that same IP,
|
||||||
|
// could never spend a slot. The escape hatch was circular: `admin_login_rate_enabled` is only
|
||||||
|
// reachable through `PATCH /admin/config`, which needs the session being blocked.
|
||||||
|
//
|
||||||
|
// So the tight bucket is now charged ONLY on a wrong password. Brute force stays bounded (every
|
||||||
|
// guess costs a slot, per IP) while a valid credential is always honoured. A separate, generous
|
||||||
|
// per-IP ceiling bounds bcrypt CPU regardless of correctness — see ADMIN_LOGIN_CPU_CEILING.
|
||||||
await api.patchConfig(adminToken, {
|
await api.patchConfig(adminToken, {
|
||||||
rate_limits_enabled: 'true',
|
rate_limits_enabled: 'true',
|
||||||
admin_login_rate_enabled: 'true',
|
admin_login_rate_enabled: 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exhaust the window with wrong passwords until throttled.
|
// Exhaust the failure window with wrong passwords until throttled.
|
||||||
let throttled = false;
|
let throttled = false;
|
||||||
for (let i = 0; i < 10 && !throttled; i++) {
|
for (let i = 0; i < 10 && !throttled; i++) {
|
||||||
throttled = (await tryLogin('wrong-' + i)).status === 429;
|
throttled = (await tryLogin('wrong-' + i)).status === 429;
|
||||||
}
|
}
|
||||||
expect(throttled, 'the IP should be throttled after a burst').toBe(true);
|
expect(throttled, 'a burst of WRONG passwords from one IP must be rate-limited').toBe(true);
|
||||||
|
|
||||||
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
// The limiter is real (above) and yet the operator gets in. That combination is the whole
|
||||||
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
// property: THIS bucket keys on failure, not on the IP alone.
|
||||||
|
//
|
||||||
|
// Deliberately not claimed here: "guests cannot lock the host out". They still can — the
|
||||||
|
// separate CPU ceiling below refuses any password, correct included. This test stays under
|
||||||
|
// that ceiling on purpose so the two are not conflated.
|
||||||
expect(
|
expect(
|
||||||
(await tryLogin(ADMIN_PASSWORD)).status,
|
(await tryLogin(ADMIN_PASSWORD)).status,
|
||||||
'a throttled IP is refused even with the correct password'
|
'a burst of wrong guesses must not cost the operator their own admin panel'
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
// And guessing is still throttled AFTER a successful login — a correct password must not
|
||||||
|
// refill or bypass the attacker's bucket.
|
||||||
|
expect(
|
||||||
|
(await tryLogin('wrong-again')).status,
|
||||||
|
'a successful login must not clear the failure bucket for wrong guesses'
|
||||||
).toBe(429);
|
).toBe(429);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -101,10 +101,26 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
const responses = await Promise.all(requests);
|
const responses = await Promise.all(requests);
|
||||||
// All accepted (or some rate-limited — both fine).
|
// One session may hold only MAX_TICKETS_PER_SESSION (4) live tickets — enough for a guest with
|
||||||
for (const r of responses) {
|
// a couple of tabs open, deliberately not enough for a reconnect loop to accumulate. Minting a
|
||||||
expect([200, 429]).toContain(r.status);
|
// 5th evicts the oldest, so most of these ten tickets are already dead when their stream opens
|
||||||
|
// and the server answers 401. That IS the cap working; this used to allow only [200, 429] and
|
||||||
|
// so failed on the very defence it was written to exercise.
|
||||||
|
//
|
||||||
|
// What must hold is that the server sheds the flood deliberately rather than falling over: no
|
||||||
|
// 5xx, and the surviving tickets still get their stream.
|
||||||
|
const statuses = responses.map((r) => r.status);
|
||||||
|
for (const s of statuses) {
|
||||||
|
expect([200, 401, 429], `unexpected status from the stream flood: ${statuses}`).toContain(s);
|
||||||
}
|
}
|
||||||
|
// EXACTLY four, not merely "at least one". The cap is a known constant, so asserting a
|
||||||
|
// bound this loose would still pass if it were tightened to 1 (a guest with two tabs loses
|
||||||
|
// their live feed) or if eviction kept the OLDEST ticket instead of the newest (every
|
||||||
|
// reconnect throws away the ticket it just minted — a permanently dead feed for that guest).
|
||||||
|
expect(
|
||||||
|
statuses.filter((s) => s === 200).length,
|
||||||
|
`exactly MAX_TICKETS_PER_SESSION streams should survive, got: ${statuses}`
|
||||||
|
).toBe(4);
|
||||||
// Tear them all down so the next test doesn't see leaked connections.
|
// Tear them all down so the next test doesn't see leaked connections.
|
||||||
controllers.forEach((c) => c.abort());
|
controllers.forEach((c) => c.abort());
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,16 @@ test.describe('Browser chaos — storage purge', () => {
|
|||||||
|
|
||||||
await clearAllStorage(page);
|
await clearAllStorage(page);
|
||||||
|
|
||||||
await page.goto('/admin');
|
// The `goto` is deliberately allowed to REJECT. What is under test is a race the app wins:
|
||||||
|
// the admin layout redirects to /admin/login the moment it sees no JWT, and a redirect that
|
||||||
|
// lands first makes this navigation either "interrupted by another navigation" or
|
||||||
|
// ERR_ABORTED. Both mean the app did exactly the right thing, quickly — so failing on them
|
||||||
|
// made the test red precisely when the behaviour was correct, roughly one run in three, and
|
||||||
|
// it read as a regression in whatever change happened to be in flight. (`waitUntil: 'commit'`
|
||||||
|
// narrows the window but does not close it; an abort can beat commit too.)
|
||||||
|
//
|
||||||
|
// `waitForURL` below is the assertion, and it is unaffected by how the navigation ended.
|
||||||
|
await page.goto('/admin').catch(() => {});
|
||||||
// The admin layout should bounce them to /admin/login when the JWT is gone.
|
// The admin layout should bounce them to /admin/login when the JWT is gone.
|
||||||
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
||||||
});
|
});
|
||||||
@@ -94,6 +103,12 @@ test.describe('Browser chaos — storage purge', () => {
|
|||||||
const g = await guest('PurgePin');
|
const g = await guest('PurgePin');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
|
// Let the page finish settling before touching its execution context. `goto` resolves at
|
||||||
|
// `load`, but the layout's boot hydration is still in flight and any navigation it triggers
|
||||||
|
// destroys the context out from under the `page.evaluate` below — which surfaced as an
|
||||||
|
// intermittent "Execution context was destroyed" that has nothing to do with what this test
|
||||||
|
// asserts.
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Simulate clearAuth() — clears JWT + user_id but keeps PIN so the user can recover.
|
// Simulate clearAuth() — clears JWT + user_id but keeps PIN so the user can recover.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
|
|||||||
// click reaches a live handler. See the test above.
|
// click reaches a live handler. See the test above.
|
||||||
await expect(page.getByText(g.displayName)).toBeVisible();
|
await expect(page.getByText(g.displayName)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Event verlassen/i }).click();
|
await page.getByTestId('account-logout').click();
|
||||||
const sheet = page.getByTestId('confirm-sheet');
|
const sheet = page.getByTestId('confirm-sheet');
|
||||||
await expect(sheet).toBeVisible();
|
await expect(sheet).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user