Compare commits
36 Commits
worktree-a
...
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 |
61
.env.example
61
.env.example
@@ -12,6 +12,15 @@ DOMAIN=my-event.example.com
|
||||
# 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 ────────────────────────────────────────────────────────────────
|
||||
@@ -36,8 +45,12 @@ DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/events
|
||||
POSTGRES_USER=eventsnap
|
||||
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
||||
POSTGRES_DB=eventsnap
|
||||
# Connection pool size. The code default is 10 (backend/src/db.rs) — set it explicitly,
|
||||
# because a `.env` written by hand from this file's secrets is otherwise silently on 10.
|
||||
# Connection pool size. The code default is 15 (DEFAULT_MAX_CONNECTIONS in backend/src/db.rs),
|
||||
# and docker-compose.yml pins this value in `app.environment` so an edit here cannot reach the
|
||||
# container. That pin is deliberate: since the value became boot-FATAL when unparseable — so an
|
||||
# operator tuning a knob that never took effect gets told, instead of silently staying on the
|
||||
# default — a stray quote or a trailing inline comment in `.env` would crash-loop the app behind
|
||||
# a live Caddy. Change the pin in compose, not this line.
|
||||
#
|
||||
# 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
|
||||
@@ -74,7 +87,15 @@ SESSION_EXPIRY_DAYS=30
|
||||
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── Storage ───────────────────────────────────────────────────────────────────
|
||||
@@ -100,17 +121,33 @@ EXPORT_PATH=/exports
|
||||
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
|
||||
# which anything warns you:
|
||||
#
|
||||
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
|
||||
# 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
|
||||
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
|
||||
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
|
||||
# estimated_guest_count is a FLOOR ON THE DIVISOR, not decoration — it is a live knob
|
||||
# (upload::quota_limit_bytes). Earlier drafts of this file and the runbook both omitted
|
||||
# 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
|
||||
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
|
||||
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
|
||||
# Memories.zip are each roughly a second copy of every original (both store media
|
||||
# uncompressed). Budget for media + 2x media, or move exports to their own volume.
|
||||
# It is recomputed against LIVE free space on every upload, so in principle it self-
|
||||
# throttles: guests converge on a fixed point at tolerance/(1+tolerance) of the free space
|
||||
# you started with — 43% at 0.75.
|
||||
#
|
||||
# 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
|
||||
# provisioned export headroom separately.
|
||||
|
||||
38
.github/workflows/checks.yml
vendored
38
.github/workflows/checks.yml
vendored
@@ -102,6 +102,44 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
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:
|
||||
name: E2E — typecheck + lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
30
Caddyfile
30
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} {
|
||||
# Compress everything EXCEPT the SSE stream — gzip buffering delays
|
||||
# "real-time" likes/comments until the ~30s keep-alive tick.
|
||||
|
||||
@@ -15,42 +15,57 @@ Everything here is written for that last constraint. Where a choice trades throu
|
||||
|
||||
## 0. Timeline — the single most important control
|
||||
|
||||
### Step zero: commit everything, before you build anything
|
||||
### Step zero: verify the deployment files are committed, before you build anything
|
||||
|
||||
**The production deployment does not exist in git yet.** At the time of writing, `docker-compose.yml`
|
||||
and `.env.example` are modified but uncommitted, and `DEPLOYMENT_RUNBOOK.md`,
|
||||
`docker-compose.build.yml`, both `.dockerignore` files and migrations `021`/`022` are untracked.
|
||||
None of them is gitignored — they are simply not committed.
|
||||
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:
|
||||
|
||||
That is not a tidiness problem, it is the deployment failing in two ways at once:
|
||||
- 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.
|
||||
|
||||
- §7 tells you to `git clone` onto the server. `git show HEAD:docker-compose.yml` still has
|
||||
`build:` keys and **no `image:` keys**, so on that clone `docker compose pull` skips both services
|
||||
and `docker compose up -d` starts **a fat-LTO release build of 427 crates on the CX22** — the
|
||||
exact scenario §1 rules out as an expected OOM. The committed file also has no log rotation.
|
||||
- `sqlx::migrate!()` embeds `./migrations` **at compile time**. A build from the working tree bakes
|
||||
in 021 and 022 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
|
||||
git add docker-compose.yml docker-compose.dev.yml docker-compose.build.yml .env.example \
|
||||
backend/.dockerignore frontend/.dockerignore frontend/Dockerfile \
|
||||
backend/migrations/021_*.sql backend/migrations/022_*.sql \
|
||||
DEPLOYMENT_RUNBOOK.md
|
||||
git commit -m "chore: production compose, ignore files and runbook"
|
||||
git push
|
||||
# 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
|
||||
|
||||
# Prove it landed — this must print two `image:` lines and nothing about `build:`
|
||||
git show HEAD:docker-compose.yml | grep -E 'image:|build:'
|
||||
git show HEAD --stat | grep -c migrations/02 # must be 4
|
||||
# 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). Run the backup rehearsal (§10). |
|
||||
| **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
|
||||
@@ -103,7 +118,7 @@ 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:9` does `COPY . .`
|
||||
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:`
|
||||
@@ -169,16 +184,35 @@ 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 ──────────────────────────────────
|
||||
MEDIA_PATH=/media # pinned by compose anyway, but keep consistent
|
||||
EXPORT_PATH=/exports # NOT pinned by compose — see the trap in §7.3
|
||||
# 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) ────────────────────────────────
|
||||
DATABASE_MAX_CONNECTIONS=30
|
||||
# 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 ─────────────────────────────────────
|
||||
@@ -188,10 +222,14 @@ COMMENTS_ENABLED=false
|
||||
RUST_LOG=eventsnap_backend=info,tower_http=warn
|
||||
```
|
||||
|
||||
### Two corrections to the repo's own advice — do not follow `.env.example` here
|
||||
### 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.** `.env.example:92-98` justifies both on the premise that "each worker can run an
|
||||
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.
|
||||
@@ -206,9 +244,15 @@ guard in `imaging::decode_limits` does not cover. Estimated peak per photo:
|
||||
| 24 MP (iPhone Pro default) | ~223 MB |
|
||||
| 48 MP ("Max" mode) | ~354 MB |
|
||||
|
||||
At concurrency 2, two 48 MP photos ≈ 800 MB against the 1 GiB cap — ~25% margin. At concurrency 4
|
||||
the same pair is ~1.5 GB → **OOM**. And app=2G + db=1G + 256M + 256M + ~370 MB OS/Docker ≈ 3954 MiB
|
||||
against ~3910 MiB MemTotal — the box is oversubscribed before a single photo arrives.
|
||||
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.
|
||||
@@ -231,22 +275,33 @@ after the first deploy, before the event.
|
||||
| `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. |
|
||||
|
||||
**Ignore `estimated_guest_count` and `upload_count_quota_enabled`.** Both are seeded, validated and
|
||||
rendered in the admin UI — and **read by no code at all** (verified by grep across `backend/src`).
|
||||
Changing them does nothing. `estimated_guest_count` in particular does *not* feed the quota formula.
|
||||
> **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
|
||||
|
||||
```
|
||||
per_user_limit = floor(free_disk × quota_tolerance / max(active_uploaders, 1))
|
||||
divisor = max(active_uploaders, estimated_guest_count, 1)
|
||||
per_user_limit = max(floor(free_disk × quota_tolerance / divisor), 500 MiB)
|
||||
```
|
||||
`active_uploaders` is `SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL`
|
||||
(`upload::compute_storage_quota`) — **people who actually uploaded**, not guests who joined. The 70 guests who never
|
||||
upload are not in the denominator; their share flows to the photographers automatically. **The
|
||||
redistribution you asked for is already the design.**
|
||||
(`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.)
|
||||
|
||||
With ~28 GB free and a realistic 30 people actually uploading, each gets **~700 MB** — against an
|
||||
expected ~1.25 GB for the *entire event*. Nobody will be blocked.
|
||||
`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,
|
||||
@@ -302,11 +357,35 @@ rate-limit you out of getting a certificate at all.
|
||||
### Host preparation
|
||||
|
||||
```bash
|
||||
docker compose version # must be v2.x — the deploy.resources limits need it
|
||||
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:
|
||||
|
||||
@@ -316,10 +395,19 @@ echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
sysctl -w vm.swappiness=10 && echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
|
||||
```
|
||||
|
||||
> **Gotcha:** 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 silently **doubles** every
|
||||
> container ceiling. If you add swap, also add `memswap_limit: 1152m` to `app` and `db`, and
|
||||
> `memswap_limit: 320m` to `frontend` and `caddy` (service-level, not under `deploy:`).
|
||||
> **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.
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
@@ -410,28 +498,33 @@ describes this trap at `backend/src/db.rs` (`explain_auth_failure`); this comman
|
||||
### 7.2 Bring it up
|
||||
|
||||
```bash
|
||||
# `.env` is consumed by docker compose, not by your shell — load it before using $DOMAIN.
|
||||
set -a; . ./.env; set +a
|
||||
# `.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 the three things compose does *not* pin
|
||||
### 7.3 Verify what the container actually received
|
||||
|
||||
`MEDIA_PATH` is pinned to `/media` on the `app` service in `docker-compose.yml`. Its siblings
|
||||
are not:
|
||||
`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`** must contain `@db:5432`. A dev `.env` points it at `@localhost`, which inside
|
||||
the container is the app itself.
|
||||
2. **`EXPORT_PATH`** must be `/exports`. 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`** must match `.env` **byte for byte.**
|
||||
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:
|
||||
|
||||
@@ -455,9 +548,10 @@ the cost of checking is 10 seconds; the cost of being wrong is the whole event.
|
||||
## 8. Post-deploy verification
|
||||
|
||||
```bash
|
||||
set -a; . ./.env; set +a # $DOMAIN comes from .env, not your shell
|
||||
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'") # $DOMAIN comes from .env, not your shell
|
||||
|
||||
docker compose ps # all four healthy
|
||||
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
|
||||
@@ -486,13 +580,13 @@ explanation:
|
||||
|
||||
```
|
||||
$ git ls-tree --name-only v0.12.0 backend/migrations/ | wc -l
|
||||
12 # 6 migrations. HEAD has 22.
|
||||
12 # 6 migrations. HEAD has 31.
|
||||
$ git rev-list --count v0.12.0..HEAD
|
||||
154
|
||||
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–022, returns `VersionMissing`.
|
||||
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
|
||||
@@ -540,7 +634,7 @@ covers both. It exists so that the rollback line in the emergency card is safe t
|
||||
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 22 migrations have paired
|
||||
**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:
|
||||
|
||||
@@ -556,6 +650,18 @@ 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.
|
||||
|
||||
@@ -570,18 +676,120 @@ docker save registry.mc02.dev/eventsnap/app:v0.13.0 \
|
||||
|
||||
## 10. Backup
|
||||
|
||||
Full commands are in `README.md:315-435` and are correct — `pg_dump --clean --if-exists`, plus
|
||||
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.
|
||||
|
||||
Two gaps the README does not cover:
|
||||
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. **Timing.** Do not use nightly cron — every irreplaceable byte is created inside one evening.
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -602,10 +810,13 @@ total ~11–13 GB of ~36 GB usable
|
||||
3–5 GB of cache that permanently shrinks the guest quota, because the quota is recomputed against
|
||||
*live* free space on every upload).
|
||||
|
||||
The `README.md:295-299` "ENOSPC" projection 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 when free space drops below 10 GB or below the keepsake requirement (`handlers::host`'s low-disk thresholds).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -654,6 +865,56 @@ These change what you will observe on the night, so they are listed separately f
|
||||
| 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
|
||||
@@ -665,8 +926,10 @@ 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.
|
||||
set -a; . ./.env; set +a
|
||||
# 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
|
||||
@@ -686,6 +949,24 @@ sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env && docker com
|
||||
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.
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
||||
| Styling | Tailwind CSS | Utility-first, mobile-first; zero runtime CSS overhead |
|
||||
| Backend | Rust + Axum | Developer preference; memory safety, single-binary deploy |
|
||||
| Async Runtime | Tokio | De-facto Rust async runtime; Axum is built on it |
|
||||
| Database Driver | SQLx | Async PostgreSQL with compile-time query checking; automatic prepared statements |
|
||||
| Database 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 |
|
||||
| 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 |
|
||||
@@ -1206,7 +1206,7 @@ of the media volume alone silently loses every generated keepsake.
|
||||
|-------|---------|
|
||||
| `axum` | Web framework |
|
||||
| `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 |
|
||||
| `bcrypt` | PIN + admin password hashing |
|
||||
| `uuid` | UUID v7 (time-sortable) |
|
||||
|
||||
90
README.md
90
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 |
|
||||
| Backend | Rust + Axum |
|
||||
| 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 |
|
||||
| Image processing | `image` crate + `oxipng` (lossless compression) |
|
||||
| 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
|
||||
|
||||
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
|
||||
> `build:` services with no published image tag, and Compose has no source-change detection:
|
||||
> if an image with that name already exists it is reused. After a `git pull` the command
|
||||
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
|
||||
> nothing looks exactly like a successful one. `--build` is what makes it real.
|
||||
> **The event server never compiles.** `app` and `frontend` have **no `build:` key** — they
|
||||
> pull an immutable tag from the registry (the `app` service in `docker-compose.yml` says so explicitly, so that
|
||||
> a wrong tag fails instantly with `manifest unknown` instead of silently starting a 45-minute
|
||||
> compile on the box guests are using). A `git pull` therefore deploys **nothing** on its own,
|
||||
> 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
|
||||
# ── 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
|
||||
|
||||
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
|
||||
# (See "Backup" below; the database dump is the one that matters here.)
|
||||
|
||||
# 2. Fetch the new code.
|
||||
# 2. Fetch the new compose/Caddyfile. This does NOT change which image runs.
|
||||
git pull
|
||||
|
||||
# 3. Rebuild and restart the application services. --build is NOT optional.
|
||||
docker compose up -d --build
|
||||
# 3. Point the stack at the new tag.
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
|
||||
# compare — it must have changed. (Ignore the CREATED column; it reports the base
|
||||
# layer's age, not this build's.) An unchanged ID means step 3 ran without --build
|
||||
# and you are still serving the old code.
|
||||
# 8. Confirm the running containers are actually on the new tag.
|
||||
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
|
||||
that a migration applied by a *newer* build is not removed by checking out an older commit,
|
||||
so rolling back code without restoring the database snapshot from step 1 leaves the schema
|
||||
ahead of the binary and the app refusing to boot.
|
||||
that a migration applied by a *newer* build is not removed by rolling the tag back, so
|
||||
reverting `EVENTSNAP_VERSION` without restoring the database snapshot from step 1 leaves the
|
||||
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
|
||||
> `caddy` is a pinned upstream image. Compose decides whether to recreate a container from its
|
||||
> **Why step 6 exists.** Steps 4–5 only touch `app` and `frontend`; `caddy` is a separate
|
||||
> pinned upstream image. Compose decides whether to recreate a container from its
|
||||
> *config hash*, which covers the mount **specification** (`./Caddyfile:/etc/caddy/Caddyfile:ro`)
|
||||
> but **not the file's contents** — so a `git pull` that changes `./Caddyfile` produces no
|
||||
> delta, Compose reports `Running`, and Caddy keeps serving its old config indefinitely. Exit
|
||||
@@ -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
|
||||
> (`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.
|
||||
>
|
||||
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
|
||||
@@ -293,19 +311,29 @@ to imply is gone. What bounds the disk is the **global gate in the upload handle
|
||||
which refuses any upload that would leave too little room to build the keepsake:
|
||||
|
||||
```
|
||||
free_after_upload < media_after × 1.1 × 2 + DISK_RESERVE_BYTES → refused
|
||||
free_after_upload < media_after × 1.1 × 2 + DISK_RESERVE_BYTES
|
||||
+ UPLOAD_GATE_HEADROOM_BYTES → refused
|
||||
```
|
||||
|
||||
Solving that for the gallery size gives the real ceiling. 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:
|
||||
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.
|
||||
|
||||
| Volume | Usable after baseline | Media ceiling | Free at release |
|
||||
|---|---|---|---|
|
||||
| 40 GB | ~35 GB | **~8 GB** | ~27 GB → both archives fit |
|
||||
| 80 GB | ~70 GB | ~19 GB | ~51 GB → both archives fit |
|
||||
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:
|
||||
|
||||
**Uploads therefore stop at roughly 8 GB of media on a 40 GB box, not when the disk is
|
||||
| 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
|
||||
|
||||
4
backend/Cargo.lock
generated
4
backend/Cargo.lock
generated
@@ -605,9 +605,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
@@ -13,6 +13,13 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||
COPY src ./src
|
||||
COPY static ./static
|
||||
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
|
||||
|
||||
# --- 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/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);
|
||||
@@ -28,6 +28,37 @@ use crate::state::AppState;
|
||||
/// feed's byline, and that "Admin" stays available for the admin row.
|
||||
const RESERVED_DISPLAY_NAMES: &[&str] = &["admin", "administrator", "host", "eventsnap"];
|
||||
|
||||
/// How long a `client_join_id` stays replayable after the account it created.
|
||||
///
|
||||
/// The key exists to survive a lost response, which a client retries within seconds — a guest
|
||||
/// walking back into signal and reopening the app is the slow end of it. Beyond that the key is
|
||||
/// only a liability: it is accepted pre-auth and answers with a session, so an unbounded lifetime
|
||||
/// makes every abandoned join attempt a permanent credential sitting in `localStorage`.
|
||||
const JOIN_REPLAY_WINDOW_MINUTES: i64 = 30;
|
||||
|
||||
/// May a presented `client_join_id` replay the account it created?
|
||||
///
|
||||
/// Extracted so the two guards are unit-testable without a database. Both must hold — see the
|
||||
/// call site in `join` for why either alone is insufficient.
|
||||
///
|
||||
/// Case-insensitive on the name to match the `LOWER(display_name)` uniqueness index: a guest
|
||||
/// retyping "anna" for "Anna" is the same person resuming, not a different one.
|
||||
fn join_key_replayable(
|
||||
stored_name: &str,
|
||||
submitted_name: &str,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
now: chrono::DateTime<Utc>,
|
||||
) -> bool {
|
||||
if stored_name.to_lowercase() != submitted_name.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
let age = now.signed_duration_since(created_at);
|
||||
// A negative age means the row is stamped in the future — clock skew between the app and
|
||||
// Postgres. Treat it as in-window rather than replayable-forever: the comparison below is
|
||||
// `<=`, so a negative duration passes, which is the same answer as "just created".
|
||||
age <= chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES)
|
||||
}
|
||||
|
||||
fn is_reserved_display_name(name: &str) -> bool {
|
||||
let name = name.trim().to_lowercase();
|
||||
RESERVED_DISPLAY_NAMES.contains(&name.as_str())
|
||||
@@ -74,6 +105,11 @@ fn validate_display_name(raw: &str) -> Result<&str, AppError> {
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinRequest {
|
||||
pub display_name: String,
|
||||
/// Stable per-attempt key so a retry after a lost response resumes the same join instead of
|
||||
/// 409ing on a name the caller itself owns. Optional: older clients simply behave as before.
|
||||
/// See migration 027 and the retry branch in `join`.
|
||||
#[serde(default)]
|
||||
pub client_join_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -101,7 +137,18 @@ pub async fn join(
|
||||
// Cheap enough to run before validation, which keeps a flood of malformed bodies from
|
||||
// being free.
|
||||
if rate_limits_on && join_rate_on {
|
||||
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 60).await;
|
||||
// 300/min, not 60. The comment above has the right principle and the old default did not
|
||||
// follow it: a 100-guest wedding does not trickle in, it arrives at the door together when
|
||||
// the QR code goes up, and every one of those joins is the SAME public IP. At 60/min guests
|
||||
// 61-100 got a 429 on the one screen that has no auto-retry — the join page — so the
|
||||
// remedy was "ask a stranger why the app says no and tap again", at exactly the moment the
|
||||
// host is busiest. Each shed request also costs another slot when they do retry.
|
||||
//
|
||||
// This is not the anti-spam control (that is the per-name bucket below, which a flood
|
||||
// cannot evade) nor the CPU bound (that is BCRYPT_PERMITS, which caps concurrent hashing
|
||||
// at 2 regardless of how many requests arrive). It only bounds raw volume, so it can
|
||||
// afford to sit well above the real arrival peak.
|
||||
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 300).await;
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("join_ip:{ip}"),
|
||||
ip_ceiling,
|
||||
@@ -147,6 +194,58 @@ pub async fn join(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// IDEMPOTENT RETRY — must come before the name-taken check, because on a retry the name is
|
||||
// taken by our OWN row and the 409 below is precisely the bug (H16).
|
||||
//
|
||||
// The account exists and its PIN hash is committed, but the plaintext went out in a response
|
||||
// that never arrived, so nobody alive knows it. We cannot replay it (only the bcrypt is
|
||||
// stored, deliberately), so instead we ROTATE it: mint a new PIN, overwrite the hash, issue a
|
||||
// fresh session, and answer as if this were the original reply.
|
||||
//
|
||||
// Rotating is safe here in a way it would not be elsewhere: the previous PIN was never
|
||||
// displayed to anyone, so there is no device holding it and nothing to invalidate. And it
|
||||
// beats the alternative of persisting plaintext PINs so they can be replayed — that would put
|
||||
// a recoverable credential in the database for every guest, to fix a lost packet.
|
||||
// Look the key up once, then decide whether this caller is entitled to replay it.
|
||||
let prior = match body.client_join_id {
|
||||
Some(k) => User::find_by_client_join_id(&state.pool, event.id, k).await?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
// A key replays ONLY for the name that minted it, and only briefly. Without both guards the
|
||||
// key is a bearer credential: it is accepted pre-auth, the submitted name was ignored, and the
|
||||
// reply carries a session with the stored row's ROLE. So anyone holding one guest's key could
|
||||
// send any name at all and receive that guest's account — a host's, if they had been promoted
|
||||
// — while the rotation below locked the rightful owner out of their own PIN.
|
||||
//
|
||||
// * Name binding also fixes the ordinary, non-malicious version, which a party guarantees:
|
||||
// a guest's join fails, they hand the venue tablet to the next person, and that person
|
||||
// joins under their own name — landing in the first guest's account, posting as them.
|
||||
// * The window bounds the credential's life. A lost response is retried in seconds, not
|
||||
// hours; the client also clears the key on success, so the only keys that survive at all
|
||||
// are genuinely-failed attempts. After the window the key is spent and a normal join runs.
|
||||
let replay = prior.as_ref().filter(|existing| {
|
||||
join_key_replayable(
|
||||
&existing.display_name,
|
||||
display_name,
|
||||
existing.created_at,
|
||||
Utc::now(),
|
||||
)
|
||||
});
|
||||
|
||||
// A key whose row exists but is not ours to replay is spent — it must not be carried into the
|
||||
// INSERT below, or it would collide with that row on `user_client_join_id_key` and report a
|
||||
// name clash the guest cannot act on.
|
||||
let effective_join_key = if prior.is_some() {
|
||||
None
|
||||
} else {
|
||||
body.client_join_id
|
||||
};
|
||||
|
||||
if let Some(existing) = replay {
|
||||
return replay_join(&state, event.id, existing).await;
|
||||
}
|
||||
|
||||
// Reject if a user with this name (case-insensitive) already exists
|
||||
if User::name_taken(&state.pool, event.id, display_name).await? {
|
||||
return Err(AppError::Conflict(format!(
|
||||
@@ -162,9 +261,42 @@ pub async fn join(
|
||||
// The pre-check above is racy: two simultaneous joins with the same name can both
|
||||
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
||||
// violation to the same clean 409 the pre-check returns, not a generic 500.
|
||||
let user = match User::create(&state.pool, event.id, display_name, &pin_hash).await {
|
||||
let user = match User::create(
|
||||
&state.pool,
|
||||
event.id,
|
||||
display_name,
|
||||
&pin_hash,
|
||||
effective_join_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
|
||||
// Either the display name or `client_join_id` collided. Both mean "somebody already
|
||||
// holds this", and for the join key that somebody is a concurrent retry of this very
|
||||
// request — so re-check the key and replay it rather than reporting a name clash the
|
||||
// guest cannot act on.
|
||||
// REPLAY it, don't 409. The loser of this race is holding proof that it is the same
|
||||
// attempt as the winner — the same `client_join_id`, minted by the same phone — and
|
||||
// the winner's row is a few milliseconds old under the same name, so the ordinary
|
||||
// name+window replay guard passes. Returning a Conflict instead sent the guest to the
|
||||
// join page's `code === 'conflict'` branch, which renders the NAME-TAKEN screen with a
|
||||
// PIN entry form — for a PIN that was never displayed to anybody. That is precisely
|
||||
// the dead end migration 027 exists to close, re-entered through the racing path, and
|
||||
// it is easy to hit: the guest's instinctive response to a hung request is to reload
|
||||
// and tap Join again.
|
||||
if let Some(join_key) = effective_join_key
|
||||
&& let Some(winner) =
|
||||
User::find_by_client_join_id(&state.pool, event.id, join_key).await?
|
||||
&& join_key_replayable(
|
||||
&winner.display_name,
|
||||
display_name,
|
||||
winner.created_at,
|
||||
Utc::now(),
|
||||
)
|
||||
{
|
||||
return replay_join(&state, event.id, &winner).await;
|
||||
}
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Der Name \"{}\" ist bereits vergeben.",
|
||||
display_name
|
||||
@@ -197,6 +329,61 @@ pub async fn join(
|
||||
))
|
||||
}
|
||||
|
||||
/// Answer a join retry with the row its `client_join_id` already minted.
|
||||
///
|
||||
/// Rotates the PIN and issues a fresh session. Rotating is safe precisely here: the previous PIN
|
||||
/// was never displayed to anyone (that is what "the response was lost" means), so no device holds
|
||||
/// it and there is nothing to invalidate. The alternative — persisting plaintext PINs so they can
|
||||
/// be replayed — would put a recoverable credential in the database for every guest, to fix a lost
|
||||
/// packet.
|
||||
///
|
||||
/// Callers must have established that this caller is ENTITLED to the replay (`join_key_replayable`:
|
||||
/// same display name, inside the window). Both call sites do; the key is accepted pre-auth, so
|
||||
/// without that check it would be a bearer credential for someone else's account.
|
||||
async fn replay_join(
|
||||
state: &AppState,
|
||||
event_id: Uuid,
|
||||
existing: &User,
|
||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash = hash_password(pin.clone(), 12).await?;
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET recovery_pin_hash = $2, failed_pin_attempts = 0, pin_locked_until = NULL
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(existing.id)
|
||||
.bind(&pin_hash)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let token = jwt::create_token(
|
||||
existing.id,
|
||||
event_id,
|
||||
existing.role.clone(),
|
||||
&state.config.jwt_secret,
|
||||
state.config.session_expiry_days,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, existing.id, &token_hash, expires_at).await?;
|
||||
|
||||
tracing::info!(
|
||||
user_id = %existing.id,
|
||||
"join retry matched client_join_id; rotated the PIN and re-issued a session"
|
||||
);
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(JoinResponse {
|
||||
jwt: token,
|
||||
pin,
|
||||
user_id: existing.id,
|
||||
is_new: true,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Default for `recover_name_rate_per_15min` — wrong PINs allowed per (IP, name) per 15 min.
|
||||
/// Mirrors migration 023; kept here so the invariant below can be asserted in a test.
|
||||
const RECOVER_NAME_CEILING_DEFAULT: usize = 4;
|
||||
@@ -211,6 +398,53 @@ const RECOVER_NAME_CEILING_DEFAULT: usize = 4;
|
||||
/// raising this key restored the exact DoS the tier ordering exists to prevent, silently.
|
||||
pub const RECOVER_NAME_CEILING_MAX: usize = (PIN_LOCK_THRESHOLD as usize) / 3;
|
||||
|
||||
/// Wrong PINs one IP may produce across ALL names before it is shut out for 15 minutes.
|
||||
///
|
||||
/// This is the tier that was missing, and its absence is what made 4-digit PINs practically
|
||||
/// brute-forceable into a HOST account (H2). Hosts are promoted guests, so a host's entire
|
||||
/// credential is a `{:04}` PIN, and `/uploaders` hands any joined guest the authoritative list of
|
||||
/// names to try. The existing tiers are per-`(IP, name)` (4 per 15 min) and a per-IP REQUEST ceiling
|
||||
/// (30/min) — neither of which bounds guesses *spread across names*:
|
||||
///
|
||||
/// * 4 guesses × 100 names = ~400 wrong PINs per 15 minutes from one IP
|
||||
/// * no single account ever reaches the 12-failure lock, so nobody is locked out to notice
|
||||
/// * nothing was logged beyond one `warn` per attempt, which nothing aggregates or alerts on
|
||||
///
|
||||
/// That is ~38,000 guesses/day against 100 accounts at 1/10,000 each — a coin-flip inside a week,
|
||||
/// and far better odds than that against any particular host over an evening.
|
||||
///
|
||||
/// Counting FAILURES rather than requests is what makes this safe to set low: a guest recovering
|
||||
/// their own device types their PIN correctly and is never charged, so a whole venue behind one NAT
|
||||
/// is unaffected. 30 wrong PINs from a single IP in 15 minutes is already far beyond fat-fingering.
|
||||
const RECOVER_IP_FAILURE_CEILING: usize = 30;
|
||||
const RECOVER_IP_FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Window for the per-(IP, name) failure budget. Matches the per-account lockout duration, so a
|
||||
/// guest who trips both waits the same fifteen minutes rather than two stacked penalties.
|
||||
const RECOVER_NAME_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
/// Charge one failed PIN attempt against the per-IP budget, and report whether it is now spent.
|
||||
///
|
||||
/// Deliberately charged on the way OUT of a failure rather than checked on the way in, so that the
|
||||
/// only thing that consumes budget is a genuinely wrong PIN.
|
||||
fn charge_recover_failure(state: &AppState, ip: &str) {
|
||||
if let Err(retry_after) = state.rate_limiter.check_with_retry(
|
||||
format!("recover_fail:{ip}"),
|
||||
RECOVER_IP_FAILURE_CEILING,
|
||||
RECOVER_IP_FAILURE_WINDOW,
|
||||
) {
|
||||
// Loud, and with the numbers an alert can key on. The old code logged one line per
|
||||
// attempt at the same level as an ordinary typo, so a horizontal sweep looked exactly
|
||||
// like a hundred guests mistyping.
|
||||
tracing::error!(
|
||||
ip = %ip,
|
||||
retry_after_secs = retry_after,
|
||||
ceiling = RECOVER_IP_FAILURE_CEILING,
|
||||
"possible PIN brute force: one IP exhausted its failed-PIN budget across names"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrong PINs, from ALL sources, before the ACCOUNT itself is locked for 15 minutes.
|
||||
///
|
||||
/// This was 3, which sat BELOW the per-(IP, name) ceiling of 5 — and that ordering, not the
|
||||
@@ -280,23 +514,84 @@ static BCRYPT_PERMITS: std::sync::LazyLock<tokio::sync::Semaphore> =
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
tokio::sync::Semaphore::new(cores.saturating_sub(1).max(1))
|
||||
// `cores - 1` resolves to exactly ONE permit on the 2-vCPU box this runs on, which turned
|
||||
// every join/recover/admin-login into a strict queue at ~200-250 ms each. `/recover` runs
|
||||
// an unconditional verify even for an unknown name at 30/min/IP, so roughly eight source
|
||||
// IPs saturated it indefinitely — and with an untimed `acquire()` an arriving guest's
|
||||
// `/join` HUNG rather than getting a 503 they could retry.
|
||||
//
|
||||
// Floor of 2: bcrypt at cost 12 is CPU-bound but runs on the blocking pool, so two in
|
||||
// flight on two cores still leaves the async runtime responsive, and it doubles arrival
|
||||
// throughput during the one burst that matters (everyone scanning the QR at once).
|
||||
tokio::sync::Semaphore::new(cores.saturating_sub(1).max(2))
|
||||
});
|
||||
|
||||
async fn verify_password(candidate: String, hash: String) -> bool {
|
||||
// `acquire()` only fails if the semaphore is closed, which never happens here.
|
||||
let _permit = BCRYPT_PERMITS.acquire().await;
|
||||
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
|
||||
/// How long to wait for a bcrypt permit before shedding the request.
|
||||
///
|
||||
/// Generous relative to one hash (~200-250 ms) and far below any client timeout, so it only fires
|
||||
/// when the queue is genuinely deep. Shedding with a `Retry-After` beats hanging: the client
|
||||
/// already honours 503 + Retry-After on the load-shedding path, and a guest who is told to try
|
||||
/// again in a moment is in a much better position than one staring at a spinner.
|
||||
/// How long an auth request waits for a bcrypt permit before shedding.
|
||||
///
|
||||
/// Sized against the arrival burst, not against a comfortable latency. Two permits at cost 12 is
|
||||
/// ~8 hashes/second, and tokio's semaphore is FIFO, so the request at queue position N waits
|
||||
/// roughly N/8 seconds. At 5s the cliff was position ~40: the "everyone scans the QR as they walk
|
||||
/// in" moment — the one case this endpoint exists for — put more than half of a 100-guest arrival
|
||||
/// past the deadline and answered them with a 503. The join page has no auto-retry, so each one
|
||||
/// became a guest standing in the doorway asking the host why the link is broken.
|
||||
///
|
||||
/// 15s drains a 100-guest burst (~12.5s) with margin, and still sits under the client's own 20s
|
||||
/// abort (`TIMEOUT_MS` in `api.ts`), so a genuinely saturated server is still reported as a
|
||||
/// retryable 503 rather than a hang. The shed remains for real overload; it just stops firing on
|
||||
/// the ordinary case.
|
||||
const BCRYPT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The 503 both bcrypt paths shed with.
|
||||
fn bcrypt_busy() -> AppError {
|
||||
tracing::warn!("bcrypt queue saturated; shedding an auth request");
|
||||
AppError::ServiceUnavailable(
|
||||
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
|
||||
Some(5),
|
||||
)
|
||||
}
|
||||
|
||||
async fn verify_password(candidate: String, hash: String) -> Result<bool, AppError> {
|
||||
verify_password_within(candidate, hash, BCRYPT_ACQUIRE_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// [`verify_password`] with an explicit ceiling on how long it will queue for a hash permit.
|
||||
///
|
||||
/// The short-timeout variant is what lets an over-budget caller still be *served* rather than
|
||||
/// refused outright: the CPU bound is the semaphore, so shedding on a brief acquire timeout caps
|
||||
/// the work just as hard as a rate bucket does — but it sheds whoever happens to arrive while the
|
||||
/// permits are busy, instead of categorically refusing an IP that a flooder shares with the victim.
|
||||
async fn verify_password_within(
|
||||
candidate: String,
|
||||
hash: String,
|
||||
acquire_timeout: Duration,
|
||||
) -> Result<bool, AppError> {
|
||||
// Bounded wait — see BCRYPT_ACQUIRE_TIMEOUT. `acquire()` itself only fails if the semaphore is
|
||||
// closed, which never happens here; the timeout is the case we care about.
|
||||
let _permit = tokio::time::timeout(acquire_timeout, BCRYPT_PERMITS.acquire())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
.map_err(|_| bcrypt_busy())?;
|
||||
Ok(
|
||||
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
/// Hash a secret on the blocking pool. Same reasoning as [`verify_password`] — and this one
|
||||
/// runs on the busiest auth path there is, since every guest who joins gets a PIN hashed.
|
||||
pub async fn hash_password(secret: String, cost: u32) -> Result<String, AppError> {
|
||||
// Same global ceiling as `verify_password` — `/join` hashes a PIN for every guest, and 100
|
||||
// guests scanning the QR at once is the arrival burst this box has to survive.
|
||||
let _permit = BCRYPT_PERMITS.acquire().await;
|
||||
// guests scanning the QR at once is the arrival burst this box has to survive. Same bounded
|
||||
// wait, too: hanging on the arrival path is the worst place to hang.
|
||||
let _permit = tokio::time::timeout(BCRYPT_ACQUIRE_TIMEOUT, BCRYPT_PERMITS.acquire())
|
||||
.await
|
||||
.map_err(|_| bcrypt_busy())?;
|
||||
tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
|
||||
@@ -341,30 +636,103 @@ pub async fn recover(
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
|
||||
let name_ceiling = config::get_usize(
|
||||
&state.config_cache,
|
||||
"recover_name_rate_per_15min",
|
||||
RECOVER_NAME_CEILING_DEFAULT,
|
||||
)
|
||||
.await
|
||||
// CLAMPED, not merely defaulted — see RECOVER_NAME_CEILING_MAX. The config value is
|
||||
// operator-settable and the invariant it has to respect is not expressible in
|
||||
// `patch_config`'s numeric range, so it is enforced at the point of use.
|
||||
.min(RECOVER_NAME_CEILING_MAX);
|
||||
let name_key = display_name.to_lowercase();
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("recover:{ip}:{name_key}"),
|
||||
name_ceiling,
|
||||
Duration::from_secs(15 * 60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The per-(IP, name) tier. Same treatment as the cross-name tier below, and for the same
|
||||
// reason: this bucket is keyed on an IP that behind the venue's NAT is the ENTIRE PARTY, so a
|
||||
// gate that refuses the request outright refuses it for everyone who shares that name key.
|
||||
//
|
||||
// It used to be a `check_with_retry` sitting here, before the account was looked up — which
|
||||
// means it charged EVERY request, including successful recoveries, and refused before any PIN
|
||||
// was verified. Ceiling is clamped to RECOVER_NAME_CEILING_MAX (4). So four POSTs naming
|
||||
// "Braut Sophie" with PIN 0000 locked Sophie out of her own recovery for fifteen minutes, WITH
|
||||
// THE CORRECT PIN, from any phone on the venue wifi — and four more every fifteen minutes
|
||||
// sustained it indefinitely, at a request rate far under every volume ceiling above. The
|
||||
// benign version needs no attacker: the host mistypes their own 4-digit PIN four times.
|
||||
//
|
||||
// Now it counts FAILURES only, and a spent budget changes what a failure answers rather than
|
||||
// refusing outright. A correct PIN always authenticates. Guessing is bounded exactly as before
|
||||
// — wrong PINs are what spend the budget — with the per-account 3-strike lockout underneath it.
|
||||
let name_key = display_name.to_lowercase();
|
||||
let name_bucket = format!("recover_name_fail:{ip}:{name_key}");
|
||||
let name_ceiling = if rate_limits_on && recover_rate_on {
|
||||
Some(
|
||||
config::get_usize(
|
||||
&state.config_cache,
|
||||
"recover_name_rate_per_15min",
|
||||
RECOVER_NAME_CEILING_DEFAULT,
|
||||
)
|
||||
.await
|
||||
// CLAMPED, not merely defaulted — see RECOVER_NAME_CEILING_MAX. The config value is
|
||||
// operator-settable and the invariant it has to respect is not expressible in
|
||||
// `patch_config`'s numeric range, so it is enforced at the point of use.
|
||||
.min(RECOVER_NAME_CEILING_MAX),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let name_budget_spent: Option<u64> = name_ceiling.and_then(|max| {
|
||||
state
|
||||
.rate_limiter
|
||||
.peek(&name_bucket, max, RECOVER_NAME_WINDOW)
|
||||
.err()
|
||||
});
|
||||
// Charged on the way OUT of a failure, exactly like `charge_recover_failure`.
|
||||
let charge_name_failure = || {
|
||||
if let Some(max) = name_ceiling {
|
||||
let _ =
|
||||
state
|
||||
.rate_limiter
|
||||
.check_with_retry(name_bucket.clone(), max, RECOVER_NAME_WINDOW);
|
||||
}
|
||||
};
|
||||
let name_refusal = |retry_after_secs: u64| {
|
||||
AppError::TooManyRequests(
|
||||
"Zu viele fehlgeschlagene Versuche für diesen Namen. Bitte warte 15 Minuten.".into(),
|
||||
Some(retry_after_secs),
|
||||
)
|
||||
};
|
||||
|
||||
// The cross-name tier (H2). Read-only here — budget is spent only by an actual wrong PIN
|
||||
// below — so a venue full of guests recovering their own devices never trips it, while a
|
||||
// horizontal sweep across the public name list runs out after RECOVER_IP_FAILURE_CEILING.
|
||||
//
|
||||
// Placed before any bcrypt work so an exhausted IP also stops consuming the hash permits.
|
||||
// A SPENT BUDGET NO LONGER REFUSES THE REQUEST OUTRIGHT — it only changes what a FAILURE
|
||||
// answers. This gate used to `return` here, before the account was even looked up, and that
|
||||
// handed any guest a venue-wide denial of service.
|
||||
//
|
||||
// The bucket is keyed on IP, and behind the venue's NAT that is one address for the entire
|
||||
// party. Thirty POSTs with invented names — each one landing in the `users.is_empty()` branch
|
||||
// below, which charged unconditionally — spent the shared budget for fifteen minutes, and ~2
|
||||
// requests/minute sustained it indefinitely. Everyone at the party was then refused PIN
|
||||
// recovery WITH THE CORRECT PIN. The host is the one who cannot absorb that: hosts are
|
||||
// promoted guests whose only credential is a 4-digit PIN, so `/recover` is their only way back
|
||||
// in after losing a session, and the constants here have no config key to turn off.
|
||||
//
|
||||
// So an exhausted budget is carried as a flag: a correct PIN still authenticates, while every
|
||||
// wrong one answers 429 instead of 401. The guessing itself stays bounded where it always
|
||||
// really was — the per-(IP,name) ceiling, and the per-account 3-strike lockout that no
|
||||
// attacker on any IP can evade.
|
||||
let ip_budget_spent: Option<u64> = if rate_limits_on && recover_rate_on {
|
||||
state
|
||||
.rate_limiter
|
||||
.peek(
|
||||
&format!("recover_fail:{ip}"),
|
||||
RECOVER_IP_FAILURE_CEILING,
|
||||
RECOVER_IP_FAILURE_WINDOW,
|
||||
)
|
||||
.err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sweep_refusal = |retry_after_secs: u64| {
|
||||
AppError::TooManyRequests(
|
||||
"Zu viele fehlgeschlagene Versuche von diesem Netzwerk. Bitte warte 15 Minuten.".into(),
|
||||
Some(retry_after_secs),
|
||||
)
|
||||
};
|
||||
|
||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
@@ -378,6 +746,16 @@ pub async fn recover(
|
||||
// timing. Display names are already public on the feed, but this still closes
|
||||
// the /recover enumeration + timing oracle.
|
||||
let _ = verify_password(body.pin.clone(), dummy_pin_hash().to_string()).await;
|
||||
// Charged here too, or the cheapest sweep (guessing names that don't exist) would be free
|
||||
// — and the whole point of the tier is that guessing costs the guesser something.
|
||||
charge_recover_failure(&state, &ip);
|
||||
charge_name_failure();
|
||||
if let Some(retry_after_secs) = name_budget_spent {
|
||||
return Err(name_refusal(retry_after_secs));
|
||||
}
|
||||
if let Some(retry_after_secs) = ip_budget_spent {
|
||||
return Err(sweep_refusal(retry_after_secs));
|
||||
}
|
||||
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
||||
}
|
||||
|
||||
@@ -401,7 +779,7 @@ pub async fn recover(
|
||||
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||
}
|
||||
|
||||
let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await;
|
||||
let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await?;
|
||||
|
||||
if pin_matches {
|
||||
// Reset failed attempts on success
|
||||
@@ -426,7 +804,10 @@ pub async fn recover(
|
||||
}));
|
||||
}
|
||||
|
||||
// Wrong PIN — increment failure count
|
||||
// Wrong PIN — charge both the per-account counter and this IP's cross-name budget. The
|
||||
// account counter alone never fires against a sweep that only spends 4 guesses per name.
|
||||
charge_recover_failure(&state, &ip);
|
||||
charge_name_failure();
|
||||
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
||||
tracing::warn!(
|
||||
user_id = %user.id,
|
||||
@@ -447,6 +828,12 @@ pub async fn recover(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(retry_after_secs) = name_budget_spent {
|
||||
return Err(name_refusal(retry_after_secs));
|
||||
}
|
||||
if let Some(retry_after_secs) = ip_budget_spent {
|
||||
return Err(sweep_refusal(retry_after_secs));
|
||||
}
|
||||
Err(AppError::Unauthorized("PIN ist falsch.".into()))
|
||||
}
|
||||
|
||||
@@ -464,18 +851,32 @@ pub struct AdminLoginResponse {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// Requests per minute per IP that may reach `verify_password` at all.
|
||||
/// Requests per minute per IP after which admin login is served in DEGRADED mode.
|
||||
///
|
||||
/// Not a security control — the failure bucket below is. It bounds how deep a queue can form on
|
||||
/// `BCRYPT_PERMITS`, which is what actually caps the CPU cost.
|
||||
///
|
||||
/// Still far above anything a person typing a password produces, but note the honest limitation:
|
||||
/// unlike the failure bucket, this ceiling CAN refuse a correct password, and on venue NAT every
|
||||
/// guest shares the operator's IP. It is a smaller number than it first was for exactly that
|
||||
/// reason — the earlier 120 was chosen when this was the only bound on bcrypt, which made it both
|
||||
/// too weak to cap CPU and too coarse to be safe for the operator.
|
||||
/// This used to REFUSE above the ceiling, and that made it a denial of service against the one
|
||||
/// person who cannot route around it. `/admin/login` is a public linkable page, every guest at the
|
||||
/// venue shares the operator's IP behind NAT, and the check ran before `verify_password` — so one
|
||||
/// phone posting twice a minute kept the bucket full and the operator, on that same IP, was
|
||||
/// refused WITH THE CORRECT PASSWORD. The escape hatch was circular: `admin_login_rate_enabled`
|
||||
/// is only flippable through `PATCH /admin/config`, which needs the session being refused. That
|
||||
/// locked out moderation, gallery release and every config key — including the ones that would
|
||||
/// undo it.
|
||||
///
|
||||
/// So exceeding it no longer refuses; it shortens the hash-permit wait to
|
||||
/// [`ADMIN_LOGIN_DEGRADED_ACQUIRE`]. The CPU bound is unchanged, because the CPU bound was always
|
||||
/// the semaphore and never this bucket: a flood now sheds itself on a busy semaphore, while the
|
||||
/// operator's single well-timed request still gets a permit and a truthful answer.
|
||||
const ADMIN_LOGIN_CPU_CEILING: usize = 30;
|
||||
|
||||
/// How long an over-budget admin login will queue for a hash permit before shedding with 503.
|
||||
///
|
||||
/// Short enough that a flood cannot build a queue that starves `/join`, long enough that a
|
||||
/// request arriving while two permits are mid-hash (~250 ms each) still waits its turn.
|
||||
const ADMIN_LOGIN_DEGRADED_ACQUIRE: Duration = Duration::from_secs(1);
|
||||
|
||||
pub async fn admin_login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
@@ -503,28 +904,37 @@ pub async fn admin_login(
|
||||
let admin_rate_on =
|
||||
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||
|
||||
// Part 1: a deliberately GENEROUS ceiling whose only job is to bound bcrypt CPU. Cost-12
|
||||
// verification is ~250 ms of a core, so an unbounded endpoint is a CPU exhaustion vector
|
||||
// regardless of whether anyone guesses right. No human typing a password reaches this.
|
||||
if rate_limits_on
|
||||
// Part 1: a ceiling whose only job is to bound how deep a queue can form on the hash permits.
|
||||
// Cost-12 verification is ~250 ms of a core, so an endpoint with no bound at all lets a flood
|
||||
// build a backlog that starves `/join`. Exceeding it DEGRADES rather than refuses — see
|
||||
// ADMIN_LOGIN_CPU_CEILING for why refusing here was a DoS against the operator.
|
||||
let degraded = rate_limits_on
|
||||
&& admin_rate_on
|
||||
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("admin_login_cpu:{ip}"),
|
||||
ADMIN_LOGIN_CPU_CEILING,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
&& state
|
||||
.rate_limiter
|
||||
.check_with_retry(
|
||||
format!("admin_login_cpu:{ip}"),
|
||||
ADMIN_LOGIN_CPU_CEILING,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.is_err();
|
||||
if degraded {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"admin login over its per-IP volume ceiling; serving with a shortened hash-permit wait"
|
||||
);
|
||||
}
|
||||
|
||||
let valid = verify_password(
|
||||
let valid = verify_password_within(
|
||||
body.password.clone(),
|
||||
state.config.admin_password_hash.clone(),
|
||||
if degraded {
|
||||
ADMIN_LOGIN_DEGRADED_ACQUIRE
|
||||
} else {
|
||||
BCRYPT_ACQUIRE_TIMEOUT
|
||||
},
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
if !valid {
|
||||
// Part 2: the tight bucket, charged ONLY on a wrong password. A correct password is
|
||||
@@ -753,11 +1163,23 @@ mod tests {
|
||||
#[test]
|
||||
fn a_display_name_may_not_carry_control_characters() {
|
||||
for bad in ["Anna\nERROR forged", "Anna\rX", "Anna\u{0}X", "A\u{7}B"] {
|
||||
assert!(validate_display_name(bad).is_err(), "{bad:?} must be rejected");
|
||||
assert!(
|
||||
validate_display_name(bad).is_err(),
|
||||
"{bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
// Real guests have accents, emoji and non-Latin names — never reject those.
|
||||
for good in ["Anna", "Zo\u{eb}", "Jos\u{e9}", "\u{5c71}\u{7530}", "Anna \u{1f389}"] {
|
||||
assert!(validate_display_name(good).is_ok(), "{good:?} must be allowed");
|
||||
for good in [
|
||||
"Anna",
|
||||
"Zo\u{eb}",
|
||||
"Jos\u{e9}",
|
||||
"\u{5c71}\u{7530}",
|
||||
"Anna \u{1f389}",
|
||||
] {
|
||||
assert!(
|
||||
validate_display_name(good).is_ok(),
|
||||
"{good:?} must be allowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,4 +1237,62 @@ mod tests {
|
||||
assert!(validate_display_name(" ").is_err());
|
||||
assert!(validate_display_name("bad\0name").is_err());
|
||||
}
|
||||
|
||||
/// The join key is presented pre-auth and the reply carries a SESSION for the stored row —
|
||||
/// so a key that replays for a name it did not create is an account-takeover primitive, not
|
||||
/// a convenience bug. These pin both guards.
|
||||
#[test]
|
||||
fn a_join_key_replays_only_for_the_name_that_created_it() {
|
||||
let created = Utc::now();
|
||||
// The legitimate case: same guest, same name, retrying a lost response.
|
||||
assert!(join_key_replayable("Anna", "Anna", created, created));
|
||||
// Case-insensitive, matching the LOWER(display_name) uniqueness index.
|
||||
assert!(join_key_replayable("Anna", "anna", created, created));
|
||||
assert!(join_key_replayable("Zoë", "zoë", created, created));
|
||||
|
||||
// The takeover: a held key presented with any other name must NOT resolve to the
|
||||
// stored account, whatever role that account happens to carry.
|
||||
assert!(!join_key_replayable(
|
||||
"Braut Sophie",
|
||||
"Zufaelliger Fremder",
|
||||
created,
|
||||
created
|
||||
));
|
||||
// The shared-device case a party guarantees: the next person types their own name.
|
||||
assert!(!join_key_replayable("Anna", "Bernd", created, created));
|
||||
// Not a prefix or substring match either.
|
||||
assert!(!join_key_replayable("Anna", "Anna B.", created, created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_key_stops_replaying_once_its_window_closes() {
|
||||
let created = Utc::now();
|
||||
let inside = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES - 1);
|
||||
let edge = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES);
|
||||
let outside = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES + 1);
|
||||
|
||||
assert!(join_key_replayable("Anna", "Anna", created, inside));
|
||||
// Inclusive at the boundary — an exactly-on-time retry is still the guest's own.
|
||||
assert!(join_key_replayable("Anna", "Anna", created, edge));
|
||||
// Past it the key is spent, so an abandoned attempt in localStorage stops being a
|
||||
// permanent unauthenticated credential.
|
||||
assert!(!join_key_replayable("Anna", "Anna", created, outside));
|
||||
assert!(!join_key_replayable(
|
||||
"Anna",
|
||||
"Anna",
|
||||
created,
|
||||
created + chrono::Duration::days(3)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_skewed_row_is_treated_as_fresh_not_immortal() {
|
||||
// Postgres stamps `created_at`; if its clock is ahead of ours the age is negative. That
|
||||
// must read as "just created" (replayable), never as an unbounded window.
|
||||
let now = Utc::now();
|
||||
let future = now + chrono::Duration::minutes(5);
|
||||
assert!(join_key_replayable("Anna", "Anna", future, now));
|
||||
// And the name guard still applies regardless of skew.
|
||||
assert!(!join_key_replayable("Anna", "Bernd", future, now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,49 @@ pub struct AppConfig {
|
||||
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
|
||||
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 {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||
@@ -164,10 +207,9 @@ impl AppConfig {
|
||||
Ok(Self {
|
||||
database_url,
|
||||
jwt_secret,
|
||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
.context("SESSION_EXPIRY_DAYS must be a number")?,
|
||||
session_expiry_days: parse_session_expiry_days(
|
||||
std::env::var("SESSION_EXPIRY_DAYS").ok().as_deref(),
|
||||
)?,
|
||||
admin_password_hash,
|
||||
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")?,
|
||||
|
||||
@@ -2,7 +2,11 @@ use anyhow::{Context, Result};
|
||||
use sqlx::PgPool;
|
||||
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`.
|
||||
const PG_INVALID_PASSWORD: &str = "28P01";
|
||||
@@ -47,10 +51,23 @@ fn explain_auth_failure(err: &sqlx::Error) {
|
||||
}
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
||||
// A malformed value must not silently become the default: an operator who typed
|
||||
// `DATABASE_MAX_CONNECTIONS=3O` (letter O) would otherwise get 15 with no indication,
|
||||
// and would keep tuning a knob that never took effect.
|
||||
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()
|
||||
.max_connections(max_connections)
|
||||
@@ -89,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!()
|
||||
.run(&pool)
|
||||
.run(&mut migrator_conn)
|
||||
.await
|
||||
.context("failed to run database migrations")?;
|
||||
let _ = sqlx::Connection::close(migrator_conn).await;
|
||||
|
||||
tracing::info!(max_connections, "database connected and migrations applied");
|
||||
Ok(pool)
|
||||
|
||||
@@ -11,6 +11,27 @@ pub enum AppError {
|
||||
/// (banned user, quota): the queued blob is kept and retried if the host reopens,
|
||||
/// instead of being purged like a genuinely-terminal rejection.
|
||||
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),
|
||||
Conflict(String),
|
||||
/// Second field: optional retry-after seconds to include in the response.
|
||||
@@ -35,6 +56,8 @@ impl AppError {
|
||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
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::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
@@ -52,6 +75,8 @@ impl AppError {
|
||||
| Self::Unauthorized(msg)
|
||||
| Self::Forbidden(msg)
|
||||
| Self::UploadsLocked(msg)
|
||||
| Self::GalleryReleased(msg)
|
||||
| Self::UserBanned(msg)
|
||||
| Self::NotFound(msg)
|
||||
| Self::Conflict(msg) => msg.clone(),
|
||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||
@@ -190,9 +215,8 @@ mod tests {
|
||||
AppError::ServiceUnavailable("busy".into(), Some(3)),
|
||||
] {
|
||||
let expected = match &err {
|
||||
AppError::TooManyRequests(_, Some(s)) | AppError::ServiceUnavailable(_, Some(s)) => {
|
||||
s.to_string()
|
||||
}
|
||||
AppError::TooManyRequests(_, Some(s))
|
||||
| AppError::ServiceUnavailable(_, Some(s)) => s.to_string(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let resp = err.into_response();
|
||||
|
||||
@@ -105,7 +105,7 @@ pub struct PatchConfigRequest(pub HashMap<String, String>);
|
||||
|
||||
pub async fn patch_config(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
RequireAdmin(auth): RequireAdmin,
|
||||
Json(body): Json<HashMap<String, String>>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
|
||||
@@ -289,6 +289,23 @@ pub async fn patch_config(
|
||||
// the TTL is only a backstop and must not be relied on for correctness.
|
||||
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
|
||||
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
||||
if privacy_note_changed || theme_changed {
|
||||
@@ -347,12 +364,18 @@ pub struct DownloadQuery {
|
||||
/// 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
|
||||
/// carry an `Authorization` header, so the client exchanges its Bearer token for
|
||||
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
|
||||
/// single-use, 30s-TTL store as the SSE stream.
|
||||
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Uses the same store as the SSE
|
||||
/// 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`. Optional so an older client that
|
||||
/// doesn't send it keeps working; it simply skips the pre-check it doesn't know to ask for.
|
||||
/// 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>,
|
||||
}
|
||||
@@ -374,8 +397,11 @@ pub async fn export_ticket(
|
||||
// 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: tickets are single-use with a 30s TTL and can only be
|
||||
// obtained from this authenticated endpoint, so one mint is at most one download.
|
||||
// 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
|
||||
@@ -391,15 +417,26 @@ pub async fn export_ticket(
|
||||
// `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.
|
||||
if let Some(kind) = q.kind.as_deref() {
|
||||
let export_type = match kind {
|
||||
"zip" => "zip",
|
||||
"html" => "html",
|
||||
other => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Unbekannter Export-Typ: {other}"
|
||||
)));
|
||||
}
|
||||
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."
|
||||
@@ -409,33 +446,60 @@ pub async fn export_ticket(
|
||||
resolve_export_file(&state, export_type, msg).await?;
|
||||
}
|
||||
|
||||
enforce_export_rate(&state, auth.user_id).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, the iframe navigated to `?ticket=null`, and one of the guest's three DAILY
|
||||
// downloads had already been charged above. That is precisely the phantom-success failure
|
||||
// this endpoint's pre-validation was added to eliminate, arriving through the other door.
|
||||
// 503 + Retry-After, matching how `sse::issue_ticket` answers the identical condition.
|
||||
// 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)
|
||||
.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.
|
||||
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
|
||||
/// id to key the export rate limit per-user (see `enforce_export_rate`).
|
||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
|
||||
/// Validate a download ticket and confirm its session still exists, resolving it to the user who
|
||||
/// minted it. Deliberately NOT single-use — see the note on `redeem_download` below.
|
||||
async fn authenticate_download_ticket(
|
||||
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
|
||||
.sse_tickets
|
||||
.consume(ticket, TicketKind::Download)
|
||||
.redeem_download(ticket, want)
|
||||
.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)
|
||||
.await
|
||||
@@ -446,15 +510,32 @@ async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<
|
||||
|
||||
pub async fn download_zip(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
// Ticket validation only — the rate limit was charged at mint time, where a 429 is visible
|
||||
// to the page. Charging it again here would cost every download two slots.
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
authenticate_download_ticket(
|
||||
&state,
|
||||
&q.ticket,
|
||||
crate::services::sse_tickets::ExportKind::Zip,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let path =
|
||||
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
|
||||
@@ -500,45 +581,130 @@ async fn resolve_export_file(
|
||||
|
||||
pub async fn download_html(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
// See `download_zip`: the limit is charged at ticket mint, where the client can see it.
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
authenticate_download_ticket(
|
||||
&state,
|
||||
&q.ticket,
|
||||
crate::services::sse_tickets::ExportKind::Html,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let path =
|
||||
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(
|
||||
path: std::path::PathBuf,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
range_header: Option<&str>,
|
||||
if_range_header: Option<&str>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
use crate::handlers::upload::{RangeSpec, parse_range};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Response, StatusCode, header};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(&path)
|
||||
let mut file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let metadata = file
|
||||
let len = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let stream = ReaderStream::new(file);
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.len();
|
||||
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.header(header::CONTENT_LENGTH, metadata.len())
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
// A validator that CHANGES when the archive does, so a resume cannot splice two generations.
|
||||
//
|
||||
// The on-disk name is `{prefix}.{event_id}.{epoch}.zip`, so it already identifies the exact
|
||||
// generation; length distinguishes a rebuild at the same epoch. Together they are a strong
|
||||
// validator.
|
||||
//
|
||||
// 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)
|
||||
|
||||
@@ -44,7 +44,6 @@ pub struct EventStatus {
|
||||
pub disk_low: bool,
|
||||
}
|
||||
|
||||
|
||||
/// Is free space low enough that the host needs to know?
|
||||
///
|
||||
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
|
||||
@@ -58,7 +57,8 @@ pub struct EventStatus {
|
||||
/// 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`;
|
||||
/// 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
|
||||
@@ -67,7 +67,14 @@ pub struct EventStatus {
|
||||
/// 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 {
|
||||
let gate_closes_at = keepsake_required.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
|
||||
// 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
|
||||
@@ -80,8 +87,15 @@ fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
||||
/// 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
|
||||
/// 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(
|
||||
state: &AppState,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
event_id: Uuid,
|
||||
excluding: Uuid,
|
||||
) -> Result<i64, AppError> {
|
||||
@@ -92,11 +106,36 @@ async fn remaining_operators(
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(excluding)
|
||||
.fetch_one(&state.pool)
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
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)]
|
||||
pub struct SetRoleRequest {
|
||||
pub role: String,
|
||||
@@ -193,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
|
||||
// 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
|
||||
@@ -215,6 +246,21 @@ pub async fn ban_user(
|
||||
//
|
||||
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
|
||||
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(
|
||||
"UPDATE \"user\"
|
||||
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
||||
@@ -256,6 +302,19 @@ pub async fn 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)
|
||||
}
|
||||
|
||||
@@ -314,12 +373,40 @@ pub async fn unban_user(
|
||||
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!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
event_id = %auth.event_id,
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -420,21 +507,26 @@ pub async fn set_role(
|
||||
|
||||
// Floor: demoting the last non-banned host/admin to guest would leave the event with
|
||||
// no operator. Refuse.
|
||||
if new_role == "guest"
|
||||
&& target.0 == "host"
|
||||
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
|
||||
));
|
||||
//
|
||||
// The check and the UPDATE are ONE transaction, behind the operator lock — see
|
||||
// `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(
|
||||
"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")
|
||||
.bind(user_id)
|
||||
.bind(new_role)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
@@ -443,6 +535,19 @@ pub async fn set_role(
|
||||
new_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)
|
||||
}
|
||||
|
||||
@@ -535,6 +640,19 @@ pub async fn 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 }))
|
||||
}
|
||||
|
||||
@@ -611,8 +729,13 @@ pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRege
|
||||
state.config.comments_enabled,
|
||||
// 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
|
||||
// a full archive. See export::REGEN_DEBOUNCE.
|
||||
crate::services::export::REGEN_DEBOUNCE,
|
||||
// a full archive.
|
||||
//
|
||||
// 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.config.media_path.clone(),
|
||||
state.config.export_path.clone(),
|
||||
@@ -633,7 +756,9 @@ pub async fn host_delete_upload(
|
||||
// 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).
|
||||
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 {
|
||||
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
||||
}
|
||||
@@ -660,6 +785,19 @@ pub async fn 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)
|
||||
}
|
||||
|
||||
@@ -697,12 +835,25 @@ pub async fn host_delete_comment(
|
||||
comment_id = %comment_id,
|
||||
"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)
|
||||
}
|
||||
|
||||
pub async fn close_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
RequireHost(auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
||||
@@ -717,12 +868,27 @@ pub async fn close_event(
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn open_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
RequireHost(auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// 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.
|
||||
@@ -748,12 +914,27 @@ pub async fn open_event(
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn release_gallery(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
RequireHost(auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// 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:
|
||||
@@ -809,6 +990,20 @@ pub async fn release_gallery(
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
// 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(
|
||||
event_id,
|
||||
event_name,
|
||||
@@ -821,13 +1016,32 @@ pub async fn release_gallery(
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::disk_is_low;
|
||||
use crate::handlers::upload::DISK_RESERVE_BYTES;
|
||||
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;
|
||||
@@ -870,7 +1084,8 @@ mod tests {
|
||||
// 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;
|
||||
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!(
|
||||
@@ -891,6 +1106,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() {
|
||||
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
|
||||
@@ -905,7 +1157,7 @@ mod tests {
|
||||
// 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;
|
||||
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));
|
||||
@@ -914,8 +1166,9 @@ mod tests {
|
||||
#[test]
|
||||
fn an_empty_gallery_still_reserves_room_for_postgres() {
|
||||
// With no gallery the keepsake term is 0, so the warn threshold collapses to
|
||||
// 1.25 x DISK_RESERVE_BYTES (12.5 GB), which dominates the 10 GB absolute floor.
|
||||
assert!(!disk_is_low(13 * GB, 0));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,3 +121,200 @@ pub async fn get_context(
|
||||
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_primary: 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
|
||||
@@ -40,5 +49,6 @@ pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEvent
|
||||
.await,
|
||||
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
|
||||
.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
|
||||
// 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.
|
||||
// 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>(
|
||||
"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)
|
||||
.fetch_one(&state.pool)
|
||||
@@ -222,8 +231,13 @@ pub async fn add_comment(
|
||||
// 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
|
||||
// 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>(
|
||||
"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)
|
||||
.fetch_one(&state.pool)
|
||||
|
||||
@@ -53,12 +53,15 @@ pub async fn issue_ticket(
|
||||
));
|
||||
}
|
||||
|
||||
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 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()")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
@@ -68,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
|
||||
/// [`issue_ticket`]) — never the raw JWT.
|
||||
pub async fn stream(
|
||||
@@ -88,6 +142,17 @@ pub async fn stream(
|
||||
.map_err(|e| AppError::Internal(e.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 events = BroadcastStream::new(rx).filter_map(|msg| match msg {
|
||||
Ok(sse_event) => Some(Ok(Event::default()
|
||||
@@ -113,6 +178,10 @@ pub async fn stream(
|
||||
let pool = state.pool.clone();
|
||||
let session_hash = token_hash.clone();
|
||||
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));
|
||||
ticker.tick().await; // consume the immediate first tick
|
||||
loop {
|
||||
|
||||
@@ -61,7 +61,7 @@ pub async fn truncate_all(
|
||||
('upload_rate_per_hour', '100'),
|
||||
('feed_rate_per_min', '60'),
|
||||
('export_rate_per_day', '3'),
|
||||
('join_ip_rate_per_min', '60'),
|
||||
('join_ip_rate_per_min', '300'),
|
||||
('recover_ip_rate_per_min', '30'),
|
||||
('social_rate_per_min', '120'),
|
||||
('quota_tolerance', '0.75'),
|
||||
|
||||
@@ -54,9 +54,7 @@ async fn read_text_field_bounded(
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?
|
||||
{
|
||||
if buf.len() + chunk.len() > max_bytes {
|
||||
return Err(AppError::BadRequest(
|
||||
"Eingabe ist zu lang.".to_string(),
|
||||
));
|
||||
return Err(AppError::BadRequest("Eingabe ist zu lang.".to_string()));
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
@@ -170,8 +168,51 @@ const ALLOWED_MEDIA: &[(&str, &str)] = &[
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
headers: axum::http::HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
||||
// REPLAY FIRST — before the rate limit, before the ban check, before the lock/release gate.
|
||||
//
|
||||
// The idempotency key also arrives as a multipart FIELD, and there is a replay for it further
|
||||
// down; but a field cannot be read until the body is being parsed, which is after every gate
|
||||
// below. So the field's replay was unreachable in exactly the situation it matters most:
|
||||
//
|
||||
// the photo committed, the response was lost on the way back (the flaky-wifi failure this
|
||||
// whole mechanism exists for), the host released the gallery at the end of the night, and
|
||||
// the phone's retry then answered `gallery_released` — telling the guest a photo that is
|
||||
// ALREADY IN THE GALLERY had not been sent.
|
||||
//
|
||||
// And the remedy that error suggests is destructive: `open_event` clears `export_released_at`
|
||||
// and BUMPS `export_epoch`, retiring the whole keepsake generation and forcing a multi-GB
|
||||
// rebuild on a 2-vCPU box at midnight — to re-send a photo that was never missing.
|
||||
//
|
||||
// A header arrives with the request line, so the answer is knowable before anything is
|
||||
// decided. Charging the hourly rate limit for a retry of an already-stored photo was the same
|
||||
// mistake one layer up: a 40-photo burst with two retries each exhausted the hour for uploads
|
||||
// that committed the first time.
|
||||
//
|
||||
// The body is still DRAINED rather than abandoned — see `drain_multipart`: replying before
|
||||
// reading the body makes the proxy see a broken pipe and turn a clean 200 into a 502.
|
||||
if let Some(cid) = headers
|
||||
.get("x-client-upload-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| Uuid::parse_str(v.trim()).ok())
|
||||
&& let Some(existing) =
|
||||
Upload::find_by_client_upload_id(&state.pool, auth.user_id, cid).await?
|
||||
{
|
||||
drain_multipart(multipart).await;
|
||||
let uploader_name = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.map(|u| u.display_name)
|
||||
.unwrap_or_default();
|
||||
tracing::info!(
|
||||
client_upload_id = %cid, upload_id = %existing.id,
|
||||
"upload retry replayed from the header key, before the lock/release gate"
|
||||
);
|
||||
let dto = replay_upload_dto(&state, &existing, &uploader_name).await;
|
||||
return Ok((StatusCode::OK, Json(dto)));
|
||||
}
|
||||
|
||||
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
||||
@@ -197,29 +238,47 @@ pub async fn upload(
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
if user.is_banned {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||
// `UserBanned`, not `Forbidden`: a ban is reversible, so the client must KEEP the queued
|
||||
// blob and park it until `user-shown` arrives. Under the generic `forbidden` code it
|
||||
// purged the photo from IndexedDB and moved the row to `blocked`, which has no retry
|
||||
// button — so an unban restored everything except whatever was in flight.
|
||||
return Err(AppError::UserBanned("Du bist gesperrt.".into()));
|
||||
}
|
||||
|
||||
// Check if uploads are locked
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
if event.uploads_locked_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
// Reversible: a host can reopen the event, so the client keeps the queued blob and
|
||||
// retries on `event-opened` rather than purging it (UploadsLocked, not Forbidden).
|
||||
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
|
||||
// released the export has been snapshotted, so a late upload could never make it into
|
||||
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
|
||||
// Also reversible (reopen clears `export_released_at`), so likewise UploadsLocked.
|
||||
// RELEASE IS CHECKED FIRST, AND THE ORDER IS THE WHOLE POINT.
|
||||
//
|
||||
// `release ⇒ lock`, so a released gallery satisfies BOTH conditions. Testing the lock first
|
||||
// made this branch unreachable: every post-release upload — the overwhelmingly common case,
|
||||
// since release is the end-of-event action every guest's queue runs into — answered
|
||||
// `uploads_locked`, and the `GalleryReleased` arm below was dead code that read as if it
|
||||
// worked. The commit-time re-check further down splits the two correctly, so the two paths
|
||||
// also disagreed about the same event state depending on where the upload was intercepted.
|
||||
//
|
||||
// The codes are not interchangeable to the client (see upload-queue.ts): `uploads_locked`
|
||||
// charges an attempt and re-pushes the whole photo on the backoff ladder, and tells the guest
|
||||
// to find it via the camera button. `gallery_released` PARKS it — no attempt charged, no
|
||||
// re-push — and says the photo is safe but needs the hosts to reopen the gallery. Against an
|
||||
// answer that cannot change on its own, the first is a cellular data leak with a misleading
|
||||
// message attached.
|
||||
//
|
||||
// Both keep the blob; both are cleared by `event-opened`. Only the retry behaviour differs.
|
||||
if event.export_released_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::UploadsLocked(
|
||||
"Galerie wurde bereits freigegeben.".into(),
|
||||
return Err(AppError::GalleryReleased(
|
||||
"Die Galerie ist abgeschlossen — es können keine neuen Fotos mehr hinzugefügt werden."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if event.uploads_locked_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
// A PLAIN lock (the host paused uploads mid-event) is the reversible-and-likely-soon case,
|
||||
// so auto-retry is right here: the client keeps the blob and resumes on `event-opened`.
|
||||
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
|
||||
// Read config limits from DB
|
||||
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
|
||||
@@ -287,20 +346,16 @@ pub async fn upload(
|
||||
// 10-20 GB of `.tmp` on a 40 GB volume that the gate cannot see, eating the
|
||||
// reserve that keeps Postgres able to write WAL. The permit is held until the
|
||||
// handler returns, which is exactly as long as the temp file can exist.
|
||||
_admission = Some(
|
||||
state
|
||||
.upload_admission
|
||||
.reserve(cap_bytes)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
AppError::ServiceUnavailable(
|
||||
"Gerade laden sehr viele Gäste hoch. Dein Foto bleibt in der \
|
||||
_admission = Some(state.upload_admission.reserve(cap_bytes).await.ok_or_else(
|
||||
|| {
|
||||
AppError::ServiceUnavailable(
|
||||
"Gerade laden sehr viele Gäste hoch. Dein Foto bleibt in der \
|
||||
Warteschlange und wird gleich automatisch gesendet."
|
||||
.into(),
|
||||
Some(30),
|
||||
)
|
||||
})?,
|
||||
);
|
||||
.into(),
|
||||
Some(30),
|
||||
)
|
||||
},
|
||||
)?);
|
||||
tokio::fs::create_dir_all(&originals_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
@@ -513,10 +568,19 @@ pub async fn upload(
|
||||
// `media_total` is the opposite: it is a sum of committed DB rows, and this upload's row
|
||||
// does not exist yet, so the prospective total does need `+ size`.
|
||||
let free = disk.free as i64;
|
||||
let media_after = state.media_total.get(&state.pool).await.saturating_add(size);
|
||||
let media_after = state
|
||||
.media_total
|
||||
.get(&state.pool, &state.config.event_slug)
|
||||
.await
|
||||
.saturating_add(size);
|
||||
let keepsake_needs =
|
||||
crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64;
|
||||
let required = keepsake_needs.saturating_add(DISK_RESERVE_BYTES);
|
||||
// Strictly more than the export preflight requires — see `UPLOAD_GATE_HEADROOM_BYTES`.
|
||||
// Matching it exactly meant the preflight was already at its limit the moment uploads
|
||||
// stopped, so the night's remaining writes decided whether the keepsake could be built.
|
||||
let required = keepsake_needs
|
||||
.saturating_add(DISK_RESERVE_BYTES)
|
||||
.saturating_add(UPLOAD_GATE_HEADROOM_BYTES);
|
||||
if free < required {
|
||||
tracing::error!(
|
||||
free_bytes = free,
|
||||
@@ -524,6 +588,7 @@ pub async fn upload(
|
||||
media_after,
|
||||
keepsake_needs,
|
||||
reserve = DISK_RESERVE_BYTES,
|
||||
headroom = UPLOAD_GATE_HEADROOM_BYTES,
|
||||
"refusing upload: it would leave too little room to build the keepsake"
|
||||
);
|
||||
return Err(AppError::QuotaExceeded(
|
||||
@@ -533,10 +598,27 @@ pub async fn upload(
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Failing OPEN when the disk can't be read is deliberate and matches the per-user quota
|
||||
// below: refusing every upload because a `statfs` failed would be a worse outage than the
|
||||
// one being guarded against.
|
||||
//
|
||||
// But it must not be SILENT. `snapshot` returns `None` when `select_disk` finds neither a
|
||||
// mount that prefixes the media path nor a `/` entry — and inside a container `/` is an
|
||||
// overlay rather than a `/dev` device, so this is a real possibility rather than a
|
||||
// theoretical one. When it happens, the ONLY global disk bound in the app is gone, the
|
||||
// per-user quota fails open through the same `None`, and the box fills to 100% — at which
|
||||
// point Postgres cannot write WAL and the whole event stops, with nothing having warned
|
||||
// anyone. The export preflight already warns on the identical condition; this is the
|
||||
// louder of the two paths and had no log line at all.
|
||||
//
|
||||
// Rate-limited by the disk cache's own TTL, so this cannot spam the log per upload.
|
||||
tracing::warn!(
|
||||
media_path = %state.config.media_path.display(),
|
||||
"disk usage unreadable — the global free-space gate is INACTIVE and uploads are \
|
||||
proceeding unbounded; check the admin stats page for a plausible free-space figure"
|
||||
);
|
||||
}
|
||||
// Failing OPEN when the disk can't be read is deliberate and matches the per-user quota
|
||||
// below: refusing every upload because a `statfs` failed would be a worse outage than the
|
||||
// one being guarded against.
|
||||
|
||||
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
|
||||
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
|
||||
@@ -616,7 +698,19 @@ pub async fn upload(
|
||||
.bind(auth.event_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if locked_at.is_some() || released_at.is_some() {
|
||||
// Same order as the fast-path check above, and for the same reason: `release ⇒ lock`, so
|
||||
// testing the lock first would collapse a release into `uploads_locked` and set the client
|
||||
// auto-retrying a photo that can never be accepted until a host reopens the gallery. A
|
||||
// guest who lost the race with `release_gallery` must get `gallery_released` so the queue
|
||||
// parks it instead.
|
||||
if released_at.is_some() {
|
||||
return Err(AppError::GalleryReleased(
|
||||
"Die Galerie ist abgeschlossen — es können keine neuen Fotos mehr hinzugefügt \
|
||||
werden."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if locked_at.is_some() {
|
||||
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
|
||||
@@ -677,7 +771,47 @@ pub async fn upload(
|
||||
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
|
||||
Hashtag::link_to_upload(&mut *tx, upload.id, h.id).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
// Hand the bytes to the row BEFORE committing, not after.
|
||||
//
|
||||
// `tx.commit().await` is a suspension point, and a COMMIT already written to the
|
||||
// socket is applied by Postgres whether or not this future lives to read the reply.
|
||||
// Disarming afterwards left a real window: the guest walks out of range mid-commit,
|
||||
// axum drops the future, Postgres commits the row anyway, and `Drop` deletes the file
|
||||
// that freshly committed row points at. The result is invisible to every repair path
|
||||
// — the row is live so the deleted-media sweep skips it, the file is gone so the
|
||||
// orphan sweep skips it — and it is missing from the keepsake with nothing in the log
|
||||
// naming it as loss.
|
||||
//
|
||||
// Disarming first cannot fix the cancellation (nothing in-process can), but it moves
|
||||
// the failure to the recoverable side: if we are dropped mid-commit the bytes leak,
|
||||
// and leaked bytes under a final name are exactly what the orphan sweeper reclaims.
|
||||
// A committed row whose file we deleted is unrecoverable. Prefer the leak.
|
||||
file_guard.disarm();
|
||||
if let Err(e) = tx.commit().await {
|
||||
// Deliberately do NOT re-arm the guard here.
|
||||
//
|
||||
// A `commit()` that returns `Err` is INDETERMINATE, not "definitely rolled back".
|
||||
// sqlx writes `COMMIT` to the socket and awaits the reply; if the connection dies
|
||||
// after Postgres flushed the WAL record but before that reply arrives (a db
|
||||
// restart, a killed backend, a network blip), the row is durably committed and we
|
||||
// are told it failed. Re-arming would then delete the file a live row points at —
|
||||
// the exact unrecoverable case the comment above says to avoid, just reached
|
||||
// through the error path instead of the cancellation path.
|
||||
//
|
||||
// It is worse than it sounds, because the client retries: the idempotency fast
|
||||
// path finds the committed row, answers 200, and the phone purges the only other
|
||||
// copy of the photo. So we prefer the leak in both directions. If the commit
|
||||
// genuinely did not apply, `sweep_orphan_originals` reclaims the bytes on its next
|
||||
// pass (it deletes files with no DB row, which is precisely this case).
|
||||
tracing::error!(
|
||||
error = ?e,
|
||||
path = %absolute_path.display(),
|
||||
"upload commit returned an error; leaving the file in place because the commit \
|
||||
may still have applied — the orphan sweeper reclaims it if it did not"
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok(upload)
|
||||
}
|
||||
.await;
|
||||
@@ -687,6 +821,9 @@ pub async fn upload(
|
||||
// and reclaims the bytes on the way out. That covers the concurrent-duplicate loser below
|
||||
// as well as the plain error case, and unlike the explicit `remove_file` calls it replaces,
|
||||
// it also covers axum dropping this future instead of returning.
|
||||
//
|
||||
// The successful-commit case disarmed the guard inside the block, immediately before
|
||||
// `tx.commit()` — see the comment there for why it cannot be done out here.
|
||||
let upload = match tx_result {
|
||||
Ok(u) => u,
|
||||
// The concurrent duplicate resolved inside the transaction. The winner's row is committed;
|
||||
@@ -699,11 +836,28 @@ pub async fn upload(
|
||||
.map_err(AppError::from)?,
|
||||
None => None,
|
||||
};
|
||||
// If the winning row has vanished between the conflict and this lookup (deleted in
|
||||
// the intervening milliseconds), there is nothing to replay — report the conflict.
|
||||
let existing = existing.ok_or_else(|| {
|
||||
AppError::Conflict("Dieser Upload wurde bereits verarbeitet.".into())
|
||||
})?;
|
||||
// No live row behind the key. Two very different causes, and the guest deserves to be
|
||||
// told which: either the winning row vanished in the intervening milliseconds, or the
|
||||
// key is still held by a photo the HOST took down (migration 031), in which case the
|
||||
// refusal is the whole point and re-sending will never work.
|
||||
let existing = match existing {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let taken_down = match client_upload_id {
|
||||
Some(cid) => {
|
||||
Upload::taken_down_by_client_upload_id(&state.pool, auth.user_id, cid)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
return Err(AppError::Conflict(if taken_down {
|
||||
"Dieses Foto wurde von den Gastgebern entfernt.".into()
|
||||
} else {
|
||||
"Dieser Upload wurde bereits verarbeitet.".to_string()
|
||||
}));
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
upload_id = %existing.id,
|
||||
"concurrent duplicate upload resolved; replaying the stored row"
|
||||
@@ -713,8 +867,6 @@ pub async fn upload(
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// The committed row now references these bytes — hand ownership over.
|
||||
file_guard.disarm();
|
||||
|
||||
// Spawn compression task
|
||||
state
|
||||
@@ -771,7 +923,8 @@ pub async fn edit_upload(
|
||||
|
||||
// This endpoint had no rate limit of any kind, while every other mutating route has one.
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let edit_rate_on = config::get_bool(&state.config_cache, "upload_edit_rate_enabled", true).await;
|
||||
let edit_rate_on =
|
||||
config::get_bool(&state.config_cache, "upload_edit_rate_enabled", true).await;
|
||||
if rate_limits_on && edit_rate_on {
|
||||
let edit_rate =
|
||||
config::get_i64(&state.config_cache, "upload_edit_rate_per_min", 30).await as usize;
|
||||
@@ -908,7 +1061,9 @@ pub async fn delete_upload(
|
||||
// Atomic with the keepsake invalidation: a guest removing their own photo must have it removed
|
||||
// from the downloadable archive too, and a half-applied delete would leave it there forever.
|
||||
let mut tx = state.pool.begin().await?;
|
||||
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
||||
// `by_host: false` — the guest deleted their own photo, so the idempotency key is released and
|
||||
// a later retry of the same queue item uploads afresh rather than 409ing forever.
|
||||
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id, false).await?;
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
@@ -1108,6 +1263,31 @@ const MIN_QUOTA_LIMIT_BYTES: i64 = 500 * 1024 * 1024;
|
||||
/// the shared filesystem long after new uploads have been refused.
|
||||
pub const DISK_RESERVE_BYTES: i64 = 10_000_000_000;
|
||||
|
||||
/// Extra free space the UPLOAD gate demands on top of what the export preflight demands.
|
||||
///
|
||||
/// Both gates were computing the identical threshold — `required_free_bytes(media, 2) +
|
||||
/// DISK_RESERVE_BYTES` — which left exactly zero margin between them. The moment the gate refused
|
||||
/// its first upload, the preflight was already sitting on its own limit, so anything written
|
||||
/// between that refusal and the host tapping "Galerie freigeben" pushed the preflight under:
|
||||
///
|
||||
/// * Postgres WAL, up to `max_wal_size` (1 GB by default) before a checkpoint reclaims it
|
||||
/// * container logs, capped at 30 MB x 4 services by `docker-compose.yml`
|
||||
/// * the compression backlog still draining — ~0.9 MB of derivatives per queued photo, and the
|
||||
/// backlog is longest exactly at the end of the night
|
||||
///
|
||||
/// The failure that produces is the worst one in the app: the release COMMITS (event closed,
|
||||
/// uploads locked, epoch bumped, `event-closed` fanned out to every phone) and only then do both
|
||||
/// workers bail, at 01:00, with no second release possible and `rebuild_export` needing the same
|
||||
/// space it just failed to find. Meanwhile ~10 GB of reserve sits unused — the preflight refused
|
||||
/// on a threshold, not for want of room.
|
||||
///
|
||||
/// Giving the upload gate this much more to satisfy means it closes strictly earlier, so ordinary
|
||||
/// end-of-night writes cannot flip the preflight. The cost is roughly 0.5 GB off the media ceiling
|
||||
/// on a 40 GB box (the gate's equilibrium is `3.2 x media`, so headroom divides by 3.2), which is
|
||||
/// the trade `README.md` already argues for: refusing the 1001st upload beats discovering at 01:00
|
||||
/// that the archive can never be built.
|
||||
pub const UPLOAD_GATE_HEADROOM_BYTES: i64 = 1_500_000_000;
|
||||
|
||||
/// Pure per-user quota formula: `max(floor((free_disk * tolerance) / divisor), MIN)`.
|
||||
///
|
||||
/// `divisor` is the LARGER of the observed uploader count and the operator's
|
||||
@@ -1141,7 +1321,8 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64, expe
|
||||
}
|
||||
|
||||
/// Computes the per-user storage quota using
|
||||
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes =
|
||||
/// `max(floor((free_disk * tolerance) / max(active_uploaders, estimated_guest_count, 1)), 500 MiB)`
|
||||
/// — see [`quota_limit_bytes`] for the floor's exact conditions. Returns `limit_bytes =
|
||||
/// None` whenever the storage quota is currently disabled — callers should skip the
|
||||
/// check (upload handler) or hide the UI (quota endpoint).
|
||||
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||
@@ -1150,11 +1331,20 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
|
||||
|
||||
let (active_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
// Scoped to THIS event (H12). Without the filter, reusing the install for a second event
|
||||
// carried the first one's uploaders forward permanently: event one's 30 photographers stayed
|
||||
// in event two's quota divisor, silently shrinking every new guest's ceiling for a party they
|
||||
// had nothing to do with. There is no reset path anywhere in the code or the runbook, so the
|
||||
// only fix would have been hand-written SQL.
|
||||
let (active_count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT up.user_id) FROM upload up
|
||||
JOIN event e ON e.id = up.event_id
|
||||
WHERE up.deleted_at IS NULL AND e.slug = $1",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let active = active_count.max(1);
|
||||
// The operator's expected headcount, used as a FLOOR on the divisor so the ceiling doesn't
|
||||
// slide down as guests arrive — see `quota_limit_bytes`. Admin-editable at runtime.
|
||||
@@ -1196,7 +1386,7 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||
|
||||
/// Outcome of parsing a `Range` request header against a known file length.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RangeSpec {
|
||||
pub(crate) enum RangeSpec {
|
||||
/// No `Range` header, or one we deliberately don't honour (multi-range, non-`bytes`
|
||||
/// unit, malformed). RFC 9110 lets a server ignore a Range it can't process and reply
|
||||
/// 200 with the full body, which is what every one of these cases does.
|
||||
@@ -1213,7 +1403,7 @@ enum RangeSpec {
|
||||
/// Deliberately supports only the three forms a media element actually sends —
|
||||
/// `bytes=N-`, `bytes=N-M`, `bytes=-S` (suffix) — and treats everything else as `Full`.
|
||||
/// Multi-range responses need `multipart/byteranges`, which no `<video>` requires.
|
||||
fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
|
||||
pub(crate) fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
|
||||
let Some(raw) = header else {
|
||||
return RangeSpec::Full;
|
||||
};
|
||||
@@ -1358,6 +1548,41 @@ async fn stream_media_file(
|
||||
/// soft-deleted and ban-hidden uploads (via `find_visible_media`) so moderation actually
|
||||
/// removes access to content. Preview and thumbnail variants are gated the same way (see
|
||||
/// [`get_preview`] / [`get_thumbnail`]).
|
||||
/// NO per-IP rate limit on this route, deliberately — a 600/min ceiling was added here and had to
|
||||
/// come back out.
|
||||
///
|
||||
/// The reasoning that put it in was that `/original` serves "100 guests occasionally tapping
|
||||
/// 'Original anzeigen'", so a venue-wide 10/s could only ever catch a scraper. That is not what
|
||||
/// this route is. `pickMediaUrl` (frontend/src/lib/data-mode-store.ts) resolves to
|
||||
/// `preview_url ?? thumbnail_url ?? /original`, and a freshly committed upload has BOTH derivatives
|
||||
/// null until the compression worker reaches it — at `COMPRESSION_WORKER_CONCURRENCY=2` that is
|
||||
/// minutes during a post-ceremony burst. So `/original` IS the feed's hot path for exactly the
|
||||
/// newest photos, in a newest-first grid, at the busiest moment; `VirtualFeed.svelte` says as much
|
||||
/// where it explains its broken-tile retry.
|
||||
///
|
||||
/// With every guest behind one NAT address the bucket is venue-wide: ~6 new photos fanned out by
|
||||
/// `upload-new` to ~100 open feeds exhausts 600 on its own, and then every original fetch from
|
||||
/// anyone at the party 429s for the rest of the window. The tiles' own 4-second retry uses a fresh
|
||||
/// `?r=` nonce, so the clients then hold the bucket saturated themselves. The whole venue watches
|
||||
/// the newest photos render as broken tiles, and the projector starts skipping slides.
|
||||
///
|
||||
/// A per-IP bucket cannot separate "one scraper" from "the entire party" when they share an
|
||||
/// address, and these four media routes are unauthenticated by design (an `<img>` cannot send a
|
||||
/// bearer token), so there is no per-user key to move to. The certain harm outweighed the
|
||||
/// speculative protection.
|
||||
///
|
||||
/// BE HONEST ABOUT WHAT REPLACED IT: nothing did. This used to say "bandwidth abuse belongs at the
|
||||
/// proxy, where per-connection limits still work", which reads as though a control exists there.
|
||||
/// It does not — the `Caddyfile` sets timeouts and no rate or concurrency directive, and the tower
|
||||
/// stack is `TraceLayer` alone. So this route is unbounded, deliberately, and the cost is real
|
||||
/// rather than theoretical: `no-store` below plus the feed's fallback to `/original` for any photo
|
||||
/// whose derivatives are still compressing plus `VirtualFeed`'s nonce'd retry means a hundred open
|
||||
/// feeds can re-fetch full-resolution originals off the same disk Postgres writes WAL to, each one
|
||||
/// also holding a connection from a 15-slot pool that upload commits are competing for.
|
||||
///
|
||||
/// If that needs bounding, the shape that fits is a concurrency semaphore over media streaming
|
||||
/// (like `upload_admission`), NOT a request-rate bucket — the venue is one IP, which is what made
|
||||
/// the previous attempt a self-inflicted outage.
|
||||
pub async fn get_original(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -1652,7 +1877,8 @@ mod tests {
|
||||
while media < USABLE {
|
||||
media += step;
|
||||
let free = USABLE - media;
|
||||
if free >= crate::services::export::required_free_bytes(media as u64, 2) as i64 + reserve
|
||||
if free
|
||||
>= crate::services::export::required_free_bytes(media as u64, 2) as i64 + reserve
|
||||
{
|
||||
ceiling = media;
|
||||
}
|
||||
@@ -1672,7 +1898,8 @@ mod tests {
|
||||
let over = ceiling + step;
|
||||
let free_over = USABLE - over;
|
||||
assert!(
|
||||
free_over < crate::services::export::required_free_bytes(over as u64, 2) as i64 + reserve,
|
||||
free_over
|
||||
< crate::services::export::required_free_bytes(over as u64, 2) as i64 + reserve,
|
||||
"the gate should already be closed one step past the ceiling"
|
||||
);
|
||||
|
||||
@@ -1719,8 +1946,8 @@ mod tests {
|
||||
);
|
||||
|
||||
// Global: the keepsake needs both halves plus the reserve, and they no longer fit.
|
||||
let required =
|
||||
crate::services::export::required_free_bytes(media as u64, 2) as i64 + DISK_RESERVE_BYTES;
|
||||
let required = crate::services::export::required_free_bytes(media as u64, 2) as i64
|
||||
+ DISK_RESERVE_BYTES;
|
||||
assert!(
|
||||
free < required,
|
||||
"the global gate must already be closed at {media} bytes of media: free {free} \
|
||||
@@ -1771,7 +1998,10 @@ mod tests {
|
||||
fn dropping_an_armed_guard_reclaims_the_file() {
|
||||
let p = scratch("armed.tmp");
|
||||
drop(TempFileGuard::new(p.clone()));
|
||||
assert!(!p.exists(), "an abandoned upload must not survive the request");
|
||||
assert!(
|
||||
!p.exists(),
|
||||
"an abandoned upload must not survive the request"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1780,7 +2010,10 @@ mod tests {
|
||||
let mut g = TempFileGuard::new(p.clone());
|
||||
g.disarm();
|
||||
drop(g);
|
||||
assert!(p.exists(), "a committed upload's bytes must never be deleted");
|
||||
assert!(
|
||||
p.exists(),
|
||||
"a committed upload's bytes must never be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1792,7 +2025,10 @@ mod tests {
|
||||
std::fs::remove_file(&old).unwrap();
|
||||
g.retarget(new.clone());
|
||||
drop(g);
|
||||
assert!(!new.exists(), "the final-named original is orphaned too until the row commits");
|
||||
assert!(
|
||||
!new.exists(),
|
||||
"the final-named original is orphaned too until the row commits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1807,7 +2043,7 @@ mod tests {
|
||||
/// commit transaction — which holds a FOR SHARE lock on the event row, so one request could
|
||||
/// stall every other upload behind tens of thousands of round trips.
|
||||
mod hashtag_caps {
|
||||
use super::super::{MAX_HASHTAGS_PER_UPLOAD, MAX_HASHTAG_LENGTH, normalize_tags};
|
||||
use super::super::{MAX_HASHTAG_LENGTH, MAX_HASHTAGS_PER_UPLOAD, normalize_tags};
|
||||
|
||||
#[test]
|
||||
fn a_huge_csv_is_capped_not_upserted_in_full() {
|
||||
|
||||
@@ -41,6 +41,20 @@ async fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
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?;
|
||||
|
||||
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
|
||||
@@ -86,9 +100,6 @@ async fn main() -> Result<()> {
|
||||
config.media_path.clone(),
|
||||
);
|
||||
|
||||
// Ensure media directories exist
|
||||
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
||||
|
||||
let api = Router::new()
|
||||
// Auth
|
||||
.route("/api/v1/event", get(handlers::public::get_public_event))
|
||||
@@ -138,6 +149,10 @@ async fn main() -> Result<()> {
|
||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||
// 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
|
||||
.route("/api/v1/feed", get(handlers::feed::feed))
|
||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||
@@ -278,7 +293,10 @@ async fn main() -> Result<()> {
|
||||
// * 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 the runbook now calls for points at this route.
|
||||
// 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)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
@@ -299,6 +317,56 @@ async fn main() -> Result<()> {
|
||||
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.
|
||||
|
||||
@@ -62,13 +62,27 @@ impl Comment {
|
||||
) -> Result<Vec<CommentDto>, sqlx::Error> {
|
||||
// Two-step: pick the newest `limit` rows older than `before`, then flip
|
||||
// them back into ascending order so the caller can render top-to-bottom.
|
||||
// `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>(
|
||||
"SELECT * FROM (
|
||||
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
|
||||
c.body, c.created_at
|
||||
FROM comment c
|
||||
JOIN \"user\" u ON u.id = c.user_id
|
||||
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
|
||||
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)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $3
|
||||
|
||||
@@ -32,14 +32,32 @@ impl Event {
|
||||
.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> {
|
||||
sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
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(name)
|
||||
.fetch_one(pool)
|
||||
.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(
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
@@ -51,3 +69,71 @@ impl Event {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +77,21 @@ impl Upload {
|
||||
//
|
||||
// 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>(
|
||||
"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 DO NOTHING
|
||||
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 *",
|
||||
)
|
||||
.bind(event_id)
|
||||
@@ -115,6 +126,29 @@ impl Upload {
|
||||
.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
|
||||
}
|
||||
|
||||
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
||||
@@ -229,6 +263,18 @@ impl Upload {
|
||||
.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,
|
||||
@@ -268,20 +314,27 @@ impl Upload {
|
||||
/// 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
|
||||
/// 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(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
by_host: bool,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let tx = conn;
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
"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
|
||||
RETURNING user_id, original_size_bytes",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.bind(by_host)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let deleted = if let Some((user_id, bytes)) = row {
|
||||
|
||||
@@ -47,19 +47,41 @@ impl User {
|
||||
event_id: Uuid,
|
||||
display_name: &str,
|
||||
pin_hash: &str,
|
||||
client_join_id: Option<Uuid>,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, client_join_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(display_name)
|
||||
.bind(pin_hash)
|
||||
.bind(client_join_id)
|
||||
.fetch_one(pool)
|
||||
.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
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ impl CompressionWorker {
|
||||
let mut attempt = 1u32;
|
||||
let outcome = loop {
|
||||
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
|
||||
{
|
||||
Ok(v) => break Ok(v),
|
||||
@@ -205,11 +206,25 @@ 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(
|
||||
&self,
|
||||
upload_id: Uuid,
|
||||
original_path: &str,
|
||||
mime_type: &str,
|
||||
charge_lifetime_attempt: bool,
|
||||
) -> Result<()> {
|
||||
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
|
||||
|
||||
@@ -218,8 +233,15 @@ impl CompressionWorker {
|
||||
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.
|
||||
match Upload::begin_derivative_attempt(&self.pool, upload_id).await? {
|
||||
// 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)",
|
||||
@@ -304,7 +326,6 @@ impl CompressionWorker {
|
||||
/// 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
|
||||
@@ -336,8 +357,10 @@ impl CompressionWorker {
|
||||
// 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 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!(
|
||||
@@ -345,14 +368,24 @@ impl CompressionWorker {
|
||||
estimated_mib = bytes / (1024 * 1024),
|
||||
"waiting for the heavy-image permit"
|
||||
);
|
||||
Some(crate::services::imaging::HEAVY_IMAGE_PERMITS.acquire().await)
|
||||
Some(
|
||||
crate::services::imaging::HEAVY_IMAGE_PERMITS
|
||||
.acquire()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Run blocking image operations in a spawn_blocking task
|
||||
tokio::task::spawn_blocking(move || {
|
||||
write_image_derivatives(upload_id, &original, &mime_owned, &preview_path, &display_path)
|
||||
write_image_derivatives(
|
||||
upload_id,
|
||||
&original,
|
||||
&mime_owned,
|
||||
&preview_path,
|
||||
&display_path,
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
|
||||
|
||||
@@ -148,3 +148,104 @@ pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +423,49 @@ async fn invalidate_missing_files(
|
||||
/// start immediately.
|
||||
pub const REGEN_DEBOUNCE: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Tracks when the CURRENT un-served burst of invalidations began, per event.
|
||||
///
|
||||
/// The starvation this fixes (B7): every invalidation bumps `export_epoch` immediately and arms a
|
||||
/// worker that sleeps `REGEN_DEBOUNCE` before claiming — and any further bump inside that window
|
||||
/// retires it. Nothing rate-limits epoch bumps; only the HTTP requests are limited, at 120/min for
|
||||
/// comment deletion and 30/min for caption edits. So one ordinary guest deleting a comment per
|
||||
/// second, or flipping a caption A/B/A, keeps `GET /export/html` at 404 for the rest of the event
|
||||
/// while the UI shows "Wird vorbereitet…" forever. **A host moderating faster than one action per
|
||||
/// 20 seconds produces the same result by accident** — which is what a takedown pass looks like.
|
||||
///
|
||||
/// The fix is to measure the debounce from the FIRST request in a burst rather than the latest, so
|
||||
/// the wait is bounded no matter how long the burst runs: coalescing still collapses a rapid pass
|
||||
/// into one build, but a build always starts within `REGEN_DEBOUNCE` of the burst beginning.
|
||||
///
|
||||
/// In-memory on purpose. It is a scheduling hint, not state: losing it on restart is harmless
|
||||
/// because `recover_exports` re-arms anything unfinished at boot anyway, and the worst case of a
|
||||
/// stale entry is one build starting immediately instead of debounced.
|
||||
type BurstStarts = std::collections::HashMap<Uuid, std::time::Instant>;
|
||||
static REGEN_BURST_START: std::sync::LazyLock<std::sync::Mutex<BurstStarts>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(BurstStarts::new()));
|
||||
|
||||
/// How long to defer the next regen worker for `event_id`, and record that a burst is running.
|
||||
///
|
||||
/// Returns `REGEN_DEBOUNCE` for the first invalidation of a burst and progressively less for
|
||||
/// each one after it, reaching zero once the burst has been going for a full debounce window.
|
||||
pub fn regen_delay_for(event_id: Uuid) -> Duration {
|
||||
let mut map = match REGEN_BURST_START.lock() {
|
||||
Ok(m) => m,
|
||||
// A poisoned mutex must not take the keepsake down: fall back to the plain debounce,
|
||||
// which is the pre-existing behaviour.
|
||||
Err(e) => e.into_inner(),
|
||||
};
|
||||
let started = *map.entry(event_id).or_insert_with(std::time::Instant::now);
|
||||
REGEN_DEBOUNCE.saturating_sub(started.elapsed())
|
||||
}
|
||||
|
||||
/// A regen worker has begun (or the event settled), so the next invalidation starts a fresh burst.
|
||||
pub fn clear_regen_burst(event_id: Uuid) {
|
||||
if let Ok(mut map) = REGEN_BURST_START.lock() {
|
||||
map.remove(&event_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Export worker entry point: every argument is state the spawned worker is BORN with (notably
|
||||
// `epoch`). Bundling them into a struct would be a pure-refactor risk on the epoch logic for no
|
||||
// gain, so the arity stands.
|
||||
@@ -451,12 +494,19 @@ pub fn spawn_export_jobs(
|
||||
if !delay.is_zero() {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
if let Err(e) =
|
||||
run_zip_export(event_id, epoch, &pool, &media_path, &export_path, &sse_tx).await
|
||||
{
|
||||
tracing::error!("ZIP export failed for event {event_id} @ epoch {epoch}: {e:#}");
|
||||
mark_failed(&pool, event_id, "zip", epoch, &e.to_string()).await;
|
||||
}
|
||||
// The burst has now been served: whatever arrives next starts a fresh debounce window.
|
||||
clear_regen_burst(event_id);
|
||||
// Run the body in its OWN task so a panic surfaces as a `JoinError` here rather than
|
||||
// unwinding past `mark_failed` — see `supervise_export`.
|
||||
let (p, m, x, s) = (
|
||||
pool.clone(),
|
||||
media_path.clone(),
|
||||
export_path.clone(),
|
||||
sse_tx.clone(),
|
||||
);
|
||||
let inner =
|
||||
tokio::spawn(async move { run_zip_export(event_id, epoch, &p, &m, &x, &s).await });
|
||||
supervise_export(inner, &pool, event_id, "zip", epoch).await;
|
||||
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
|
||||
});
|
||||
|
||||
@@ -464,25 +514,77 @@ pub fn spawn_export_jobs(
|
||||
if !delay.is_zero() {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
if let Err(e) = run_html_export(
|
||||
event_id,
|
||||
epoch,
|
||||
&event_name2,
|
||||
comments_enabled,
|
||||
&pool2,
|
||||
&media_path2,
|
||||
&export_path2,
|
||||
&sse_tx2,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("HTML export failed for event {event_id} @ epoch {epoch}: {e:#}");
|
||||
mark_failed(&pool2, event_id, "html", epoch, &e.to_string()).await;
|
||||
}
|
||||
let (p, m, x, s) = (
|
||||
pool2.clone(),
|
||||
media_path2.clone(),
|
||||
export_path2.clone(),
|
||||
sse_tx2.clone(),
|
||||
);
|
||||
let inner = tokio::spawn(async move {
|
||||
run_html_export(
|
||||
event_id,
|
||||
epoch,
|
||||
&event_name2,
|
||||
comments_enabled,
|
||||
&p,
|
||||
&m,
|
||||
&x,
|
||||
&s,
|
||||
)
|
||||
.await
|
||||
});
|
||||
supervise_export(inner, &pool2, event_id, "html", epoch).await;
|
||||
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Await an export worker and make sure the job row NEVER stays at `running`.
|
||||
///
|
||||
/// Both workers were bare `tokio::spawn`s whose `Err` path was handled correctly — but a PANIC
|
||||
/// unwound straight past `mark_failed` and `maybe_broadcast_complete`, leaving the row at whatever
|
||||
/// `progress_pct` it had reached. The UI renders that as "Wird erstellt (77%)" with the download
|
||||
/// disabled, permanently: nothing sweeps `running` rows, and `recover_exports` only runs at boot.
|
||||
/// So the single most likely cause of a stuck keepsake was also the one the error handling missed.
|
||||
///
|
||||
/// Running the body as a child task turns that panic into a `JoinError` we can act on. The
|
||||
/// maintenance loop already supervises itself this way; this extends the same pattern to the two
|
||||
/// workers that actually produce the thing guests came for.
|
||||
async fn supervise_export(
|
||||
handle: tokio::task::JoinHandle<Result<()>>,
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_type: &str,
|
||||
epoch: i64,
|
||||
) {
|
||||
match handle.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
"{export_type} export failed for event {event_id} @ epoch {epoch}: {e:#}"
|
||||
);
|
||||
mark_failed(pool, event_id, export_type, epoch, &e.to_string()).await;
|
||||
}
|
||||
Err(join_err) => {
|
||||
// Panicked, or cancelled at shutdown. Either way the row must not be left claiming to
|
||||
// be in progress — a failed job the host can retry beats one that lies forever.
|
||||
tracing::error!(
|
||||
panicked = join_err.is_panic(),
|
||||
"{export_type} export task for event {event_id} @ epoch {epoch} died without \
|
||||
reporting: {join_err}"
|
||||
);
|
||||
mark_failed(
|
||||
pool,
|
||||
event_id,
|
||||
export_type,
|
||||
epoch,
|
||||
"Interner Fehler beim Erstellen des Keepsakes. Bitte über \"Neu erzeugen\" \
|
||||
erneut versuchen.",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight that will sacrifice the previous generation rather than deadlock, and the order is
|
||||
/// the whole point.
|
||||
///
|
||||
@@ -500,6 +602,11 @@ pub fn spawn_export_jobs(
|
||||
/// generation is the one thing we can reclaim — sacrifice it and try once more. A keepsake that
|
||||
/// exists beats one we preserved but can never replace.
|
||||
///
|
||||
/// But ONLY when the sacrifice is sufficient: we measure what pruning would free and compare it to
|
||||
/// the shortfall first. Pruning and hoping meant a failed second check left nothing on disk at all
|
||||
/// — no old archive and no new one — which is worse than either outcome this function chooses
|
||||
/// between. When the old archive cannot buy us a rebuild, it stays.
|
||||
///
|
||||
/// SHARED by both halves deliberately. This started as two copies and one of them (HTML) silently
|
||||
/// kept the single-phase form, so the ZIP archive rebuilt and the viewer stayed permanently stuck
|
||||
/// — the exact deadlock above, on half the product. `prefix` is the only thing that differs, and
|
||||
@@ -512,16 +619,160 @@ async fn ensure_export_space_reclaiming(
|
||||
prefix: &str,
|
||||
epoch: i64,
|
||||
) -> Result<()> {
|
||||
if ensure_export_space(pool, event_id, export_path).await.is_ok() {
|
||||
if ensure_export_space(pool, event_id, export_path)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
tracing::warn!(
|
||||
"not enough room to rebuild {prefix} alongside the previous keepsake; reclaiming it first"
|
||||
);
|
||||
|
||||
// LOOK BEFORE YOU DESTROY.
|
||||
//
|
||||
// This used to prune unconditionally and then re-check. When the re-check ALSO failed, the old
|
||||
// keepsake was already gone and the rebuild returned `Err` — leaving **no archive on disk at
|
||||
// all**, which is strictly worse than the deadlock the reclaim exists to avoid. It needs no
|
||||
// host action to reach: any guest deleting their own photo arms this path, and the doc comment
|
||||
// above asserts the opposite invariant.
|
||||
//
|
||||
// So sacrifice the old generation only when doing so is actually sufficient. When it is not,
|
||||
// that old keepsake is the only one anybody will ever have — keep it, and report the failure
|
||||
// the same way the non-reclaiming check does. `rebuild_export` is the host's retry once they
|
||||
// have freed space.
|
||||
let deficit = match export_space_deficit(pool, event_id, export_path).await? {
|
||||
// Raced back into having room (a concurrent prune, a guest deleting an upload). Nothing
|
||||
// to reclaim and nothing to fail.
|
||||
None => return Ok(()),
|
||||
Some(d) => d,
|
||||
};
|
||||
let reclaimable =
|
||||
reclaimable_superseded_bytes(pool, export_path, prefix, event_id, epoch).await;
|
||||
|
||||
// PRUNE EVEN WHEN IT IS NOT ENOUGH ON ITS OWN. This used to refuse unless
|
||||
// `reclaimable >= deficit`, on the premise that "deleting it would leave no archive at all" —
|
||||
// and that premise does not survive contact with `prune_superseded_archives`, which can only
|
||||
// ever touch generations `n < keep_seq` that `protected_files` does not name. Those are exactly
|
||||
// the archives no handler can serve: a download resolves through `export_current`, which
|
||||
// requires `j.epoch = e.export_epoch`, and the epoch only ever increments. The bytes this
|
||||
// branch was protecting were already unreachable from every route, and the next successful
|
||||
// build deletes them anyway.
|
||||
//
|
||||
// What the refusal did cost was the only in-app way out of the deadlock this function exists to
|
||||
// break. `reclaimable` is scoped to the caller's OWN prefix — one old archive, ~1.1x the
|
||||
// gallery — while `deficit` is sized for both halves plus the 10 GB reserve. So on a tight
|
||||
// disk each worker independently measures its own share as insufficient and neither prunes,
|
||||
// while the two shares are JOINTLY sufficient. Every "Neu erzeugen" reruns the identical
|
||||
// arithmetic and refuses identically: permanently stuck, with dead archives on the volume that
|
||||
// nothing will reclaim and nothing can serve.
|
||||
//
|
||||
// So reclaim what we can and let the re-check below decide. If it still does not fit, we fail
|
||||
// exactly as the plain preflight would — but the sibling worker's prune has now freed its share
|
||||
// too, and the host's retry converges instead of looping.
|
||||
if reclaimable < deficit {
|
||||
tracing::warn!(
|
||||
deficit,
|
||||
reclaimable,
|
||||
"pruning the previous {prefix} keepsake will not free the full shortfall on its own; \
|
||||
reclaiming anyway — it is already unservable, and the sibling half frees the rest"
|
||||
);
|
||||
} else {
|
||||
// `else`, not a second unconditional line: both used to fire in the shortfall case, and
|
||||
// they read as contradicting each other ("will not free the shortfall" / "reclaiming it
|
||||
// first") to whoever is reading logs at 2am.
|
||||
tracing::warn!(
|
||||
deficit,
|
||||
reclaimable,
|
||||
"not enough room to rebuild {prefix} alongside the previous keepsake; reclaiming it first"
|
||||
);
|
||||
}
|
||||
prune_superseded_archives(pool, export_path, prefix, event_id, epoch).await;
|
||||
ensure_export_space(pool, event_id, export_path).await
|
||||
}
|
||||
|
||||
/// Largest share of an event's media an archive may silently omit and still publish.
|
||||
///
|
||||
/// Not zero, deliberately. A skip is *expected* in ordinary operation — a guest deleting their own
|
||||
/// photo mid-build, or the hourly reclaim collecting an original past its retention window — and
|
||||
/// the writers go out of their way to degrade one entry rather than fail the whole keepsake,
|
||||
/// because a released event cannot be rebuilt by the host without reopening uploads. Making any
|
||||
/// skip fatal would turn a tolerated one-photo gap into total loss of the keepsake, which is the
|
||||
/// regression the TOCTOU comments in each writer warn about.
|
||||
///
|
||||
/// What must never happen is publishing an archive that is missing *most* of the event. 10% of a
|
||||
/// ~100-photo wedding is ~10 photos: far above the one-or-two a live delete explains, far below
|
||||
/// the "media_path is wrong so nothing opened" catastrophe.
|
||||
const MAX_SKIPPED_FRACTION: f64 = 0.10;
|
||||
|
||||
/// Skips tolerated regardless of how small the event is.
|
||||
///
|
||||
/// A pure fraction inverts this check on a small gallery: at nine uploads one skip is 11%, so the
|
||||
/// single most ordinary event there is — a guest deleting their own photo while the archive builds
|
||||
/// — failed the whole keepsake. That is exactly the "one-photo gap becomes total loss" outcome
|
||||
/// [`MAX_SKIPPED_FRACTION`]'s own comment says it exists to avoid, and it is worst early on, when
|
||||
/// a host testing a release has only a handful of photos.
|
||||
///
|
||||
/// Two is the number of concurrent live deletes worth absorbing; beyond that on a tiny gallery the
|
||||
/// `written == 0` guard still catches the misconfiguration case, which is the one that matters.
|
||||
const MIN_TOLERATED_SKIPS: usize = 2;
|
||||
|
||||
/// Decide whether an archive that skipped some media may still be published.
|
||||
///
|
||||
/// Rejects two cases:
|
||||
/// * `written == 0` with media expected — the misconfiguration case. This is what produced a
|
||||
/// few-hundred-byte ZIP containing zero photos that passed every automated check, was
|
||||
/// advertised green, and was handed to every guest.
|
||||
/// * more than [`MAX_SKIPPED_FRACTION`] omitted — enough missing that the archive misrepresents
|
||||
/// the event even though it is structurally valid.
|
||||
///
|
||||
/// A tolerated partial still logs at `error` level with the exact counts, so the gap is visible in
|
||||
/// the record rather than buried in per-entry warnings nobody aggregates.
|
||||
fn check_export_completeness(
|
||||
kind: &str,
|
||||
event_id: Uuid,
|
||||
expected: usize,
|
||||
written: usize,
|
||||
skipped: usize,
|
||||
) -> Result<()> {
|
||||
if skipped == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if written == 0 && expected > 0 {
|
||||
anyhow::bail!(
|
||||
"{kind}-Export enthält keine einzige Datei ({expected} erwartet, alle \
|
||||
übersprungen). Das deutet auf einen falschen MEDIA_PATH oder fehlende \
|
||||
Zugriffsrechte hin — das Keepsake wurde NICHT veröffentlicht."
|
||||
);
|
||||
}
|
||||
// A PROPORTIONAL skip is loud, but it is NOT fatal, and the difference is the whole point.
|
||||
//
|
||||
// This used to `bail!` once skips passed `max(2, 10% of expected)`. That inverts
|
||||
// MAX_SKIPPED_FRACTION's own justification two doc comments up — "making any skip fatal would
|
||||
// turn a tolerated one-photo gap into total loss of the keepsake" — because the refusal is
|
||||
// DETERMINISTIC ACROSS RETRIES. The files that could not be read are still unreadable when the
|
||||
// host taps "Neu erzeugen", and by then the gallery is released and the uploads cannot be
|
||||
// collected again. So on a 30-photo event, 4 unreadable originals stopped publishing the other
|
||||
// 26 — not once, but forever. Refusing to hand over an incomplete keepsake is only defensible
|
||||
// if something better can still arrive; here nothing can.
|
||||
//
|
||||
// `written == 0` above stays fatal, and it is the case that actually mattered: a wrong
|
||||
// MEDIA_PATH produced a few-hundred-byte archive with zero photos that passed every automated
|
||||
// check and was advertised as ready. That one is a misconfiguration the host CAN fix and retry.
|
||||
//
|
||||
// Everything short of that publishes, with the counts at `error` level so the gap is in the
|
||||
// record rather than buried in per-entry warnings. A partial keepsake is what the guests get to
|
||||
// keep; an aborted one is nothing at all.
|
||||
let budget = MIN_TOLERATED_SKIPS.max((expected as f64 * MAX_SKIPPED_FRACTION).floor() as usize);
|
||||
let level_note = if expected > 0 && skipped > budget {
|
||||
"MATERIALLY INCOMPLETE — published anyway because a refused rebuild is not recoverable"
|
||||
} else {
|
||||
"published with missing media"
|
||||
};
|
||||
tracing::error!(
|
||||
%event_id, kind, expected, written, skipped, budget,
|
||||
"{level_note} — {skipped} of {expected} entries could not be read"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take the process-wide heavy-image permit if this file is big enough to need it.
|
||||
///
|
||||
/// Mirrors the compression worker's gate exactly (a header probe, no pixels decoded), so the two
|
||||
@@ -531,7 +782,10 @@ async fn heavy_permit_for(path: &Path) -> Option<tokio::sync::SemaphorePermit<'s
|
||||
let estimate = crate::services::imaging::estimated_processing_peak_bytes(path, 2048);
|
||||
match estimate {
|
||||
Some(bytes) if bytes > crate::services::imaging::HEAVY_IMAGE_BYTES => {
|
||||
crate::services::imaging::HEAVY_IMAGE_PERMITS.acquire().await.ok()
|
||||
crate::services::imaging::HEAVY_IMAGE_PERMITS
|
||||
.acquire()
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -634,6 +888,15 @@ async fn run_zip_export_inner(
|
||||
let out_name = gen_name(event_id, "Gallery", epoch, ".zip");
|
||||
let out_path = exports_dir.join(&out_name);
|
||||
|
||||
// Skips are TOLERATED but no longer SILENT — see `check_export_completeness`. All three
|
||||
// writers continued past an unreadable source with only a `warn!`, nothing counted them,
|
||||
// nothing reached `error_message`, and the job finalized at `progress_pct = 100,
|
||||
// status = 'done'`. So a wrong `media_path` after a compose edit produced a few-hundred-byte
|
||||
// ZIP with zero media that passed the liveness check, was advertised green, and was handed to
|
||||
// every guest as their keepsake.
|
||||
let mut written = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
|
||||
{
|
||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||
let mut zip = ZipFileWriter::with_tokio(file);
|
||||
@@ -666,9 +929,11 @@ async fn run_zip_export_inner(
|
||||
row.id,
|
||||
src.display()
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
written += 1;
|
||||
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut f = src_file.compat();
|
||||
@@ -697,6 +962,11 @@ async fn run_zip_export_inner(
|
||||
file.sync_all().await?;
|
||||
}
|
||||
|
||||
// Refuse to publish an archive that lost media it was supposed to contain. Checked BEFORE the
|
||||
// rename so a rejected build never reaches a servable path.
|
||||
check_export_completeness("ZIP", event_id, uploads.len(), written, skipped)
|
||||
.inspect_err(|_| tracing::error!("ZIP export for event {event_id}: refusing to publish"))?;
|
||||
|
||||
tokio::fs::rename(&tmp_path, &out_path).await?;
|
||||
|
||||
// Commit ONLY if our generation is still current. `finalize_job` is guarded on `epoch` — a
|
||||
@@ -833,7 +1103,15 @@ async fn run_html_export_inner(
|
||||
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
|
||||
// (zip entry name under media/, where its bytes come from). Built here, streamed
|
||||
// into the ZIP in step 5 — so we also know the exact file count without a rescan.
|
||||
let mut media_manifest: Vec<(String, MediaSource)> = Vec::new();
|
||||
// The bool is "this entry is the FULL variant", i.e. the one `data.json` advertises as the
|
||||
// photo itself. It exists so `check_export_completeness` can count photos rather than files —
|
||||
// see the call site. Exactly one full entry is pushed per upload that survives the stat.
|
||||
let mut media_manifest: Vec<(String, MediaSource, bool)> = Vec::new();
|
||||
// Uploads dropped at the stat below never enter `media_manifest`, so without counting them
|
||||
// here a wholly-unreadable media directory yields an EMPTY manifest — expected 0, skipped 0 —
|
||||
// and the completeness check downstream would wave it through as a legitimately empty event.
|
||||
// These are the same loss as a skip at write time and are checked as one.
|
||||
let mut upload_skipped = 0usize;
|
||||
|
||||
for (i, row) in uploads.iter().enumerate() {
|
||||
let src = media_path.join(&row.original_path);
|
||||
@@ -852,6 +1130,7 @@ async fn run_html_export_inner(
|
||||
row.id,
|
||||
src.display()
|
||||
);
|
||||
upload_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -922,7 +1201,15 @@ async fn run_html_export_inner(
|
||||
.context("failed to save thumbnail")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
// NOT `?`. The `?` here was on the JoinError, not on the closure's Result — so a
|
||||
// decoder PANIC (the `image` crate can panic on malformed input, and a resize can
|
||||
// abort on allocation) propagated out and failed the ENTIRE keepsake, where the very
|
||||
// same file merely failing returns `Err` and costs one tile. Worse, it was
|
||||
// deterministic: "Neu erzeugen" reads the same poison file and dies the same way. That
|
||||
// is the failure shape the completeness guard was reversed to eliminate, arriving
|
||||
// through the other door.
|
||||
.unwrap_or_else(|e| Err(anyhow::anyhow!("thumbnail task panicked: {e}")));
|
||||
|
||||
// Same dangling-reference hazard as the video branch: a failure here left `thumb`
|
||||
// pointing at a file the ZIP writer would then skip, so `data.json` advertised an
|
||||
@@ -959,7 +1246,10 @@ async fn run_html_export_inner(
|
||||
.context("failed to save compressed full image")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
// See the thumbnail branch: a panic here must cost this one full variant (the
|
||||
// original is then streamed as-is below), never the whole keepsake.
|
||||
.unwrap_or_else(|e| Err(anyhow::anyhow!("full-image task panicked: {e}")));
|
||||
|
||||
match compress_result {
|
||||
Ok(()) => MediaSource::Temp(full_path),
|
||||
@@ -983,9 +1273,9 @@ async fn run_html_export_inner(
|
||||
// writer skip it silently while `data.json` still advertised it — the viewer then drew a
|
||||
// broken image tile for an entry the archive never contained.
|
||||
if let Some(name) = &thumb_name {
|
||||
media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name))));
|
||||
media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name)), false));
|
||||
}
|
||||
media_manifest.push((full_name.clone(), full_source));
|
||||
media_manifest.push((full_name.clone(), full_source, true));
|
||||
|
||||
// Build comments for this upload
|
||||
let post_comments: Vec<ViewerComment> = comments
|
||||
@@ -1069,6 +1359,18 @@ async fn run_html_export_inner(
|
||||
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
|
||||
// index.html (file://), where a cross-origin fetch() of a sibling file is
|
||||
// blocked — so the data must be inlined rather than fetched from data.json.
|
||||
// The viewer IS the keepsake — Memories.zip without it is a folder of files with no way to
|
||||
// look at them. `write_viewer_with_data` walks `dir.files()`, which iterates nothing at all
|
||||
// when the compiled-in directory is empty, so a viewer build that failed after Vite emptied
|
||||
// its output directory used to produce a perfectly valid archive with no viewer in it,
|
||||
// silently. Asserted here rather than trusted: this costs one lookup per export.
|
||||
if VIEWER_DIR.get_file("index.html").is_none() {
|
||||
anyhow::bail!(
|
||||
"the keepsake viewer is missing from this binary (static/export-viewer/index.html \
|
||||
was not compiled in). Run `npm run build` in frontend/export-viewer and rebuild — \
|
||||
an archive without the viewer is not a keepsake."
|
||||
);
|
||||
}
|
||||
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?;
|
||||
|
||||
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
|
||||
@@ -1099,8 +1401,22 @@ async fn run_html_export_inner(
|
||||
// whose ffmpeg step failed) are skipped — the viewer tolerates gaps.
|
||||
let file_total = media_manifest.len().max(1) as f32;
|
||||
let mut files_written = 0u32;
|
||||
// Photos, not files — the number `check_export_completeness` is actually about.
|
||||
//
|
||||
// `files_written` counts MANIFEST ROWS, and there are up to two per upload: a thumbnail and
|
||||
// a full variant. Thumbnails are 400px JPEGs this export GENERATES ITSELF into its own temp
|
||||
// dir, so they are no evidence that any original was captured. Counting them meant the one
|
||||
// remaining fatal case — nothing at all was written — could not fire while thumbnails kept
|
||||
// succeeding: if the media volume became unreadable after the stat pass, every original
|
||||
// open failed and every thumb open succeeded, and a keepsake with 100 thumbnails and ZERO
|
||||
// full-resolution photos published green, `done` at the live epoch, with the download
|
||||
// button lit. Boot recovery skips a `done` job, so nothing would ever have rebuilt it.
|
||||
let mut full_written = 0usize;
|
||||
// See `check_export_completeness`: a viewer that tolerates gaps must still not publish an
|
||||
// archive with no media in it at all.
|
||||
let mut media_skipped = 0usize;
|
||||
|
||||
for (name, source) in &media_manifest {
|
||||
for (name, source, is_full) in &media_manifest {
|
||||
let path = source.path();
|
||||
// Open-first: a source that disappeared between the manifest being built and now (a
|
||||
// delete, or the hourly sweep reclaiming a long-failed original) must skip this entry,
|
||||
@@ -1112,6 +1428,7 @@ async fn run_html_export_inner(
|
||||
"HTML export: skipping media {name} — cannot read {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
media_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -1124,6 +1441,9 @@ async fn run_html_export_inner(
|
||||
zip_entry.close().await?;
|
||||
|
||||
files_written += 1;
|
||||
if *is_full {
|
||||
full_written += 1;
|
||||
}
|
||||
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
|
||||
if !update_progress(pool, event_id, "html", epoch, pct.min(98)).await {
|
||||
return Err(Superseded.into());
|
||||
@@ -1135,6 +1455,21 @@ async fn run_html_export_inner(
|
||||
let mut file = zip.close().await?.into_inner();
|
||||
file.flush().await?;
|
||||
file.sync_all().await?;
|
||||
|
||||
// Before the rename, so a rejected viewer never reaches a servable path. Inside this
|
||||
// scope because `media_manifest` and the counters are scoped here.
|
||||
// Measured against the full upload set, not the manifest: an upload dropped at the stat
|
||||
// and one dropped at the write are the same loss to the guest looking for their photo.
|
||||
check_export_completeness(
|
||||
"HTML",
|
||||
event_id,
|
||||
uploads.len(),
|
||||
full_written,
|
||||
upload_skipped + media_skipped,
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
tracing::error!("HTML export for event {event_id}: refusing to publish")
|
||||
})?;
|
||||
}
|
||||
|
||||
// 6. Finalise
|
||||
@@ -1246,9 +1581,21 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
|
||||
/// `export_job` (that is the point of the design: one write retires everything). So after a reopen
|
||||
/// the row is still `pending` at our epoch and this claim SUCCEEDS: the worker will build an archive
|
||||
/// nobody can ever see, because retirement is enforced at READ time (`export_current` requires
|
||||
/// `j.epoch = e.export_epoch`), not at write time. That is wasted work, not incorrectness — and the
|
||||
/// `update_progress` liveness check bails such a worker out early. Do not "optimise" this into a
|
||||
/// cross-table check: that is exactly the unsound guard we removed.
|
||||
/// `j.epoch = e.export_epoch`), not at write time. That is wasted work, not incorrectness. Do not
|
||||
/// "optimise" this into a cross-table check: that is exactly the unsound guard we removed.
|
||||
///
|
||||
/// This used to claim that "the `update_progress` liveness check bails such a worker out early".
|
||||
/// IT DOES NOT, and it cannot: `update_progress`'s predicate is `epoch = ours AND status =
|
||||
/// 'running'` on the JOB ROW, which a reopen does not touch — so the check returns true on every
|
||||
/// tick and the worker grinds the whole gallery to completion, every ffmpeg poster and every
|
||||
/// Lanczos3 resize, before its `finalize_job` writes `done` at an epoch nothing reads.
|
||||
///
|
||||
/// The cost is real on a 2-vCPU box: a host reopening the event mid-export — the documented
|
||||
/// "oops, one more photo" path — leaves a full export burning CPU and the heavy-image semaphore
|
||||
/// DURING the live event, and lands a full-gallery-sized orphan that nothing reclaims until the
|
||||
/// next successful build at a higher epoch. Bounded and not corrupting, so it is left as is; but
|
||||
/// the mitigation the old comment promised was never there, and anyone sizing this box should
|
||||
/// know that.
|
||||
///
|
||||
/// Errors are distinguished from a lost claim: silently treating a pool timeout as "someone else
|
||||
/// owns it" left the row `pending` at 0% with no live worker and no error — a spinner forever.
|
||||
@@ -1349,9 +1696,23 @@ async fn protected_files(pool: &PgPool, event_id: Uuid) -> Vec<String> {
|
||||
/// a hung ffmpeg — left the event with NO archive at all, which is the one outcome the product
|
||||
/// exists to prevent, at the one moment nobody is watching.
|
||||
///
|
||||
/// The single exception is phase 2 of `ensure_export_space_reclaiming`, where the previous
|
||||
/// generation's bytes are the only way the rebuild can fit at all. Both call sites carry the full
|
||||
/// reasoning; do not "restore" a pre-build prune on the strength of this function's convenience.
|
||||
/// The single exception is phase 2 of `ensure_export_space_reclaiming`, and BE PRECISE ABOUT WHAT
|
||||
/// THAT EXCEPTION NOW COSTS, because the guarantee above is weaker than it reads. That phase used
|
||||
/// to prune only when the reclaimed bytes would actually close the shortfall. It no longer does:
|
||||
/// `reclaimable` is scoped to one prefix while `deficit` covers both halves plus the reserve, so on
|
||||
/// a tight disk each worker measured its own share as insufficient, neither pruned, and every "Neu
|
||||
/// erzeugen" refused identically — permanently stuck, with dead archives on the volume that nothing
|
||||
/// would reclaim and nothing could serve. It now prunes anyway and lets the re-check decide.
|
||||
///
|
||||
/// The trade that buys: a rebuild can now delete the last physical copy and THEN fail, which is
|
||||
/// precisely the "no archive at all" outcome this doc argues against. It is accepted because the
|
||||
/// refusal it replaces was unrecoverable — deterministic across retries — whereas this failure
|
||||
/// converges once the sibling worker frees its share. But "an epoch can be rolled back, deleted
|
||||
/// bytes cannot" is no longer a guarantee this module provides end to end, and a manual
|
||||
/// `UPDATE event SET export_epoch = <n>` can no longer rescue that case.
|
||||
///
|
||||
/// Both call sites carry the full reasoning; do not "restore" a pre-build prune on the strength of
|
||||
/// this function's convenience.
|
||||
///
|
||||
/// Narrower than [`prune_stale_export_files`] on purpose: FINAL archives only. Those are inert — a
|
||||
/// superseded worker either already renamed its file (and will delete it itself when its guarded
|
||||
@@ -1528,6 +1889,64 @@ async fn ensure_export_space(pool: &PgPool, event_id: Uuid, export_path: &Path)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many bytes short of buildable we are, or `None` when there is already room.
|
||||
///
|
||||
/// Split out of `ensure_export_space` so the reclaiming wrapper can ask "would pruning be
|
||||
/// enough?" BEFORE it destroys anything. Mirrors that function's arithmetic exactly; the one
|
||||
/// deliberate difference is that an unresolvable mount reports `None` (fail open) rather than a
|
||||
/// deficit, so a missing disk reading can never be the reason we delete the only archive.
|
||||
async fn export_space_deficit(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_path: &Path,
|
||||
) -> Result<Option<u64>> {
|
||||
let media_bytes = estimate_export_bytes(pool, event_id).await?;
|
||||
let (armed,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM export_job
|
||||
WHERE event_id = $1 AND status IN ('pending', 'running')",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.context("counting armed export jobs")?;
|
||||
let required = required_free_bytes(media_bytes, armed)
|
||||
.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
|
||||
|
||||
let Some(free) = crate::services::disk::free_bytes(export_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok((free < required).then(|| required - free))
|
||||
}
|
||||
|
||||
/// Sum what `prune_superseded_archives` WOULD reclaim, without deleting anything.
|
||||
///
|
||||
/// Deliberately shares `is_superseded_archive` and `protected_files` with the real prune, so the
|
||||
/// estimate cannot drift from what actually gets removed. An unreadable directory reports 0,
|
||||
/// which makes the caller refuse to prune — the safe direction.
|
||||
async fn reclaimable_superseded_bytes(
|
||||
pool: &PgPool,
|
||||
exports_dir: &Path,
|
||||
prefix: &str,
|
||||
event_id: Uuid,
|
||||
keep_seq: i64,
|
||||
) -> u64 {
|
||||
let protected = protected_files(pool, event_id).await;
|
||||
let final_prefix = format!("{prefix}.{event_id}.");
|
||||
let Ok(mut rd) = tokio::fs::read_dir(exports_dir).await else {
|
||||
return 0;
|
||||
};
|
||||
let mut total = 0u64;
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !is_superseded_archive(&name, &final_prefix, keep_seq, &protected) {
|
||||
continue;
|
||||
}
|
||||
total = total.saturating_add(entry.metadata().await.map(|m| m.len()).unwrap_or(0));
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
|
||||
/// ONLY strictly-older generations (`n < keep_seq`) — never `keep_seq`'s own current file,
|
||||
/// and never a NEWER generation that a concurrent re-release may already be producing (that
|
||||
@@ -1916,6 +2335,34 @@ Viel Freude mit den Erinnerungen!\n";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The viewer must actually be compiled into the binary.
|
||||
///
|
||||
/// `include_dir!` over an empty directory is not an error, and `write_viewer_with_data`
|
||||
/// iterates `dir.files()` — zero files, zero writes, `Ok(())`. So a viewer build that failed
|
||||
/// after Vite emptied its output directory produced a binary whose Memories.zip contains every
|
||||
/// photo and no way to view them, with nothing anywhere reporting it. This is the cheapest
|
||||
/// place to notice, and it runs on every `cargo test`.
|
||||
#[test]
|
||||
fn the_keepsake_viewer_is_compiled_into_this_binary() {
|
||||
let index = super::VIEWER_DIR
|
||||
.get_file("index.html")
|
||||
.expect("static/export-viewer/index.html must be compiled in — run `npm run build` in frontend/export-viewer");
|
||||
// Not just present: substantial. An empty or truncated file would satisfy `get_file` and
|
||||
// still ship a blank keepsake. The real artifact is ~235 KB with the fonts inlined.
|
||||
assert!(
|
||||
index.contents().len() > 50_000,
|
||||
"the compiled-in viewer is only {} bytes — that is not a complete keepsake viewer",
|
||||
index.contents().len()
|
||||
);
|
||||
// And it must be self-contained: the whole point of the inlining is that it opens from
|
||||
// file:// with no network. A `/fonts/...` reference here is the bug shipping again.
|
||||
let html = std::str::from_utf8(index.contents()).expect("viewer is valid UTF-8");
|
||||
assert!(
|
||||
!html.contains("url(/"),
|
||||
"the compiled-in viewer references an external asset — it will 404 silently from file://"
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
const EVT: &str = "11111111-1111-1111-1111-111111111111";
|
||||
@@ -1924,6 +2371,69 @@ mod tests {
|
||||
format!("Gallery.{EVT}.")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_invalidations_still_starts_a_build_within_one_window() {
|
||||
// B7's invariant, and the reason the burst start is tracked at all. A per-request delay
|
||||
// meant every invalidation restarted the wait, so a host moderating faster than one action
|
||||
// per 20 seconds deferred the build forever — `/export/html` 404s and the UI sits on
|
||||
// "Wird vorbereitet…" for the rest of the event.
|
||||
//
|
||||
// What must hold is that the delay is measured from the FIRST request in the burst, so the
|
||||
// wait never grows: request #50 of a takedown pass is scheduled no later than request #1
|
||||
// was. Coalescing survives (later requests still collapse into one build); starvation does
|
||||
// not.
|
||||
let event = Uuid::new_v4();
|
||||
clear_regen_burst(event);
|
||||
|
||||
let first = regen_delay_for(event);
|
||||
assert!(
|
||||
first <= REGEN_DEBOUNCE,
|
||||
"the first invalidation of a burst waits at most one debounce window"
|
||||
);
|
||||
|
||||
let mut previous = first;
|
||||
for _ in 0..50 {
|
||||
let next = regen_delay_for(event);
|
||||
assert!(
|
||||
next <= previous,
|
||||
"a later invalidation must never push the build further out than an earlier one"
|
||||
);
|
||||
previous = next;
|
||||
}
|
||||
|
||||
// And the burst is a scheduling window, not a permanent state: once a worker has run,
|
||||
// the next invalidation is a fresh burst that gets the full coalescing delay again.
|
||||
clear_regen_burst(event);
|
||||
assert!(
|
||||
regen_delay_for(event) >= previous,
|
||||
"clearing the burst starts a fresh debounce window"
|
||||
);
|
||||
clear_regen_burst(event);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_events_burst_never_schedules_another_events_rebuild() {
|
||||
// The map is keyed per event because the delay is a property of THAT event's moderation
|
||||
// pass. A shared window would let a busy event drag an idle one's keepsake along with it —
|
||||
// or worse, let an idle event's stale entry start a busy one's build immediately, which is
|
||||
// the stampede the debounce exists to prevent.
|
||||
let busy = Uuid::new_v4();
|
||||
let quiet = Uuid::new_v4();
|
||||
clear_regen_burst(busy);
|
||||
clear_regen_burst(quiet);
|
||||
|
||||
for _ in 0..10 {
|
||||
regen_delay_for(busy);
|
||||
}
|
||||
clear_regen_burst(busy);
|
||||
|
||||
assert!(
|
||||
regen_delay_for(quiet) <= REGEN_DEBOUNCE,
|
||||
"an untouched event gets its own full window"
|
||||
);
|
||||
clear_regen_burst(quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strictly_older_archive_is_reclaimed() {
|
||||
// The whole point: at the start of a rebuild at epoch 5, generation 4's archive is dead
|
||||
@@ -2094,4 +2604,57 @@ mod tests {
|
||||
// guarding against — the failure mode must be "refuse", never "wrap and allow".
|
||||
assert_eq!(required_free_bytes(u64::MAX, 2), u64::MAX);
|
||||
}
|
||||
|
||||
/// The completeness gate decides whether a partially-readable gallery still ships. Both
|
||||
/// directions are dangerous: too strict and one live delete costs the guests every photo
|
||||
/// (a released event cannot be rebuilt without reopening uploads); too loose and a wrong
|
||||
/// MEDIA_PATH ships an archive that silently misrepresents the whole party.
|
||||
#[test]
|
||||
fn a_clean_export_and_a_totally_empty_one_are_unambiguous() {
|
||||
// Nothing skipped is always fine, at any size.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 0, 0, 0).is_ok());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 100, 100, 0).is_ok());
|
||||
|
||||
// Everything skipped is the misconfiguration case — never publish it, at any size.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 100, 0, 100).is_err());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 3, 0, 3).is_err());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 1, 0, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_gallery_survives_an_ordinary_live_delete() {
|
||||
// These are the regressions a pure 10% fraction caused: on a small gallery a single
|
||||
// guest deleting their own photo mid-build is >10%, so the whole keepsake failed.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 9, 8, 1).is_ok());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 3, 2, 1).is_ok());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 4, 2, 2).is_ok());
|
||||
// Half a tiny gallery is published too, and logged as materially incomplete. Refusing it
|
||||
// would hand the guests nothing at all rather than the three photos that DID survive, and
|
||||
// the rebuild that refusal invites reads the same unreadable files again.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 6, 3, 3).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_materially_incomplete_archive_is_still_published_but_shouted_about() {
|
||||
// This used to assert `is_err()` past the 10% tolerance, and that was the wrong trade.
|
||||
// The refusal is deterministic across retries — the unreadable files are still unreadable
|
||||
// when the host taps "Neu erzeugen", and the gallery is already released so the photos
|
||||
// cannot be collected again. So refusing did not buy a better archive later; it converted
|
||||
// "90 of 100 photos" into "no keepsake, ever". Past the tolerance we publish and log at
|
||||
// `error` with the counts.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 100, 90, 10).is_ok());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 100, 89, 11).is_ok());
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 300, 200, 100).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_with_nothing_in_it_is_still_refused() {
|
||||
// The one fatal case, and the only one the host can actually act on: a wrong MEDIA_PATH
|
||||
// produced a few-hundred-byte ZIP with zero photos that passed every automated check and
|
||||
// was advertised as ready. That IS recoverable — fix the path, rebuild — so refusing to
|
||||
// publish it is the correct answer, unlike the partial case above.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 100, 0, 100).is_err());
|
||||
// ...but an event that genuinely has no media is not an error.
|
||||
assert!(check_export_completeness("zip", Uuid::nil(), 0, 0, 0).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +448,12 @@ async fn sweep_orphan_originals(pool: &PgPool, media_path: &std::path::Path) {
|
||||
}
|
||||
// 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()
|
||||
|
||||
@@ -55,15 +55,23 @@ impl MediaTotalCache {
|
||||
/// 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) -> i64 {
|
||||
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(total_upload_bytes)::bigint FROM \"user\"",
|
||||
"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;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod audit;
|
||||
pub mod compression;
|
||||
pub mod config;
|
||||
pub mod disk;
|
||||
|
||||
@@ -7,7 +7,23 @@ use std::time::{Duration, Instant};
|
||||
/// of recent requests and rejects new ones once the window is full.
|
||||
#[derive(Clone)]
|
||||
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 {
|
||||
@@ -34,7 +50,14 @@ impl RateLimiter {
|
||||
let now = Instant::now();
|
||||
let key = key.into();
|
||||
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);
|
||||
if timestamps.len() < max {
|
||||
timestamps.push(now);
|
||||
@@ -58,6 +81,37 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
|
||||
pub fn clear(&self) {
|
||||
@@ -68,17 +122,23 @@ impl RateLimiter {
|
||||
/// background task (see [`crate::services::maintenance`]) so that long-lived
|
||||
/// processes don't accumulate one HashMap entry per IP that ever connected.
|
||||
///
|
||||
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
|
||||
/// of which endpoint's window it was tracked under (the longest window today is
|
||||
/// 24h for export downloads). If we ever add longer windows, raise this constant.
|
||||
/// Expires each bucket against ITS OWN window (see [`Bucket`]), not one global ceiling. The
|
||||
/// previous fixed 24 h ceiling retained per-minute keys for a full day — ~172,800 keys/day/IP
|
||||
/// 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) {
|
||||
let now = Instant::now();
|
||||
let ceiling = Duration::from_secs(24 * 60 * 60);
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let before = map.len();
|
||||
map.retain(|_, ts| {
|
||||
ts.retain(|&t| now.duration_since(t) < ceiling);
|
||||
!ts.is_empty()
|
||||
map.retain(|_, bucket| {
|
||||
bucket
|
||||
.hits
|
||||
.retain(|&t| now.duration_since(t) < bucket.window);
|
||||
!bucket.hits.is_empty()
|
||||
});
|
||||
let dropped = before.saturating_sub(map.len());
|
||||
if dropped > 0 {
|
||||
@@ -230,15 +290,20 @@ mod tests {
|
||||
fn prune_drops_keys_whose_windows_have_fully_expired() {
|
||||
let rl = RateLimiter::new();
|
||||
|
||||
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
|
||||
// so backdate the Instant directly.
|
||||
// A key whose only timestamp is older than its own window. We can't sleep, so backdate
|
||||
// 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()
|
||||
.checked_sub(Duration::from_secs(25 * 60 * 60))
|
||||
.expect("backdating an Instant by 25h");
|
||||
rl.windows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("stale".to_string(), vec![ancient]);
|
||||
.checked_sub(Duration::from_secs(90 * 60))
|
||||
.expect("backdating an Instant by 90 minutes");
|
||||
rl.windows.lock().unwrap().insert(
|
||||
"stale".to_string(),
|
||||
Bucket {
|
||||
hits: vec![ancient],
|
||||
window: MIN,
|
||||
},
|
||||
);
|
||||
|
||||
// ...alongside a key that is still inside its window.
|
||||
assert!(rl.check_with_retry("live", 5, MIN).is_ok());
|
||||
|
||||
@@ -13,6 +13,29 @@ use rand::Rng;
|
||||
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
||||
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.
|
||||
@@ -23,6 +46,20 @@ const MAX_TICKETS: usize = 4096;
|
||||
/// 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
|
||||
@@ -39,8 +76,23 @@ const MAX_TICKETS_PER_SESSION: usize = 4;
|
||||
pub enum TicketKind {
|
||||
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
|
||||
Sse,
|
||||
/// Downloads an export archive (`GET /export/{zip,html}`). Expensive, rate-limited per day.
|
||||
Download,
|
||||
/// 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)]
|
||||
@@ -53,6 +105,9 @@ struct Entry {
|
||||
token_hash: String,
|
||||
issued_at: Instant,
|
||||
kind: TicketKind,
|
||||
/// Times this ticket has been redeemed. Only meaningful for `Download` — see
|
||||
/// [`MAX_DOWNLOAD_REDEMPTIONS`].
|
||||
redemptions: u32,
|
||||
}
|
||||
|
||||
impl SseTicketStore {
|
||||
@@ -83,14 +138,26 @@ impl SseTicketStore {
|
||||
// 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);
|
||||
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)
|
||||
.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 {
|
||||
@@ -117,6 +184,7 @@ impl SseTicketStore {
|
||||
token_hash,
|
||||
issued_at: Instant::now(),
|
||||
kind,
|
||||
redemptions: 0,
|
||||
},
|
||||
);
|
||||
Some(ticket)
|
||||
@@ -130,10 +198,48 @@ impl SseTicketStore {
|
||||
/// 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 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 {
|
||||
@@ -151,7 +257,7 @@ impl SseTicketStore {
|
||||
/// long-running process doesn't accumulate stale tickets.
|
||||
pub fn prune(&self) {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +294,14 @@ mod tests {
|
||||
|
||||
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&sse, TicketKind::Download),
|
||||
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).unwrap();
|
||||
let dl = store
|
||||
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&dl, TicketKind::Sse),
|
||||
None,
|
||||
@@ -203,9 +311,13 @@ mod tests {
|
||||
// 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).unwrap();
|
||||
let dl = store
|
||||
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&dl, TicketKind::Download).as_deref(),
|
||||
store
|
||||
.consume(&dl, TicketKind::Download(ExportKind::Zip))
|
||||
.as_deref(),
|
||||
Some("h")
|
||||
);
|
||||
}
|
||||
@@ -214,7 +326,10 @@ mod tests {
|
||||
fn issue_then_consume_returns_the_hash_exactly_once() {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = issue(&store, "hash-1");
|
||||
assert_eq!(store.consume(&ticket, TicketKind::Sse).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.
|
||||
assert_eq!(
|
||||
store.consume(&ticket, TicketKind::Sse),
|
||||
@@ -244,7 +359,10 @@ mod tests {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = issue(&store, "h");
|
||||
store.prune(); // not expired → kept
|
||||
assert_eq!(store.consume(&ticket, TicketKind::Sse).as_deref(), Some("h"));
|
||||
assert_eq!(
|
||||
store.consume(&ticket, TicketKind::Sse).as_deref(),
|
||||
Some("h")
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an entry that is already past the TTL.
|
||||
@@ -257,6 +375,7 @@ mod tests {
|
||||
issued_at: Instant::now()
|
||||
.checked_sub(TTL + Duration::from_secs(1))
|
||||
.expect("host uptime should exceed the ticket TTL"),
|
||||
redemptions: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -305,7 +424,11 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
|
||||
assert!(
|
||||
store.inner.lock().unwrap().contains_key(&mine[mine.len() - 1]),
|
||||
store
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key(&mine[mine.len() - 1]),
|
||||
"the newest ticket is the one the caller is about to use"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -330,6 +453,7 @@ mod tests {
|
||||
kind: TicketKind::Sse,
|
||||
token_hash: format!("session-{i}"),
|
||||
issued_at: Instant::now(),
|
||||
redemptions: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -344,4 +468,99 @@ mod tests {
|
||||
"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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,7 @@ impl UploadAdmission {
|
||||
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
|
||||
{
|
||||
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.
|
||||
@@ -124,12 +119,12 @@ mod tests {
|
||||
|
||||
// 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");
|
||||
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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,9 @@ use common::*;
|
||||
use sqlx::PgPool;
|
||||
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.
|
||||
async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
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`
|
||||
/// 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
|
||||
/// 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
|
||||
/// and resume when the host reopens.
|
||||
/// outcome, and it is reversible: the client keeps the blob and resumes 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]
|
||||
async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool) {
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ async fn create_upload(
|
||||
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 DO NOTHING
|
||||
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)
|
||||
@@ -161,7 +161,10 @@ async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
|
||||
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
|
||||
.await
|
||||
.expect("first insert");
|
||||
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
|
||||
// `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
|
||||
@@ -172,4 +175,94 @@ async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,18 @@ services:
|
||||
logging: *default-logging
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
# `:?` for the same reason EVENTSNAP_VERSION and DOMAIN use it, and this is the worst place
|
||||
# to omit it. These are interpolated into `environment:`, which OVERRIDES `env_file` — so an
|
||||
# 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:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -42,6 +51,44 @@ services:
|
||||
# millisecond, so connections are no longer spent waiting. Raising it back
|
||||
# toward 30 means raising this limit with it.
|
||||
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:
|
||||
# Production PULLS a prebuilt image; it never compiles. A release build of this crate is
|
||||
@@ -82,6 +129,24 @@ services:
|
||||
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
|
||||
# so this is authoritative for the container.
|
||||
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`.
|
||||
@@ -122,6 +187,31 @@ services:
|
||||
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
|
||||
# OOM the single box and take down Postgres.
|
||||
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:
|
||||
# Pulled, not built — see the note on `app` above.
|
||||
@@ -161,6 +251,16 @@ services:
|
||||
resources:
|
||||
limits:
|
||||
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:
|
||||
image: caddy:2-alpine
|
||||
@@ -187,6 +287,15 @@ services:
|
||||
resources:
|
||||
limits:
|
||||
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:
|
||||
postgres_data:
|
||||
|
||||
@@ -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/',
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BASE } from './env';
|
||||
*
|
||||
* Field shape matches [backend/src/handlers/upload.rs]:
|
||||
* - 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)
|
||||
* - hashtags (CSV text, optional)
|
||||
*/
|
||||
@@ -17,6 +18,12 @@ export type UploadOptions = {
|
||||
contentType?: string;
|
||||
caption?: 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(
|
||||
@@ -29,9 +36,12 @@ export async function uploadRaw(
|
||||
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
||||
if (opts.caption !== undefined) form.append('caption', opts.caption);
|
||||
if (opts.hashtags !== undefined) form.append('hashtags', opts.hashtags);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers,
|
||||
body: form as any,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ export class AccountPage {
|
||||
this.page = page;
|
||||
this.displayName = page.locator('[data-testid="account-display-name"]');
|
||||
this.pinDisplay = page.locator('[data-testid="account-pin"]');
|
||||
this.leaveButton = page.getByRole('button', { name: /event verlassen/i });
|
||||
this.leaveConfirmButton = page.getByRole('button', { name: /^abmelden$/i });
|
||||
// Keyed on testids, not visible copy. The button was renamed "Event verlassen" ->
|
||||
// "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"]');
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { BASE } from '../../helpers/env';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
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 ({
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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 ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
host,
|
||||
db,
|
||||
}) => {
|
||||
// The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch
|
||||
// their keepsake was locked out until tomorrow.
|
||||
await db.setExportReleased('e2e-test-event', true);
|
||||
//
|
||||
// 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, {
|
||||
rate_limits_enabled: 'true',
|
||||
export_rate_enabled: 'true',
|
||||
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 res = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
const minted = await fetch(`${BASE}/api/v1/export/ticket?kind=zip`, {
|
||||
method: 'POST',
|
||||
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)}`);
|
||||
};
|
||||
|
||||
const a = await guest('ExportFirst');
|
||||
const b = await guest('ExportSecond');
|
||||
|
||||
// A spends their single daily allowance. The archive itself may not exist (404) —
|
||||
// what matters is that the limiter admitted the request rather than 429ing it.
|
||||
expect((await mintAndFetch(a.jwt)).status).not.toBe(429);
|
||||
// A spends their single daily allowance — on a real archive, so this is a genuine 200 rather
|
||||
// than merely "not 429", which would have been satisfied by any error at all.
|
||||
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);
|
||||
|
||||
// B shares A's IP and must still get their keepsake.
|
||||
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by A’s download').not.toBe(
|
||||
429
|
||||
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by A’s download').toBe(
|
||||
200
|
||||
);
|
||||
|
||||
// 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[] = [];
|
||||
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);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,14 @@ test.describe('Upload — storage quota enforcement', () => {
|
||||
await api.patchConfig(adminToken, {
|
||||
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({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Both the status chip ("Gesperrt") and the server's reason ("Du bist gesperrt.") must
|
||||
// render — the reason is the part that had no UI at all before.
|
||||
await expect(page.getByText('Gesperrt', { exact: true })).toBeVisible();
|
||||
// The server's reason ("Du bist gesperrt.") must render — it had no UI at all before.
|
||||
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
|
||||
// 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
|
||||
.poll(
|
||||
() =>
|
||||
@@ -74,7 +84,10 @@ test.describe('Upload — a rejected upload is surfaced', () => {
|
||||
const all = tx.objectStore('queue').getAll();
|
||||
all.onsuccess = () =>
|
||||
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);
|
||||
};
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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,
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
|
||||
// on the author gets 403 on their own delete, so if the host has no removal affordance
|
||||
// the comment is stuck on screen forever.
|
||||
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
|
||||
// uploads, so if the comment sat on their own photo the whole card would vanish and
|
||||
// there would be nothing left to moderate.
|
||||
const victim = await guest('PhotoOwner');
|
||||
// The UI leg of host comment moderation, and the ONLY test that clicks it. The affordance is
|
||||
// rendered solely by LightboxModal (`$isStaff` gates the trash button, and it routes through a
|
||||
// ConfirmSheet rather than deleting on first tap). Without this, `pendingCommentDelete` could
|
||||
// stop being wired to the sheet's onConfirm, or the staff gate could invert, and every
|
||||
// remaining comment-moderation test would still pass — they all call the API directly.
|
||||
//
|
||||
// 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 author = await guest('CommentOffender');
|
||||
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
|
||||
await api.banUser(host.jwt, author.userId);
|
||||
|
||||
// Confirm the deadlock really exists — the author cannot retract it themselves.
|
||||
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${author.jwt}` },
|
||||
});
|
||||
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
|
||||
const author = await guest('LightboxCommenter');
|
||||
await seedComment(author.jwt, uploadId, 'bitte entfernen');
|
||||
|
||||
await signIn(page, host);
|
||||
await page.goto('/feed');
|
||||
@@ -141,9 +135,88 @@ test.describe('Host — moderation from the UI', () => {
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
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 });
|
||||
|
||||
// "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();
|
||||
// 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 });
|
||||
});
|
||||
|
||||
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 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.getByRole('button', { name: /event verlassen/i }).click();
|
||||
await page.getByTestId('account-logout').click();
|
||||
const confirm = page.getByTestId('confirm-sheet-confirm');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
await page.waitForURL('**/join', { timeout: 10_000 });
|
||||
|
||||
@@ -59,7 +59,7 @@ test.describe('Export — the archives extract to readable files', () => {
|
||||
.toBe(true);
|
||||
|
||||
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',
|
||||
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');
|
||||
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||
method: 'POST',
|
||||
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
|
||||
// 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',
|
||||
headers: bearer,
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ test.describe('Export — video streaming (P4)', () => {
|
||||
.toBe('done');
|
||||
|
||||
// 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',
|
||||
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
|
||||
// endpoint authenticates with a single-use ticket (no Bearer header).
|
||||
async function mintTicket(jwt: string): Promise<string> {
|
||||
const res = await fetch(base + '/api/v1/export/ticket', {
|
||||
/** The raw mint response — `/export/ticket` pre-validates that the archive is actually
|
||||
* 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',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
return (await res.json()).ticket;
|
||||
}
|
||||
|
||||
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.fakeExportJob(SLUG, 'zip', 'done');
|
||||
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));
|
||||
expect(res.status).toBe(404);
|
||||
// Refused at the MINT. This used to be asserted one step later, on the download, because the
|
||||
// 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 ({
|
||||
@@ -122,9 +126,9 @@ test.describe('Export — release and download', () => {
|
||||
await db.setExportReleased(SLUG, true);
|
||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||
await db.setExportZipReady(SLUG, true);
|
||||
const ticket = await mintTicket(g.jwt);
|
||||
|
||||
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
||||
expect(res.status).toBe(404);
|
||||
// Same as above: the pre-check resolves the file on disk, so a `done` job whose archive is
|
||||
// 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');
|
||||
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Export — the keepsake has no broken tiles', () => {
|
||||
)
|
||||
.toBe('done');
|
||||
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
@@ -98,8 +98,18 @@ test.describe('Export — the keepsake has no broken tiles', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// THE assertion: nothing rendered broken. Give the images a moment to settle first.
|
||||
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(() =>
|
||||
Array.from(document.querySelectorAll('img'))
|
||||
.filter((i) => i.complete && i.naturalWidth === 0)
|
||||
|
||||
@@ -133,10 +133,14 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
});
|
||||
statuses.push(r.status);
|
||||
}
|
||||
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(
|
||||
0
|
||||
);
|
||||
expect(statuses.some((s) => s === 429), 'the attacker must be throttled').toBe(true);
|
||||
expect(
|
||||
statuses.filter((s) => s === 200),
|
||||
'a wrong PIN must never authenticate'
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
statuses.some((s) => s === 429),
|
||||
'the attacker must be throttled'
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
await db.isPinLocked(g.userId),
|
||||
@@ -255,29 +259,46 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
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 ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// This is the assertion that makes the test non-vacuous: it isolates the RATE LIMIT from the
|
||||
// password logic. If the throttle were removed, the correct password would return 200 here.
|
||||
test('the FAILURE bucket never refuses a correct admin password', async ({ api, adminToken }) => {
|
||||
// This asserted the OPPOSITE — that a throttled IP is refused even with the right password —
|
||||
// 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
|
||||
// `/admin/login` is a publicly linkable page, so a single tight IP bucket charged before the
|
||||
// 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, {
|
||||
rate_limits_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;
|
||||
for (let i = 0; i < 10 && !throttled; i++) {
|
||||
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 bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||
// The limiter is real (above) and yet the operator gets in. That combination is the whole
|
||||
// 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(
|
||||
(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);
|
||||
});
|
||||
|
||||
|
||||
@@ -101,10 +101,26 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
})
|
||||
);
|
||||
const responses = await Promise.all(requests);
|
||||
// All accepted (or some rate-limited — both fine).
|
||||
for (const r of responses) {
|
||||
expect([200, 429]).toContain(r.status);
|
||||
// One session may hold only MAX_TICKETS_PER_SESSION (4) live tickets — enough for a guest with
|
||||
// a couple of tabs open, deliberately not enough for a reconnect loop to accumulate. Minting a
|
||||
// 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.
|
||||
controllers.forEach((c) => c.abort());
|
||||
|
||||
|
||||
@@ -81,7 +81,16 @@ test.describe('Browser chaos — storage purge', () => {
|
||||
|
||||
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.
|
||||
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
||||
});
|
||||
@@ -94,6 +103,12 @@ test.describe('Browser chaos — storage purge', () => {
|
||||
const g = await guest('PurgePin');
|
||||
await signIn(page, g);
|
||||
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.
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -44,7 +44,7 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
|
||||
// click reaches a live handler. See the test above.
|
||||
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');
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
|
||||
@@ -80,8 +80,16 @@ async function exportStatus(jwt: string): Promise<any> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function mintTicket(jwt: string): Promise<string> {
|
||||
const res = await post('/api/v1/export/ticket', jwt);
|
||||
/** The raw mint response. `/export/ticket` pre-validates that the archive is servable, so an
|
||||
* unavailable keepsake is refused HERE — before one of the guest's three daily downloads is
|
||||
* charged for an archive that cannot be served. */
|
||||
function mintTicketResponse(jwt: string, kind: 'zip' | 'html' = 'zip') {
|
||||
return post(`/api/v1/export/ticket?kind=${kind}`, jwt);
|
||||
}
|
||||
|
||||
async function mintTicket(jwt: string, kind: 'zip' | 'html' = 'zip'): Promise<string> {
|
||||
// `kind` is REQUIRED: the ticket is bound to one archive (see TicketKind::Download).
|
||||
const res = await post(`/api/v1/export/ticket?kind=${kind}`, jwt);
|
||||
return (await res.json()).ticket;
|
||||
}
|
||||
|
||||
@@ -294,9 +302,11 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
);
|
||||
expect(ev.released_at, 'reopen clears the release timestamp').toBeNull();
|
||||
|
||||
const ticket = await mintTicket(host.jwt);
|
||||
const dl = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
||||
expect(dl.status, 'a reopened event serves no keepsake').toBe(404);
|
||||
// Refused at the MINT, not one step later at the download: the ticket is bound to an archive,
|
||||
// so the pre-check always knows which one to resolve and answers honestly up front.
|
||||
expect((await mintTicketResponse(host.jwt)).status, 'a reopened event serves no keepsake').toBe(
|
||||
404
|
||||
);
|
||||
});
|
||||
|
||||
test('open ‖ release churn always converges to a consistent, downloadable keepsake', async ({
|
||||
@@ -387,11 +397,12 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
|
||||
expect(
|
||||
res.status,
|
||||
'an upload whose body completed after the release MUST be rejected (403 uploads_locked). ' +
|
||||
'A 201 here means the server accepted a photo it will never put in the keepsake — silent, ' +
|
||||
'permanent data loss.'
|
||||
'an upload whose body completed after the release MUST be rejected (403). A 201 here means ' +
|
||||
'the server accepted a photo it will never put in the keepsake — silent, permanent data loss.'
|
||||
).toBe(403);
|
||||
expect((await res.json()).error).toBe('uploads_locked');
|
||||
// `gallery_released`, matching the pre-flight check: the release is what rejected this, and
|
||||
// that code is the one that PARKS the blob instead of re-pushing it on the retry ladder.
|
||||
expect((await res.json()).error).toBe('gallery_released');
|
||||
|
||||
// And the keepsake holds exactly the one upload that was genuinely committed before the release.
|
||||
await waitExportDone(host.jwt);
|
||||
@@ -679,10 +690,7 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
||||
expect(failed.zip.status).toBe('failed');
|
||||
|
||||
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
|
||||
const ticket = await mintTicket(host.jwt);
|
||||
expect(
|
||||
(await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status
|
||||
).toBe(404);
|
||||
expect((await mintTicketResponse(host.jwt)).status).toBe(404);
|
||||
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
|
||||
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
|
||||
|
||||
|
||||
@@ -36,7 +36,15 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi
|
||||
expect(ok.status, 'upload succeeds once the host reopens').toBeLessThan(300);
|
||||
});
|
||||
|
||||
test('released gallery → 403 uploads_locked (also reversible via reopen)', async ({ host }) => {
|
||||
test('released gallery → 403 gallery_released, the PARKING code (not uploads_locked)', async ({
|
||||
host,
|
||||
}) => {
|
||||
// `release ⇒ lock`, so a released gallery satisfies both conditions and the handler's check
|
||||
// ORDER decides which code the guest gets. It must be the release one, and the difference is
|
||||
// not cosmetic: `uploads_locked` charges a retry attempt and re-pushes the whole photo on the
|
||||
// backoff ladder, against an answer that cannot change until a host acts. `gallery_released`
|
||||
// parks it — blob kept, nothing re-sent, and the guest is told to ask the hosts to reopen.
|
||||
// This asserted `uploads_locked` while the release branch was unreachable dead code.
|
||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||
@@ -49,6 +57,6 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi
|
||||
});
|
||||
expect(rejected.status).toBe(403);
|
||||
const body = await rejected.json();
|
||||
expect(body.error).toBe('uploads_locked');
|
||||
expect(body.error).toBe('gallery_released');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,93 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
import { defineConfig } from 'vite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/** Webfonts the shared theme declares, and where their bytes actually live. */
|
||||
const FONTS = ['Inter', 'Fraunces'];
|
||||
|
||||
/**
|
||||
* Inline the webfonts the shared theme CSS references by ABSOLUTE path.
|
||||
*
|
||||
* `src/tailwind-theme.css` is the design-token source of truth for both the live app and this
|
||||
* viewer, and it declares `src: url('/fonts/Inter.woff2')`. That is correct for the app, which
|
||||
* serves `static/fonts/` from the site root — but the keepsake is opened from `file://` off a USB
|
||||
* stick or a Downloads folder, where `/fonts/...` resolves to the root of the guest's DISK. Both
|
||||
* requests 404, silently: `font-display: swap` means the viewer renders in a fallback system font
|
||||
* with no error, so nothing on the server side can ever report it. The keepsake is the one artifact
|
||||
* the whole event exists to produce, and it was shipping without the typography it was designed in.
|
||||
*
|
||||
* Fixing it in the shared CSS would break the app (a data URI there would inline ~154 KB into every
|
||||
* page load for no reason), and shipping a `fonts/` folder beside `index.html` would give the guest
|
||||
* a directory they can break by moving one file. So the substitution belongs HERE, in the build that
|
||||
* knows its output has no origin: rewrite the emitted HTML only.
|
||||
*
|
||||
* Runs in `generateBundle` rather than `transformIndexHtml` because `viteSingleFile` inlines the
|
||||
* stylesheet during the latter — the `url()` we need to rewrite does not exist in the HTML until
|
||||
* after it has run.
|
||||
*/
|
||||
function inlineThemeFonts() {
|
||||
return {
|
||||
name: 'eventsnap:inline-theme-fonts',
|
||||
enforce: 'post',
|
||||
generateBundle(_options, bundle) {
|
||||
const html = bundle['index.html'];
|
||||
if (!html || typeof html.source !== 'string') return;
|
||||
|
||||
for (const family of FONTS) {
|
||||
const bytes = readFileSync(
|
||||
fileURLToPath(new URL(`../static/fonts/${family}.woff2`, import.meta.url))
|
||||
);
|
||||
const uri = `data:font/woff2;base64,${bytes.toString('base64')}`;
|
||||
const before = html.source;
|
||||
html.source = html.source.replaceAll(`/fonts/${family}.woff2`, uri);
|
||||
// A silent no-op here is the exact failure this plugin exists to prevent, and it
|
||||
// would come back the moment the theme renames a font or switches to a CDN. Fail
|
||||
// the build instead of shipping another keepsake in Times New Roman.
|
||||
if (html.source === before) {
|
||||
this.error(
|
||||
`inline-theme-fonts: no reference to /fonts/${family}.woff2 in the built ` +
|
||||
`keepsake. The shared theme CSS changed how it loads webfonts — update ` +
|
||||
`FONTS in vite.standalone.config.js to match, or the offline viewer will ` +
|
||||
`render in a fallback font.`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// The FONTS loop above only catches a RENAME. It cannot catch an ADDITION, and an addition
|
||||
// is the likelier accident by far: someone doing ordinary app work adds a display font or a
|
||||
// decorative background to the shared theme, has no reason to open a viewer build config,
|
||||
// and ships a keepsake that reaches for `/fonts/Playfair.woff2` on the guest's own disk.
|
||||
// `font-display: swap` hides it, so the artifact looks correct to everyone who happens to
|
||||
// have the file locally, and renders in Times New Roman for the couple.
|
||||
//
|
||||
// So assert the invariant itself rather than a list: nothing in the emitted keepsake may
|
||||
// reference an external URL. Self-maintaining — it covers renames, additions, fonts,
|
||||
// images and stylesheets alike, and nobody has to remember it exists.
|
||||
//
|
||||
// In `writeBundle`, NOT `generateBundle`: the latter runs more than once, and on the
|
||||
// earlier pass the stylesheet has not been inlined yet, so asserting there fails a
|
||||
// perfectly good build. This hook sees only what was actually written.
|
||||
writeBundle(_options, bundle) {
|
||||
const html = bundle['index.html'];
|
||||
if (!html || typeof html.source !== 'string') return;
|
||||
|
||||
const external = [...html.source.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)]
|
||||
.map((m) => m[2].trim())
|
||||
.filter((u) => !u.startsWith('data:'));
|
||||
if (external.length) {
|
||||
this.error(
|
||||
`inline-theme-fonts: the built keepsake still references ${external.length} ` +
|
||||
`external asset(s): ${[...new Set(external)].join(', ')}. The viewer is opened ` +
|
||||
`from file:// with no network and no origin, so every one of these resolves to ` +
|
||||
`the root of the guest's disk and 404s silently. Inline them (see FONTS above) ` +
|
||||
`or remove them from the theme the viewer imports.`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Builds the keepsake viewer as ONE self-contained index.html (all JS + CSS
|
||||
// inlined) so it renders when opened via file://. Uses the plain Svelte plugin
|
||||
@@ -12,14 +99,34 @@ export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
svelte({ configFile: false, preprocess: vitePreprocess(), compilerOptions: { runes: true } }),
|
||||
viteSingleFile()
|
||||
viteSingleFile(),
|
||||
inlineThemeFonts()
|
||||
],
|
||||
resolve: {
|
||||
alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) }
|
||||
},
|
||||
build: {
|
||||
outDir: fileURLToPath(new URL('../../backend/static/export-viewer', import.meta.url)),
|
||||
emptyOutDir: true,
|
||||
// NOT `true`. Vite empties outDir BEFORE generating, so a build that fails late — which is
|
||||
// now a real possibility, since `inlineThemeFonts` calls `this.error` on a keepsake that is
|
||||
// not self-contained — left the directory EMPTY. `include_dir!` over an empty directory
|
||||
// compiles perfectly happily, and `write_viewer_with_data` iterates zero files and returns
|
||||
// Ok, so the next `cargo build` produced a binary whose Memories.zip has the photos and no
|
||||
// viewer at all. Before the guard existed the build could not fail, so neither could this.
|
||||
//
|
||||
// The only output is a single `index.html`, overwritten on every successful build, so
|
||||
// there is nothing to accumulate.
|
||||
//
|
||||
// This protects ONE of the two failure paths, not both. `inlineThemeFonts` errors from
|
||||
// `generateBundle`, before anything is written, so the previous artifact survives intact.
|
||||
// The external-asset assertion errors from `writeBundle` — which runs AFTER Vite has
|
||||
// written `index.html` — so that failure does overwrite the good viewer with the broken
|
||||
// one. It cannot ship: the build exits non-zero, and `the_keepsake_viewer_is_compiled_into_
|
||||
// this_binary` plus the `!html.contains("url(/")` assertion both fail the Rust test suite
|
||||
// before any image is built. But if a `writeBundle` failure is what you are looking at,
|
||||
// restore the artifact with `git checkout backend/static/export-viewer/` rather than
|
||||
// assuming the working copy is still the last good one.
|
||||
emptyOutDir: false,
|
||||
target: 'es2020'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +78,10 @@
|
||||
'<button id="app-boot-reload" class="app-boot__fail-btn">Neu laden</button>' +
|
||||
'</div>';
|
||||
var btn = document.getElementById('app-boot-reload');
|
||||
if (btn) btn.addEventListener('click', function () { location.reload(); });
|
||||
if (btn)
|
||||
btn.addEventListener('click', function () {
|
||||
location.reload();
|
||||
});
|
||||
}, 15000);
|
||||
})();
|
||||
</script>
|
||||
@@ -151,7 +154,12 @@
|
||||
.app-boot__fail {
|
||||
max-width: 20rem;
|
||||
text-align: center;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.app-boot__fail-title {
|
||||
margin: 0 0 0.5rem;
|
||||
@@ -198,7 +206,12 @@
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #faf9f7;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
color: #545350;
|
||||
}
|
||||
html.dark .app-boot__noscript {
|
||||
|
||||
@@ -15,11 +15,28 @@ export class ApiError extends Error {
|
||||
|
||||
const TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Pages that ARE the recovery flow — redirecting from them would loop. */
|
||||
const AUTH_ROUTES = ['/join', '/recover'];
|
||||
/**
|
||||
* Pages that ARE a credential-entry form — redirecting away from them would loop.
|
||||
*
|
||||
* `/admin/login` belongs here: a mistyped admin password 401s, the hard redirect below fired, and
|
||||
* the admin was thrown off their own login form — destroying the error message that would have
|
||||
* told them what happened. The redirect exists to rescue a session that died mid-app; someone
|
||||
* actively typing credentials into a login form does not need rescuing.
|
||||
*
|
||||
* It must be `/admin/login` and NOT the `/admin` prefix. `/admin` would also match the dashboard,
|
||||
* and suppressing `clearAuth()` there is a trap: the dead token stays resident, `admin/+page`
|
||||
* bounces to `/admin/login`, and that page's `getRole() === 'admin'` guard — which decodes the JWT
|
||||
* without checking `exp` — bounces straight back. The result is an unbreakable flip-flop with no
|
||||
* way to reach the login form short of clearing site data, on the one device running the party.
|
||||
*/
|
||||
const AUTH_ROUTES = ['/join', '/recover', '/admin/login'];
|
||||
|
||||
/**
|
||||
* Send a guest whose session died back to the join screen.
|
||||
* Send someone whose session died back to the right credential form.
|
||||
*
|
||||
* Staff go to `/admin/login`, guests to `/join`. Sending an admin whose session expired to the
|
||||
* guest join screen is a dead end: they do not have a name and PIN to type, and the screen gives
|
||||
* them no route back to their own login.
|
||||
*
|
||||
* Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already
|
||||
* imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would
|
||||
@@ -29,9 +46,31 @@ const AUTH_ROUTES = ['/join', '/recover'];
|
||||
*/
|
||||
function redirectToJoin(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (onAuthRoute()) return;
|
||||
const staff = window.location.pathname.startsWith('/admin');
|
||||
window.location.assign(staff ? '/admin/login' : '/join');
|
||||
}
|
||||
|
||||
/**
|
||||
* Are we currently ON a credential-entry page?
|
||||
*
|
||||
* A 401 means two completely different things depending on the answer, and treating them alike
|
||||
* destroyed data (H14). Off these pages it means "your session died" — clear it and rescue the
|
||||
* guest. ON them it means "the credentials you just typed were wrong", which is ordinary form
|
||||
* validation and must change no stored state at all.
|
||||
*
|
||||
* The concrete failure: an authenticated guest taps "Gerät wechseln", mistypes their own name, and
|
||||
* the backend answers 401 (deliberately identical for a wrong PIN and an unknown name — that
|
||||
* indistinguishability is an anti-enumeration property, see `recover`'s dummy bcrypt, so it must not
|
||||
* be "fixed" by making the responses differ). `clearAuth()` then threw away their working token,
|
||||
* and the recover page additionally called `clearPin()` — which discarded the ONLY copy of their
|
||||
* PIN, since localStorage is where it lives and the server holds only the bcrypt. One typo, and
|
||||
* both their session and their credential were gone; rejoining under the same name 409s.
|
||||
*/
|
||||
function onAuthRoute(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const path = window.location.pathname;
|
||||
if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return;
|
||||
window.location.assign('/join');
|
||||
return AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`));
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
@@ -106,7 +145,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
// An expired/invalid token (401) clears the dead session. Banned users are
|
||||
// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
|
||||
// simply get a 403 "gesperrt" toast on writes.
|
||||
if (res.status === 401) {
|
||||
if (res.status === 401 && !onAuthRoute()) {
|
||||
clearAuth();
|
||||
// Clearing auth alone leaves the guest stranded: the bottom nav and FAB are
|
||||
// gated on `isAuthenticated` so they simply vanish, route guards only run in
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
getRole,
|
||||
getUserId,
|
||||
clearAuth,
|
||||
clearPin
|
||||
clearPin,
|
||||
getPinOwner,
|
||||
getDisplayName
|
||||
} from './auth';
|
||||
|
||||
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
|
||||
@@ -115,3 +117,35 @@ describe('auth — JWT claim decode', () => {
|
||||
expect(getRole()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth — the cached PIN knows whose it is', () => {
|
||||
// A host PIN reset also revokes the guest's sessions, so the guest reaches /recover only AFTER
|
||||
// clearAuth has run. /recover clears a rejected cached PIN only when the submitted name matches
|
||||
// this device's — so if the owner name did not survive clearAuth, the dead PIN could never be
|
||||
// cleared and would keep pre-filling the field.
|
||||
it('the PIN owner survives clearAuth, exactly as the PIN itself does', () => {
|
||||
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
|
||||
expect(getPinOwner()).toBe('Alice');
|
||||
|
||||
clearAuth();
|
||||
|
||||
expect(getPin(), 'the PIN is deliberately kept so the guest can recover').toBe('1234');
|
||||
expect(getDisplayName(), 'the display name is wiped for shared-device privacy').toBeNull();
|
||||
expect(getPinOwner(), 'so the PIN owner must be stored separately, or the pair is broken').toBe(
|
||||
'Alice'
|
||||
);
|
||||
});
|
||||
|
||||
it('clearPin drops the owner too — no orphan pointing at a PIN that is gone', () => {
|
||||
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
|
||||
clearPin();
|
||||
expect(getPin()).toBeNull();
|
||||
expect(getPinOwner()).toBeNull();
|
||||
});
|
||||
|
||||
it('no cached PIN means no owner, even if a display name is present', () => {
|
||||
setAuth('a.b.c', null, 'uid-1', 'Alice');
|
||||
expect(getPin()).toBeNull();
|
||||
expect(getPinOwner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,23 @@ import { browser } from '$app/environment';
|
||||
|
||||
const TOKEN_KEY = 'eventsnap_jwt';
|
||||
const PIN_KEY = 'eventsnap_pin';
|
||||
/**
|
||||
* Whose PIN `PIN_KEY` holds — and it is a SEPARATE key from `DISPLAY_NAME_KEY` on purpose.
|
||||
*
|
||||
* `/recover` only clears a rejected cached PIN when the name submitted is the one this device
|
||||
* belongs to, so that a guest who mistypes their own name does not lose the only copy of their PIN
|
||||
* (the server keeps just the bcrypt). That check read `DISPLAY_NAME_KEY` — which `clearAuth`
|
||||
* deletes, for shared-device privacy, one step BEFORE the guest ever reaches `/recover`:
|
||||
*
|
||||
* host taps "PIN zurücksetzen" → the backend also revokes every session for that user
|
||||
* (`host.rs`, `Session::delete_all_for_user`) → the guest's next request 401s → `clearAuth`
|
||||
* → redirect to /join → the guest goes to /recover, where the field is PRE-FILLED with the
|
||||
* dead PIN and can never be cleared, because the name it would be compared against is gone
|
||||
*
|
||||
* Since `clearAuth` deliberately keeps the PIN so the guest can recover, it must keep the PIN's
|
||||
* owner too, or the pair is inconsistent and the guard is unreachable exactly when it is needed.
|
||||
*/
|
||||
const PIN_OWNER_KEY = 'eventsnap_pin_owner';
|
||||
const USER_ID_KEY = 'eventsnap_user_id';
|
||||
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
|
||||
|
||||
@@ -43,9 +60,22 @@ export function getPin(): string | null {
|
||||
export function clearPin(): void {
|
||||
if (!browser) return;
|
||||
localStorage.removeItem(PIN_KEY);
|
||||
localStorage.removeItem(PIN_OWNER_KEY);
|
||||
currentPin.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display name the cached PIN belongs to, or `null` if there is no cached PIN.
|
||||
*
|
||||
* Survives `clearAuth` alongside the PIN itself — see [`PIN_OWNER_KEY`]. Falls back to the auth
|
||||
* display name for devices that cached a PIN before this key existed.
|
||||
*/
|
||||
export function getPinOwner(): string | null {
|
||||
if (!browser) return null;
|
||||
if (localStorage.getItem(PIN_KEY) === null) return null;
|
||||
return localStorage.getItem(PIN_OWNER_KEY) ?? readAuth(DISPLAY_NAME_KEY);
|
||||
}
|
||||
|
||||
export function getUserId(): string | null {
|
||||
return readAuth(USER_ID_KEY);
|
||||
}
|
||||
@@ -81,6 +111,8 @@ export function setAuth(
|
||||
localStorage.setItem(TOKEN_KEY, jwt);
|
||||
if (pin) {
|
||||
localStorage.setItem(PIN_KEY, pin);
|
||||
// Stored with the PIN, not derived from it later — see `PIN_OWNER_KEY`.
|
||||
if (displayName) localStorage.setItem(PIN_OWNER_KEY, displayName);
|
||||
currentPin.set(pin);
|
||||
}
|
||||
localStorage.setItem(USER_ID_KEY, userId);
|
||||
@@ -112,7 +144,7 @@ export function setAdminAuth(jwt: string, userId: string, displayName?: string):
|
||||
|
||||
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
|
||||
// here at import-time so they get reset on every clearAuth path — both the
|
||||
// explicit "Event verlassen" button and the api.ts 401 auto-clear. Keeps
|
||||
// explicit "Abmelden" button and the api.ts 401 auto-clear. Keeps
|
||||
// clearAuth the single source of truth without baking dependencies on every
|
||||
// downstream store into this module (which would create circular imports).
|
||||
const clearAuthHooks: Array<() => void> = [];
|
||||
|
||||
@@ -61,6 +61,9 @@ const KNOWN_EVENTS = [
|
||||
'new-comment',
|
||||
'comment-deleted',
|
||||
'user-hidden',
|
||||
// The mirror of `user-hidden`: a host lifted a ban, so the guest's uploads return to every
|
||||
// feed and the projector, and their own parked upload queue resumes.
|
||||
'user-shown',
|
||||
'event-closed',
|
||||
'event-opened',
|
||||
'event-updated',
|
||||
@@ -144,7 +147,9 @@ export function connectSse(): void {
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) => {
|
||||
noteStreamActivity();
|
||||
dispatch(eventName, (e as MessageEvent).data);
|
||||
const data = (e as MessageEvent).data;
|
||||
noteDelivered(eventName, data);
|
||||
dispatch(eventName, data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,6 +196,10 @@ export function disconnectSse(): void {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// A new stream has delivered nothing, so anything the next delta returns is genuinely
|
||||
// undelivered as far as THIS connection is concerned. Keeping the old set would suppress the
|
||||
// liveness signal for content that arrived during the gap — the opposite failure to H1.
|
||||
forgetDelivered();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
@@ -305,6 +314,85 @@ function dispatch(eventType: string, data: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids the LIVE STREAM has actually delivered to us — the evidence the liveness backstop needs.
|
||||
*
|
||||
* The backstop asks "did the poll find something the stream never pushed?", and the old answer was
|
||||
* simply "did the poll return any rows?". Those are not the same question, and on a live event they
|
||||
* diverge constantly: `dispatch` advances the cursor to an upload's `created_at`, which is always
|
||||
* EARLIER than the `server_time` the previous delta stored, so the cursor rewinds onto
|
||||
* `feed_delta`'s deliberately inclusive `>=` boundary and the poll re-returns an upload the stream
|
||||
* had already delivered a moment ago. Row count > 0, so the backstop concluded the socket was dead
|
||||
* and tore down a perfectly healthy stream — then reset `reconnectAttempt` to 0, bypassing the
|
||||
* jittered backoff. At 100 guests that is roughly one reconnect per second, sustained, all evening,
|
||||
* each costing ~10 queries.
|
||||
*
|
||||
* Bounded FIFO: an event runs for hours and this must not grow without limit. The cap only needs to
|
||||
* exceed what one delta window can return (`DELTA_LIMIT` server-side), because anything older than
|
||||
* the current window cannot be re-returned as "new".
|
||||
*/
|
||||
const DELIVERED_MEMORY = 500;
|
||||
const deliveredIds: string[] = [];
|
||||
const deliveredSet = new Set<string>();
|
||||
|
||||
function rememberDelivered(id: string): void {
|
||||
if (deliveredSet.has(id)) return;
|
||||
deliveredSet.add(id);
|
||||
deliveredIds.push(id);
|
||||
if (deliveredIds.length > DELIVERED_MEMORY) {
|
||||
const evicted = deliveredIds.shift();
|
||||
if (evicted !== undefined) deliveredSet.delete(evicted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record whatever ids a stream payload carried, so a later delta can be recognised as a repeat.
|
||||
*
|
||||
* Only the id that IS the thing the liveness check tests, per event — not every id field present.
|
||||
*
|
||||
* This used to harvest `id`, `upload_id` and `user_id` from every payload, which quietly disarmed
|
||||
* two thirds of the backstop. `new-upload` carries `id` + `user_id`, and `like-update` /
|
||||
* `new-comment` carry `upload_id` + `user_id`, so by the time anything was deleted or anyone was
|
||||
* banned their ids were already in `deliveredSet` — recorded from ordinary traffic about content
|
||||
* that was still perfectly live. `carried`'s `deleted_ids` and `hidden_user_ids` clauses were then
|
||||
* false essentially always.
|
||||
*
|
||||
* The cost: a socket that goes half-open (a phone roaming APs leaves `readyState === OPEN`, so the
|
||||
* cheap check misses it) is only noticed once a genuinely NEW upload appears. A host moderating
|
||||
* three photos, or banning a guest, produced a delta whose every id was "already delivered" — so
|
||||
* the stream stayed dead and the host kept moderating into a feed nobody's app was listening to.
|
||||
*/
|
||||
function noteDelivered(eventName: string, data: string): void {
|
||||
try {
|
||||
const p = JSON.parse(data) as { id?: unknown; upload_id?: unknown; user_id?: unknown };
|
||||
// Mirrors the three clauses in `carried`: uploads by upload id, deletions by upload id,
|
||||
// ban-hides by user id.
|
||||
const relevant =
|
||||
eventName === 'new-upload'
|
||||
? p.id
|
||||
: // `upload-processed` carries `upload_id`, not `id` (see compression.rs) — reading
|
||||
// `p.id` recorded nothing at all, so this branch quietly did the opposite of what
|
||||
// the comment above claims. Harmless today only because the delta cursor is
|
||||
// anchored by `new-upload`, which is not a property worth depending on.
|
||||
eventName === 'upload-processed' || eventName === 'upload-deleted'
|
||||
? (p.upload_id ?? p.id)
|
||||
: // Only `user-hidden` has a matching clause in `carried` (`hidden_user_ids` comes
|
||||
// from `uploads_hidden = TRUE`). Recording an UNBAN's user id could only ever
|
||||
// suppress a later genuine signal, so it is deliberately not recorded.
|
||||
eventName === 'user-hidden'
|
||||
? p.user_id
|
||||
: undefined;
|
||||
if (typeof relevant === 'string') rememberDelivered(relevant);
|
||||
} catch {
|
||||
// non-JSON payload — nothing to record
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset on disconnect: a fresh stream has delivered nothing yet. */
|
||||
function forgetDelivered(): void {
|
||||
deliveredIds.length = 0;
|
||||
deliveredSet.clear();
|
||||
}
|
||||
|
||||
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
|
||||
function extractCreatedAt(data: string): string | undefined {
|
||||
try {
|
||||
@@ -333,10 +421,18 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
// "Did the poll find something the STREAM never delivered?" — not "did it return rows?".
|
||||
// See `deliveredIds`: on a live event the cursor rewinds onto the inclusive `>=` boundary
|
||||
// and re-returns uploads the stream already pushed, so a row count made every healthy
|
||||
// stream look dead and produced a sustained reconnect storm.
|
||||
//
|
||||
// Ids the stream delivered while we were connected are excluded. Anything genuinely new —
|
||||
// including everything that arrived while the socket was half-open — still counts, which is
|
||||
// the signal this backstop exists for.
|
||||
return (
|
||||
response.uploads.length > 0 ||
|
||||
response.deleted_ids.length > 0 ||
|
||||
response.hidden_user_ids.length > 0
|
||||
response.uploads.some((u) => !deliveredSet.has(u.id)) ||
|
||||
response.deleted_ids.some((id) => !deliveredSet.has(id)) ||
|
||||
response.hidden_user_ids.some((id) => !deliveredSet.has(id))
|
||||
);
|
||||
} catch (e) {
|
||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||
|
||||
@@ -142,7 +142,6 @@ describe('shouldAbortForStall', () => {
|
||||
expect(shouldAbortForStall(now - 91_000, now, true)).toBe(false);
|
||||
expect(shouldAbortForStall(now - 121_000, now, true)).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import { uuid } from '$lib/uuid';
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth';
|
||||
import { onSseEvent } from './sse';
|
||||
@@ -215,6 +216,21 @@ interface QueueEntry {
|
||||
* Cleared by `retryItem` — an explicit tap is the guest changing their mind.
|
||||
*/
|
||||
cancelled?: boolean;
|
||||
/**
|
||||
* Parked waiting for a specific host action, with the blob intact.
|
||||
*
|
||||
* Both values are reversible 403s whose answer cannot change without somebody deciding to
|
||||
* change it, which makes automatic retries pure waste: they re-pushed the whole photo over
|
||||
* cellular on every budget refill for the rest of the night while the guest was told to tap
|
||||
* a camera button that 403s.
|
||||
*
|
||||
* - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`.
|
||||
* - `'unban'` — the uploader is banned (`user_banned`). Cleared by `user-shown`.
|
||||
*
|
||||
* An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try
|
||||
* anyway, and if the condition still holds the next response simply re-parks it.
|
||||
*/
|
||||
parkedFor?: 'reopen' | 'unban';
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
@@ -266,7 +282,7 @@ onClearAuth(() => queueItems.set([]));
|
||||
let sseBound = false;
|
||||
function bindSse(): void {
|
||||
if (sseBound || typeof window === 'undefined') return;
|
||||
const resume = (options: { resetAttempts?: boolean } = {}) => {
|
||||
const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => {
|
||||
void (async () => {
|
||||
await requeueRetriable(options);
|
||||
await processQueue();
|
||||
@@ -275,7 +291,20 @@ function bindSse(): void {
|
||||
// A reopen is a deliberate host action that changes the server's answer, so it's fair to
|
||||
// give parked items a fresh retry budget. A plain reconnect is not — that's the signal
|
||||
// that fires over and over on a flapping AP.
|
||||
onSseEvent('event-opened', () => resume({ resetAttempts: true }));
|
||||
onSseEvent('event-opened', () => resume({ resetAttempts: true, release: 'reopen' }));
|
||||
// An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to
|
||||
// everyone (every feed needs to un-hide that user's photos), so check it is actually us
|
||||
// before resuming — otherwise one guest's unban would resume every OTHER banned guest's
|
||||
// queue straight into another 403.
|
||||
onSseEvent('user-shown', (payload) => {
|
||||
let userId: unknown;
|
||||
try {
|
||||
userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' });
|
||||
});
|
||||
onSseEvent('feed-delta', () => resume());
|
||||
sseBound = true;
|
||||
}
|
||||
@@ -293,8 +322,14 @@ bindSse();
|
||||
*
|
||||
* `resetAttempts` is for signals that are positive evidence the blocking condition changed
|
||||
* (the host reopening the event), where starting the budget over is warranted.
|
||||
*
|
||||
* `release` names the host action that just happened, and un-parks only the items that were
|
||||
* waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and
|
||||
* an unban must not resume uploads into a released gallery — both would just 403 again.
|
||||
*/
|
||||
async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Promise<void> {
|
||||
async function requeueRetriable(
|
||||
options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}
|
||||
): Promise<void> {
|
||||
const myUserId = getUserId();
|
||||
const all = await storeGetAll();
|
||||
const now = Date.now();
|
||||
@@ -306,6 +341,13 @@ async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Prom
|
||||
// not even on `resetAttempts` (the host reopening the event says nothing about whether
|
||||
// this guest still wants this photo sent). Only `retryItem` clears it.
|
||||
if (entry.cancelled) continue;
|
||||
// Parked waiting on a host action. Only the matching signal releases it, so a plain
|
||||
// reconnect leaves it alone instead of re-pushing the photo at a server whose answer
|
||||
// cannot have changed. A manual "Erneut" bypasses this via `retryItem`.
|
||||
if (entry.parkedFor) {
|
||||
if (entry.parkedFor !== options.release) continue;
|
||||
entry.parkedFor = undefined;
|
||||
}
|
||||
if (options.resetAttempts) {
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
@@ -612,6 +654,31 @@ class AuthError extends Error {}
|
||||
*/
|
||||
class LockedError extends Error {}
|
||||
|
||||
/**
|
||||
* The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every
|
||||
* blob-preserving code path below keeps treating it as a reversible lock (the host *can* still
|
||||
* reopen, and losing a photo is the worst outcome).
|
||||
*
|
||||
* What differs is the retry policy. A closed event is a pause the host means to undo, so
|
||||
* auto-resuming on reconnect is right. A released gallery is the end of the event, and in the
|
||||
* normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over
|
||||
* cellular on every budget refill, forever, for an answer that will not change, while the guest
|
||||
* is told to tap a camera button that 403s. Items parked this way sit still (see
|
||||
* `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest
|
||||
* retries by hand.
|
||||
*/
|
||||
class ReleasedError extends LockedError {}
|
||||
|
||||
/**
|
||||
* The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives:
|
||||
* `unban_user` exists and the host's confirm copy promises the photos come back, which the old
|
||||
* generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row
|
||||
* moved to `blocked`, and `blocked` has no retry button).
|
||||
*
|
||||
* Parks still like `ReleasedError` and waits for `user-shown`.
|
||||
*/
|
||||
class BannedError extends LockedError {}
|
||||
|
||||
/** Retry policy for an upload response status. */
|
||||
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
|
||||
|
||||
@@ -656,11 +723,36 @@ export function classifyUploadStatus(status: number): UploadOutcome {
|
||||
export function isReversibleLock(status: number, errorCode: unknown): boolean {
|
||||
return (
|
||||
errorCode === 'uploads_locked' ||
|
||||
errorCode === 'gallery_released' ||
|
||||
// A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging
|
||||
// the blob here made that promise impossible to keep for anything mid-flight.
|
||||
errorCode === 'user_banned' ||
|
||||
errorCode === 'quota_exceeded' ||
|
||||
(status === 403 && errorCode !== 'forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the reversible-lock bucket, is this the END of the event rather than a pause?
|
||||
*
|
||||
* `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host
|
||||
* reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure +
|
||||
* exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets
|
||||
* re-pushed over cellular all night.
|
||||
*/
|
||||
export function isGalleryReleased(errorCode: unknown): boolean {
|
||||
return errorCode === 'gallery_released';
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on
|
||||
* its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the
|
||||
* photo on every reconnect at a guest who is currently not allowed to upload.
|
||||
*/
|
||||
export function isUserBanned(errorCode: unknown): boolean {
|
||||
return errorCode === 'user_banned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the
|
||||
* field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried
|
||||
@@ -716,6 +808,35 @@ export async function loadQueue(): Promise<void> {
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release parked items whose blocking condition is already over, using the authoritative state
|
||||
* the app fetches at boot.
|
||||
*
|
||||
* `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE
|
||||
* `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the
|
||||
* host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they
|
||||
* close the app at the end of the night, and the host lifts the ban or reopens uploads the next
|
||||
* morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast
|
||||
* had promised "wird gesendet, sobald die Sperre aufgehoben ist".
|
||||
*
|
||||
* Called once per boot with what `/me/context` and the event state actually say, so a park can
|
||||
* never outlive the condition it was waiting on. Cheap: a no-op unless something is parked.
|
||||
*/
|
||||
export async function releaseResolvedParks(state: {
|
||||
banned: boolean;
|
||||
uploadsOpen: boolean;
|
||||
}): Promise<void> {
|
||||
// Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says
|
||||
// nothing about whether the gallery reopened, and vice versa.
|
||||
if (!state.banned) {
|
||||
await requeueRetriable({ resetAttempts: true, release: 'unban' });
|
||||
}
|
||||
if (state.uploadsOpen) {
|
||||
await requeueRetriable({ resetAttempts: true, release: 'reopen' });
|
||||
}
|
||||
await processQueue();
|
||||
}
|
||||
|
||||
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
||||
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
||||
@@ -765,7 +886,7 @@ export async function addToQueue(
|
||||
|
||||
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
|
||||
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
||||
const id = crypto.randomUUID();
|
||||
const id = uuid();
|
||||
const entry: QueueEntry = {
|
||||
id,
|
||||
userId,
|
||||
@@ -813,6 +934,10 @@ export async function retryItem(id: string): Promise<void> {
|
||||
// And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped
|
||||
// themselves is them changing their mind.
|
||||
entry.cancelled = false;
|
||||
// Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may
|
||||
// have reopened or unbanned without this device seeing the SSE). If the condition still
|
||||
// holds the next response re-parks it, so this cannot become a loop.
|
||||
entry.parkedFor = undefined;
|
||||
await storePut(entry);
|
||||
|
||||
queueItems.update((items) =>
|
||||
@@ -1003,6 +1128,17 @@ async function uploadItem(id: string): Promise<void> {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/v1/upload');
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
// The SAME idempotency key as the multipart field above, in a header.
|
||||
//
|
||||
// The field alone cannot be read until the body is being parsed, which is after the
|
||||
// release/lock pre-flight — so a retry of a photo that was already stored got
|
||||
// `gallery_released` instead of its original row, and the guest was told a photo that
|
||||
// IS in the gallery had not been sent. The only remedy on offer (ask the hosts to
|
||||
// reopen) bumps the export epoch and destroys the released keepsake.
|
||||
//
|
||||
// A header arrives with the request line, so the server can replay before it decides
|
||||
// anything about locks. The field stays for the concurrent case and as the fallback.
|
||||
xhr.setRequestHeader('X-Client-Upload-Id', entry.id);
|
||||
// Wall-clock backstop only — generous enough that a slow-but-alive LTE upload is
|
||||
// never killed by it. See MIN/MAX_UPLOAD_TIMEOUT_MS.
|
||||
xhr.timeout = Math.min(
|
||||
@@ -1124,7 +1260,16 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// portal) must NOT purge the blob — losing a photo is the worst outcome, and
|
||||
// 403 is the reversible-lock status here.
|
||||
if (isReversibleLock(xhr.status, body?.error)) {
|
||||
settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.')));
|
||||
const msg = body?.message || 'Event ist geschlossen.';
|
||||
settle(() =>
|
||||
reject(
|
||||
isGalleryReleased(body?.error)
|
||||
? new ReleasedError(msg)
|
||||
: isUserBanned(body?.error)
|
||||
? new BannedError(msg)
|
||||
: new LockedError(msg)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Any other 4xx the server will keep rejecting (banned, too large, wrong
|
||||
@@ -1190,20 +1335,31 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// the hourly media reclaim puts them back under it). KEEP the blob and park the item
|
||||
// as retryable so it survives until then. `event-opened` and the `feed-delta`
|
||||
// reconnect both auto-resume it; a manual "Erneut" also works. Never purge here.
|
||||
const exhausted = chargeAttempt(entry);
|
||||
// Neither a release nor a ban lifts on its own, so charging an attempt — and with it
|
||||
// the backoff ladder and budget refill that drive automatic re-pushes — buys nothing
|
||||
// but bandwidth. Park those still and wait for the host action.
|
||||
const parkedFor: 'reopen' | 'unban' | undefined =
|
||||
e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined;
|
||||
const exhausted = parkedFor ? false : chargeAttempt(entry);
|
||||
entry.status = 'error';
|
||||
entry.error = withRetryHint(e.message, exhausted);
|
||||
entry.parkedFor = parkedFor;
|
||||
entry.error = parkedFor ? e.message : withRetryHint(e.message, exhausted);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', entry.error);
|
||||
// Say it out loud. The queue list is only mounted on /upload and the composer sends
|
||||
// the guest straight to /feed, so this message otherwise lands in a store that
|
||||
// nothing on screen renders — the photo just never appears and the guest, with no
|
||||
// operator to ask, assumes it worked.
|
||||
toast(
|
||||
`${entry.fileName}: ${e.message} Du findest den Upload über den Kamera-Button.`,
|
||||
'warning',
|
||||
6000
|
||||
);
|
||||
//
|
||||
// For a release, be explicit that the photo is NOT lost and NOT coming back on its
|
||||
// own — that is the whole difference the guest needs to act on.
|
||||
const parkedHint =
|
||||
parkedFor === 'reopen'
|
||||
? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.'
|
||||
: parkedFor === 'unban'
|
||||
? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.'
|
||||
: ' Du findest den Upload über den Kamera-Button.';
|
||||
toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000);
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof AuthError) {
|
||||
|
||||
47
frontend/src/lib/uuid.test.ts
Normal file
47
frontend/src/lib/uuid.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { uuid } from './uuid';
|
||||
|
||||
const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('uuid', () => {
|
||||
it('uses crypto.randomUUID when it is available', () => {
|
||||
const spy = vi.spyOn(crypto, 'randomUUID');
|
||||
expect(uuid()).toMatch(V4);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The case that matters: an idempotency key is now minted on the JOIN path, and a TypeError
|
||||
// there surfaces as a generic error the guest cannot get past — no account yet, so /recover is
|
||||
// no help either. `crypto.randomUUID` needs a secure context and Safari >= 15.4.
|
||||
it('falls back to getRandomValues when randomUUID is missing', () => {
|
||||
const real = crypto.getRandomValues.bind(crypto);
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: real
|
||||
// randomUUID deliberately absent
|
||||
});
|
||||
|
||||
const id = uuid();
|
||||
expect(id, 'the fallback must still produce a well-formed v4 UUID').toMatch(V4);
|
||||
});
|
||||
|
||||
it('the fallback sets the version and variant bits, not just random hex', () => {
|
||||
// Every byte 0x00 — so version/variant nibbles can only be right if they are set explicitly.
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: (a: Uint8Array) => a.fill(0)
|
||||
});
|
||||
expect(uuid()).toBe('00000000-0000-4000-8000-000000000000');
|
||||
});
|
||||
|
||||
it('produces distinct values through the fallback', () => {
|
||||
const real = crypto.getRandomValues.bind(crypto);
|
||||
vi.stubGlobal('crypto', { getRandomValues: real });
|
||||
const ids = new Set(Array.from({ length: 200 }, () => uuid()));
|
||||
expect(ids.size, 'a collision would replay one guest join or upload onto another').toBe(200);
|
||||
});
|
||||
});
|
||||
37
frontend/src/lib/uuid.ts
Normal file
37
frontend/src/lib/uuid.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A v4 UUID, with a fallback for environments where `crypto.randomUUID` is missing.
|
||||
*
|
||||
* `crypto.randomUUID` needs Safari ≥ 15.4 / Chrome ≥ 92 **and a secure context**. Production is
|
||||
* HTTPS (Caddy terminates TLS for `{$DOMAIN}`), so the realistic gap is an old phone — but the
|
||||
* consequence changed when idempotency keys moved onto the join path. Previously such a device
|
||||
* joined and browsed fine and only failed at upload, a degraded but survivable experience. Now the
|
||||
* `TypeError` is thrown inside `handleJoin`'s `try` and surfaces as the generic
|
||||
* "Ein Fehler ist aufgetreten.", identically on every retry: the guest cannot join, cannot browse,
|
||||
* and cannot use `/recover` either, because they have no account yet. It is the one screen in the
|
||||
* app where a hard failure leaves no way out at all.
|
||||
*
|
||||
* The fallback is `crypto.getRandomValues` (universally available, no secure-context requirement)
|
||||
* with the version and variant bits set per RFC 4122 §4.4. `Math.random` is deliberately NOT a
|
||||
* further fallback: these values are idempotency keys, and a collision between two guests would
|
||||
* replay one guest's join or upload to another. If there is no CSPRNG at all, throwing is correct.
|
||||
*/
|
||||
export function uuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
|
||||
|
||||
const hex: string[] = [];
|
||||
for (const b of bytes) hex.push(b.toString(16).padStart(2, '0'));
|
||||
return [
|
||||
hex.slice(0, 4).join(''),
|
||||
hex.slice(4, 6).join(''),
|
||||
hex.slice(6, 8).join(''),
|
||||
hex.slice(8, 10).join(''),
|
||||
hex.slice(10, 16).join('')
|
||||
].join('-');
|
||||
}
|
||||
@@ -9,11 +9,17 @@
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue';
|
||||
import {
|
||||
queueItems,
|
||||
isProcessing,
|
||||
loadQueue,
|
||||
rateLimitRetryAt,
|
||||
releaseResolvedParks
|
||||
} from '$lib/upload-queue';
|
||||
import { privacyNote } from '$lib/privacy-note-store';
|
||||
import { refreshQuota } from '$lib/quota-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
import { api } from '$lib/api';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
|
||||
import { setRole } from '$lib/role-store';
|
||||
@@ -88,9 +94,72 @@
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
isBanned.set(ctx.is_banned);
|
||||
} catch {
|
||||
// Now that we know the AUTHORITATIVE state, release anything the queue parked
|
||||
// waiting on a host action that has already happened. The live `user-shown` /
|
||||
// `event-opened` events only reach a tab that was open when the host acted, and
|
||||
// the usual sequence is the other way round: the guest closes the app, the host
|
||||
// lifts the ban or reopens uploads later. Without this the photo stays parked
|
||||
// forever, which is exactly the "my pictures never sent" the host gets asked about.
|
||||
void releaseResolvedParks({
|
||||
banned: ctx.is_banned,
|
||||
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
|
||||
});
|
||||
} catch (err) {
|
||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||
// a session land on /join anyway, and the per-page mount will retry.
|
||||
//
|
||||
// But ONE consequence is not recoverable by a per-page mount: the park release
|
||||
// above. A parked photo's other two release paths are the live `user-shown` /
|
||||
// `event-opened` SSE events, and the routes that open a stream are /feed, /diashow,
|
||||
// /export, /host and /admin — NOT /upload, which is exactly where the toast sends
|
||||
// the guest to watch their queue. So on venue wifi, the condition this branch
|
||||
// exists for, one failed request could strand the photo for the whole session with
|
||||
// the queue row still reading "Du bist gesperrt.". Retry once, briefly.
|
||||
//
|
||||
// NOT on a 401. `api.get` already answered that one by calling `clearAuth()` and
|
||||
// redirecting to /join, so there is no session left to hydrate and a second attempt
|
||||
// can only fire a SECOND redirect — two seconds later, by which time the guest may
|
||||
// have navigated somewhere else. Retry the transient case this exists for (offline,
|
||||
// 5xx, a dropped request) and nothing else.
|
||||
//
|
||||
// A guard, not an early `return`: everything below this block — `refreshQuota`
|
||||
// and, outside it, every SSE listener registration — still has to run.
|
||||
const worthRetrying = !(err instanceof ApiError && err.status === 401);
|
||||
if (worthRetrying) {
|
||||
// DETACHED, not awaited. Everything below — including every SSE listener
|
||||
// registered outside this block — used to sit behind it, and the worst case is
|
||||
// a 20 s request timeout + 2 s backoff + a second 20 s timeout: ~42 s during
|
||||
// which `pin-reset`, `user-hidden`/`user-shown`, `event-closed`/`event-opened`
|
||||
// and `event-updated` are dispatched to an empty handler list. On `main` the
|
||||
// exposure was one timeout; awaiting the retry doubled it, on exactly the wifi
|
||||
// the retry exists for.
|
||||
//
|
||||
// Five of the six self-heal (`/feed`'s mount re-reads them, and the upload
|
||||
// queue binds `event-opened`/`user-shown` at module scope, so parked photos
|
||||
// still release). `pin-reset` does NOT: nothing else clears the cached
|
||||
// plaintext PIN, so a missed one leaves a dead PIN displayed in "Mein Konto"
|
||||
// and pre-filling /recover.
|
||||
//
|
||||
// Detaching costs nothing — the retry only writes stores and releases parks,
|
||||
// and no code below reads its result.
|
||||
void (async () => {
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const ctx = await api.get<MeContextDto>('/me/context');
|
||||
isBanned.set(ctx.is_banned);
|
||||
eventState.set({
|
||||
uploadsLocked: ctx.uploads_locked,
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
void releaseResolvedParks({
|
||||
banned: ctx.is_banned,
|
||||
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
|
||||
});
|
||||
} catch {
|
||||
// Still down. The "Erneut" button on the parked row remains the way back.
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
void refreshQuota();
|
||||
}
|
||||
@@ -115,8 +184,7 @@
|
||||
// `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards
|
||||
// from every feed), so only OUR id means us. Without this, `isBanned` was seeded once
|
||||
// on boot and never moved — a guest banned mid-party kept the full UI and learned
|
||||
// about it one 403 toast at a time, which reads as the app being broken. The ban is
|
||||
// one-way here on purpose: an unban has no SSE, and the next `/me/context` clears it.
|
||||
// about it one 403 toast at a time, which reads as the app being broken.
|
||||
onSseEvent('user-hidden', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
@@ -125,6 +193,18 @@
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// And the mirror. This used to be one-way ("an unban has no SSE, and the next
|
||||
// `/me/context` clears it") because `unban_user` broadcast nothing at all — so a
|
||||
// guest whose ban was lifted kept the banned UI until they happened to reload,
|
||||
// which for a PWA with no URL bar is not a thing they can easily do.
|
||||
onSseEvent('user-shown', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
if (payload.user_id === getUserId()) isBanned.set(false);
|
||||
} catch {
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// Reflect a host closing/reopening uploads live, so the composer switches to a
|
||||
// locked state immediately instead of a guest finding out via a rejected upload.
|
||||
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getToken, getDisplayName, getExpiry, clearAuth, currentPin } from '$lib/auth';
|
||||
import { role, setRole } from '$lib/role-store';
|
||||
import { clearQueue } from '$lib/upload-queue';
|
||||
import { api } from '$lib/api';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { dataMode } from '$lib/data-mode-store';
|
||||
import { themePreference, type ThemePreference } from '$lib/theme-store';
|
||||
@@ -14,6 +14,7 @@
|
||||
import { avatarPalette, initials } from '$lib/avatar';
|
||||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||||
import { vibrate } from '$lib/haptics';
|
||||
import { toast } from '$lib/toast-store';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
|
||||
let displayName = $state<string | null>(null);
|
||||
@@ -124,6 +125,40 @@
|
||||
goto('/join');
|
||||
}
|
||||
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
/**
|
||||
* Erase this account. Unlike `handleLogout`, the server call is NOT best-effort — if it fails
|
||||
* we must keep the guest signed in and say so, because clearing local auth on a failed delete
|
||||
* would leave them believing their photos were gone while every one of them is still live.
|
||||
*/
|
||||
async function handleDeleteAccount() {
|
||||
if (deleting) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await api.delete('/me');
|
||||
} catch (e) {
|
||||
deleteConfirmOpen = false;
|
||||
deleting = false;
|
||||
toast(
|
||||
e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen. Bitte versuch es erneut.',
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Only now is it safe to tear down locally. The queue must go too: its blobs are this
|
||||
// guest's photos, and leaving them would re-upload everything on the next session.
|
||||
try {
|
||||
await clearQueue();
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
clearAuth();
|
||||
goto('/join');
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
@@ -517,7 +552,13 @@
|
||||
</a>
|
||||
|
||||
<!-- Leave / logout (this device) -->
|
||||
<!-- `data-testid` rather than an accessible-name locator: this trigger and the
|
||||
confirm sheet's button are BOTH labelled "Abmelden", so `getByRole('button',
|
||||
{ name: /abmelden/i })` is ambiguous the moment the sheet opens. The rename
|
||||
away from "Event verlassen" silently broke three e2e locators — including the
|
||||
one behind the smoke spec — precisely because they keyed on visible copy. -->
|
||||
<button
|
||||
data-testid="account-logout"
|
||||
onclick={() => {
|
||||
leaveEverywhere = false;
|
||||
leaveConfirmOpen = true;
|
||||
@@ -570,6 +611,32 @@
|
||||
>Auf allen Geräten abmelden</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<!-- Erasure. The join page's data notice promises this exists, and until now nothing
|
||||
did: there was no user-deletion route at any role, so honouring "please remove my
|
||||
photos" meant hand-written SQL against production. -->
|
||||
<button
|
||||
data-testid="account-delete"
|
||||
onclick={() => (deleteConfirmOpen = true)}
|
||||
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-red-500 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400"
|
||||
>Konto und alle Fotos löschen</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -624,3 +691,15 @@
|
||||
onConfirm={() => handleLogout(leaveEverywhere)}
|
||||
onCancel={() => (leaveConfirmOpen = false)}
|
||||
/>
|
||||
|
||||
<!-- Erasure confirm. Separate sheet from the logout one because the copy has to be unambiguous
|
||||
about the difference: logging out keeps everything, this destroys it. -->
|
||||
<ConfirmSheet
|
||||
open={deleteConfirmOpen}
|
||||
title="Konto und alle Fotos löschen?"
|
||||
message="Alle deine Fotos, Bildtexte, Kommentare und Likes werden endgültig gelöscht — auch aus der Diashow und aus dem Erinnerungs-Archiv. Das lässt sich nicht rückgängig machen."
|
||||
confirmLabel="Endgültig löschen"
|
||||
tone="danger"
|
||||
onConfirm={handleDeleteAccount}
|
||||
onCancel={() => (deleteConfirmOpen = false)}
|
||||
/>
|
||||
|
||||
@@ -195,12 +195,12 @@
|
||||
clearTimeout(timer);
|
||||
action();
|
||||
};
|
||||
// Always strictly less than the dwell — see PRELOAD_HEADROOM_MS.
|
||||
const preloadBudget = Math.max(
|
||||
250,
|
||||
Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS)
|
||||
);
|
||||
timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget);
|
||||
// Always strictly less than the dwell — see PRELOAD_HEADROOM_MS.
|
||||
const preloadBudget = Math.max(
|
||||
250,
|
||||
Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS)
|
||||
);
|
||||
timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget);
|
||||
const pre = new Image();
|
||||
pre.src = candidates[i];
|
||||
pre.decode().then(
|
||||
@@ -445,6 +445,12 @@
|
||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
||||
// An unban restores that guest's photos to the eligible set. The periodic `reconcile`
|
||||
// below would pick this up on its own, but this is the projector nobody is standing at —
|
||||
// waiting a whole interval to un-hide photos the host has just decided are fine again is
|
||||
// needlessly visible. A full reconcile is the only correct response anyway: the hidden
|
||||
// cards were dropped from local state, so there is nothing to restore in place.
|
||||
unsubs.push(onSseEvent('user-shown', () => void reconcile()));
|
||||
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
|
||||
// Open the stream ourselves — a kiosk/projector loads /diashow directly (never
|
||||
// via /feed), so we can't rely on another page having opened the singleton
|
||||
@@ -515,8 +521,8 @@
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-4 left-4 max-w-xs rounded-md bg-black/60 px-3 py-2 text-left text-xs text-white/70 backdrop-blur"
|
||||
>
|
||||
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische
|
||||
Bildschirmsperre am Gerät deaktivieren.
|
||||
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische Bildschirmsperre
|
||||
am Gerät deaktivieren.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -126,9 +126,7 @@
|
||||
// they did, the guest got a green success toast, a consumed single-use ticket, one
|
||||
// of three daily slots gone, and nothing in their Downloads. Now the mint fails
|
||||
// honestly and lands in the `catch` below.
|
||||
const { ticket } = await api.post<{ ticket: string }>(
|
||||
`/export/ticket?kind=${kind}`
|
||||
);
|
||||
const { ticket } = await api.post<{ ticket: string }>(`/export/ticket?kind=${kind}`);
|
||||
downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
|
||||
// An iframe download produces NO visible change: no spinner, no navigation, and
|
||||
// on mobile often no browser chrome either. Without a word here the guest cannot
|
||||
|
||||
@@ -308,7 +308,11 @@
|
||||
unsubscribers.push(
|
||||
onSseEvent('new-upload', (data) => {
|
||||
try {
|
||||
const upload: FeedUpload = JSON.parse(data);
|
||||
// The `new-upload` payload is the backend's `UploadDto`, which carries `hashtags`
|
||||
// on top of what `FeedUpload` declares — needed for the filter check below.
|
||||
// Typed explicitly rather than widening `FeedUpload`, because the feed's own
|
||||
// rows (from `/feed`) genuinely do not include them.
|
||||
const upload: FeedUpload & { hashtags?: string[] } = JSON.parse(data);
|
||||
// GRID view must NOT prepend live. Its rows are POSITIONAL windows
|
||||
// (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts
|
||||
// every tile by one slot: each row's keyed `{#each}` then sees a different set of
|
||||
@@ -329,6 +333,12 @@
|
||||
// row, and a duplicate id in a keyed `{#each}` is a thrown error, not a
|
||||
// cosmetic glitch.
|
||||
if (uploads.some((u) => u.id === upload.id)) return;
|
||||
// Respect the active filter (H13). `uploads` IS the filtered set — the server
|
||||
// applied `filterParams()` — but this handler prepended unconditionally, so a
|
||||
// guest who filtered to #tanzflaeche had that filter quietly destroyed within
|
||||
// minutes by everyone else's uploads, with no indication and no way back short
|
||||
// of toggling the chip. The payload carries `hashtags`, so we can just check.
|
||||
if (selectedHashtag && !upload.hashtags?.includes(selectedHashtag)) return;
|
||||
uploads = [upload, ...uploads];
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -372,6 +382,17 @@
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// The ban was lifted — their cards must come back. Unlike a hide we cannot do this in
|
||||
// place: the cards were filtered out of `uploads`, so there is nothing left to restore
|
||||
// from. Flag the feed stale and let the existing tap-to-refresh pill resync, which is
|
||||
// the same treatment a truncated delta gets and keeps the guest's scroll position.
|
||||
//
|
||||
// Without this the unban was invisible to every open feed and to the unattended
|
||||
// projector until somebody reloaded by hand — while the host's confirm copy promised
|
||||
// the photos were back.
|
||||
onSseEvent('user-shown', () => {
|
||||
feedStale = true;
|
||||
}),
|
||||
// Patch the single affected card in place from the SSE payload instead of
|
||||
// refetching page 1 — a busy event fires these constantly and a full reload
|
||||
// would yank every scrolled-down user back to the top on each reaction.
|
||||
@@ -406,9 +427,49 @@
|
||||
return;
|
||||
}
|
||||
if (delta.uploads.length) {
|
||||
// `/feed/delta` takes NO filter parameters, so its rows are the unfiltered
|
||||
// event. Merging them into a filtered view is the same defect as the
|
||||
// `new-upload` prepend above (H13) — and here we cannot check hashtags,
|
||||
// because the delta rows do not carry them.
|
||||
//
|
||||
// So when a filter is active, don't merge: flag the feed stale and let the
|
||||
// existing tap-to-refresh pill re-run page 1 under `filterParams()`. Same
|
||||
// treatment a truncated delta gets, and it keeps the guest's scroll.
|
||||
// Dedupe FIRST, in both branches. `delta.uploads.length > 0` is not
|
||||
// evidence that anything NEW arrived: the delta cursor boundary is
|
||||
// inclusive and `sse.ts` deliberately rewinds `lastEventTime` to an
|
||||
// upload's `created_at`, so a delta routinely re-returns rows the
|
||||
// stream already delivered. The filtered branch skipped this check
|
||||
// entirely and set `feedStale` on EVERY delta — and the backstop
|
||||
// polls every 60-120s, so a guest who tapped a hashtag got a "Neue
|
||||
// Beiträge" pill they could never clear, each tap costing a full
|
||||
// filtered refetch. That trains guests to ignore the one control
|
||||
// that means something. It also fired for any other guest's
|
||||
// non-matching upload, which by construction is never in the
|
||||
// filtered `uploads`.
|
||||
const seen = new Set(uploads.map((u) => u.id));
|
||||
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
||||
if (fresh.length) uploads = [...fresh, ...uploads];
|
||||
if (fresh.length) {
|
||||
// Test the EFFECTIVE filter, not the raw chip state. `filterParams()`
|
||||
// is what actually decides which rows the server returns, and in list
|
||||
// view it reads `selectedHashtag` alone — `activeFilters` is ignored.
|
||||
// `switchView('list')` deliberately keeps a chip there (an uploader
|
||||
// chip, or a second tag) while setting `selectedHashtag` to the first
|
||||
// TAG filter, which is null when the only chip was an uploader. So
|
||||
// `activeFilters.length` was truthy on a list view that is genuinely
|
||||
// unfiltered, and every delta raised the pill instead of merging —
|
||||
// the same "a pill for rows that should have merged" this block was
|
||||
// written to stop, one branch further in.
|
||||
if (filterParams().toString() !== '') {
|
||||
// Still cannot MERGE under a filter — `/feed/delta` takes no
|
||||
// filter params and its rows carry no hashtags, so we cannot
|
||||
// tell which belong in this view. Flagging stale is right;
|
||||
// doing it for rows already on screen was not.
|
||||
feedStale = true;
|
||||
} else {
|
||||
uploads = [...fresh, ...uploads];
|
||||
}
|
||||
}
|
||||
}
|
||||
// A delta reconciles new uploads and deletions, but not like/comment
|
||||
// counts that changed on already-visible cards while we were
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { setAuth } from '$lib/auth';
|
||||
import { uuid } from '$lib/uuid';
|
||||
import { markGuideSeen } from '$lib/onboarding';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
|
||||
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
|
||||
let eventName = $state('');
|
||||
// The operator's own data notice, if they set one. Usually empty — see the notice block below.
|
||||
let privacyNote = $state('');
|
||||
let noticeOpen = $state(false);
|
||||
onMount(async () => {
|
||||
try {
|
||||
const ev = await api.get<{ name: string; slug: string }>('/event');
|
||||
const ev = await api.get<{ name: string; slug: string; privacy_note?: string }>('/event');
|
||||
eventName = ev.name;
|
||||
privacyNote = ev.privacy_note?.trim() ?? '';
|
||||
} catch {
|
||||
// Non-fatal — fall back to the generic heading if the lookup fails.
|
||||
}
|
||||
@@ -34,6 +39,39 @@
|
||||
let pinRequestSent = $state(false);
|
||||
let pinRequestLoading = $state(false);
|
||||
|
||||
/**
|
||||
* Stable idempotency key for THIS join attempt, surviving a reload or a PWA relaunch.
|
||||
*
|
||||
* The failure it closes: `/join` commits the account and the PIN hash, but the plaintext PIN
|
||||
* only ever exists in the response body. Lose that response — the 5G-to-nothing handoff every
|
||||
* venue car park has — and the retry used to 409 on the guest's own name, leaving them staring
|
||||
* at a PIN prompt for a PIN nobody had ever seen. With a key the server recognises the retry
|
||||
* and answers with a working PIN (it rotates it; see migration 027).
|
||||
*
|
||||
* Kept in localStorage rather than component state because the guest's instinctive response to
|
||||
* a hung request is to reload the page, which would otherwise mint a fresh key and re-create
|
||||
* the exact bug. Cleared once the join has demonstrably landed.
|
||||
*/
|
||||
const JOIN_KEY_STORAGE = 'eventsnap:join-attempt-id';
|
||||
function joinAttemptId(): string {
|
||||
let id: string | null = null;
|
||||
try {
|
||||
id = localStorage.getItem(JOIN_KEY_STORAGE);
|
||||
} catch {
|
||||
// Private mode / storage disabled. A per-call UUID is still better than none: it makes
|
||||
// a retry within this page view idempotent, which is the common case.
|
||||
}
|
||||
if (!id) {
|
||||
id = uuid();
|
||||
try {
|
||||
localStorage.setItem(JOIN_KEY_STORAGE, id);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
if (!displayName.trim()) return;
|
||||
loading = true;
|
||||
@@ -44,9 +82,19 @@
|
||||
pin: string;
|
||||
user_id: string;
|
||||
is_new: boolean;
|
||||
}>('/join', { display_name: displayName.trim() });
|
||||
}>('/join', {
|
||||
display_name: displayName.trim(),
|
||||
client_join_id: joinAttemptId()
|
||||
});
|
||||
|
||||
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
|
||||
// The join landed and we hold the PIN — retire the key so a later deliberate join (a
|
||||
// second guest on a shared device) is a new attempt rather than a retry of this one.
|
||||
try {
|
||||
localStorage.removeItem(JOIN_KEY_STORAGE);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
pin = res.pin;
|
||||
showPinModal = true;
|
||||
} catch (e) {
|
||||
@@ -311,6 +359,70 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!--
|
||||
Data notice AT THE POINT OF COLLECTION.
|
||||
|
||||
There was none at all — not on this page, not anywhere pre-auth — while
|
||||
PROJECT.md:407 claimed there was. For ~100 EU guests uploading photos of
|
||||
identifiable people, including children, that was the most consequential gap in
|
||||
the whole audit, and the least work to close.
|
||||
|
||||
The baseline text below is hardcoded rather than read from `privacy_note`,
|
||||
because `privacy_note` defaults to '' (migration 009) — a notice an operator can
|
||||
leave blank is not a notice. The operator's own text is shown IN ADDITION when
|
||||
they have set one.
|
||||
|
||||
Summary visible without a tap (that is the part that has to be unmissable);
|
||||
detail behind a disclosure so it does not bury the one field on the page.
|
||||
-->
|
||||
<div class="mt-5 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<p class="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
Mit dem Beitreten legst du ein Konto mit deinem Namen an. Deine Fotos, Kommentare und
|
||||
dein Name sind für alle Gäste dieses Events sichtbar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (noticeOpen = !noticeOpen)}
|
||||
data-testid="join-privacy-toggle"
|
||||
aria-expanded={noticeOpen}
|
||||
class="mt-1 text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{noticeOpen ? 'Weniger anzeigen' : 'Was passiert mit meinen Daten?'}
|
||||
</button>
|
||||
{#if noticeOpen}
|
||||
<div
|
||||
class="mt-2 space-y-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400"
|
||||
data-testid="join-privacy-note"
|
||||
>
|
||||
<p>
|
||||
<strong class="text-gray-800 dark:text-gray-200">Was gespeichert wird:</strong> dein angezeigter
|
||||
Name, deine hochgeladenen Fotos und Videos samt Aufnahmezeitpunkt, deine Bildtexte, Kommentare
|
||||
und Likes. Dazu ein verschlüsselter Prüfwert deines PINs — der PIN selbst wird nicht gespeichert.
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-gray-800 dark:text-gray-200">Wer es sehen kann:</strong> alle Gäste
|
||||
dieses Events. Die Gastgeber können außerdem Beiträge und Kommentare entfernen. Am Ende
|
||||
erhalten die Gastgeber ein Archiv mit allen Fotos.
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-gray-800 dark:text-gray-200">Wie lange:</strong> bis die Gastgeber
|
||||
das Event abschließen und die Installation abbauen. Du kannst eigene Fotos jederzeit selbst
|
||||
löschen, und über „Mein Konto“ dein Konto samt aller Inhalte entfernen lassen.
|
||||
</p>
|
||||
<p>
|
||||
Lade bitte keine Fotos von Personen hoch, die damit nicht einverstanden sind — bei
|
||||
Kindern brauchst du das Einverständnis der Eltern.
|
||||
</p>
|
||||
{#if privacyNote}
|
||||
<p class="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||
<strong class="text-gray-800 dark:text-gray-200">Hinweis der Gastgeber:</strong>
|
||||
{privacyNote}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-sm">
|
||||
<a
|
||||
href="/recover"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { setAuth, getPin, getToken, clearPin } from '$lib/auth';
|
||||
import { setAuth, getPin, getToken, clearPin, getPinOwner } from '$lib/auth';
|
||||
import { markGuideSeen } from '$lib/onboarding';
|
||||
import { browser } from '$app/environment';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
@@ -80,10 +80,31 @@
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
error = e.message;
|
||||
// A wrong PIN here often means the locally-cached PIN is stale (a host reset it
|
||||
// while this device was offline and missed the `pin-reset` SSE). Drop the cached
|
||||
// value so it doesn't keep pre-filling the field with the dead PIN.
|
||||
if (e.status === 401) clearPin();
|
||||
// A wrong PIN CAN mean the locally-cached PIN is stale (a host reset it while this
|
||||
// device was offline and missed the `pin-reset` SSE), and then dropping it stops the
|
||||
// field pre-filling with a dead value. `+layout.svelte` already handles the online
|
||||
// case; this is the offline backstop.
|
||||
//
|
||||
// But it must be narrow, because the backend returns the SAME 401 for a wrong PIN
|
||||
// and an UNKNOWN NAME (deliberately — it closes an enumeration and timing oracle).
|
||||
// Clearing on any 401 meant a guest who mistyped their own name lost the only copy
|
||||
// of their PIN: localStorage is where it lives, the server keeps only the bcrypt,
|
||||
// and rejoining under the same name 409s. One typo, permanently locked out of their
|
||||
// own account, needing a host with a dashboard open.
|
||||
//
|
||||
// So clear only when the evidence actually points at a stale cache: the name they
|
||||
// submitted is the one this device belongs to, AND the PIN that was rejected is the
|
||||
// cached one. Any other 401 leaves stored state untouched.
|
||||
// `getPinOwner()`, NOT `getDisplayName()`. A host PIN reset also revokes the guest's
|
||||
// sessions, so by the time they reach this screen `clearAuth` has already deleted the
|
||||
// display name — and the guard could never fire again on that device. The dead PIN
|
||||
// then pre-fills this field forever, and since a 4-digit value auto-submits, every
|
||||
// correction burns another of the four wrong-PIN attempts the shared venue IP allows
|
||||
// per 15 minutes. The PIN's owner is stored with the PIN and survives with it.
|
||||
const submittedOwnName =
|
||||
getPinOwner()?.trim().toLowerCase() === displayName.trim().toLowerCase();
|
||||
const submittedCachedPin = getPin() !== null && pin.trim() === getPin();
|
||||
if (e.status === 401 && submittedOwnName && submittedCachedPin) clearPin();
|
||||
} else {
|
||||
error = 'Ein Fehler ist aufgetreten.';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user