Compare commits
46 Commits
fix/keepsa
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef6d3a077a | ||
|
|
214f9e3062 | ||
|
|
253878e027 | ||
|
|
5b705317ef | ||
|
|
05063694d2 | ||
|
|
23e2f485dd | ||
|
|
eb0e405562 | ||
|
|
1d9fb11c7b | ||
|
|
61119be817 | ||
|
|
e1c689d1a7 | ||
|
|
d5b4bf0ac1 | ||
|
|
0ae5a64e77 | ||
|
|
edc5f1f62c | ||
|
|
46bb2e5174 | ||
|
|
2b1500e624 | ||
|
|
89ca819529 | ||
|
|
fffa2d556c | ||
|
|
51e55b1ace | ||
|
|
87d01a8a26 | ||
|
|
496dba5a1f | ||
|
|
1d0df3ebf6 | ||
|
|
faea555967 | ||
|
|
157499d493 | ||
|
|
2f952494c2 | ||
|
|
43d37269b6 | ||
|
|
9b90929269 | ||
|
|
117e67fa80 | ||
|
|
f275de5c8f | ||
|
|
3f0f9c098b | ||
|
|
e52b2f1cd1 | ||
|
|
5969ec74ea | ||
|
|
a4bac03628 | ||
|
|
6fd75adb27 | ||
|
|
7d0334bf22 | ||
|
|
402215d405 | ||
|
|
2b313e67e0 | ||
|
|
2551c25436 | ||
|
|
d51c6b8c4b | ||
|
|
3bcb7c6a76 | ||
|
|
08d92b7531 | ||
|
|
06bc9ddcb3 | ||
|
|
5f702f2b40 | ||
|
|
31faccfdf8 | ||
|
|
06ade4e158 | ||
|
|
b601c062bd | ||
|
|
0c0eed885a |
118
.env.example
118
.env.example
@@ -1,7 +1,19 @@
|
||||
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||
# Public domain Caddy will serve and obtain a TLS certificate for.
|
||||
#
|
||||
# The DNS A record must already point at this server BEFORE the first `up -d`: Caddy
|
||||
# requests a certificate on boot, and Let's Encrypt allows only 5 failed validations per
|
||||
# hostname per hour. Never delete the caddy_data volume — it holds the certificate and
|
||||
# the ACME account key.
|
||||
DOMAIN=my-event.example.com
|
||||
|
||||
# ── Image version ─────────────────────────────────────────────────────────────
|
||||
# Tag pulled for the `app` and `frontend` services (docker-compose.yml). Production runs
|
||||
# prebuilt images from the registry and never compiles — see DEPLOYMENT_RUNBOOK.md.
|
||||
# Always an immutable tag, never `latest`: rollback is `EVENTSNAP_VERSION=<previous>`
|
||||
# + `docker compose up -d`, which works offline if that image is still resident locally.
|
||||
EVENTSNAP_VERSION=v0.13.0
|
||||
|
||||
# ── App server ────────────────────────────────────────────────────────────────
|
||||
APP_PORT=3000
|
||||
# Set to `production` in real deployments. This activates the secret guard that
|
||||
@@ -12,17 +24,39 @@ APP_ENV=production
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
# Set a strong password and keep it in sync between DATABASE_URL and
|
||||
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
|
||||
#
|
||||
# SET THIS BEFORE THE FIRST `docker compose up -d`. Postgres reads POSTGRES_PASSWORD
|
||||
# only when it initialises its data directory, on that very first boot. Change it
|
||||
# afterwards and the app authenticates with the new password against a volume still
|
||||
# holding the old one — a permanent restart loop ("password authentication failed").
|
||||
# The only ways out are restoring the old password or `docker compose down -v`, which
|
||||
# deletes the database, the media and the exports. In production the app refuses to
|
||||
# boot while this is still the placeholder below, so it cannot be missed by accident.
|
||||
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
|
||||
POSTGRES_USER=eventsnap
|
||||
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
||||
POSTGRES_DB=eventsnap
|
||||
# Connection pool size. Default 10. For a busy event (~100 guests polling the feed
|
||||
# + SSE + uploads at once) raise to ~30 so requests don't queue on a pool permit.
|
||||
# PAIRED WITH THE DB CONTAINER'S MEMORY LIMIT: 30 backends plus Postgres 16's default
|
||||
# shared_buffers is already snug in the 1G that docker-compose.yml allots the `db`
|
||||
# service. If you raise this, raise `db.deploy.resources.limits.memory` with it — an
|
||||
# OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
||||
DATABASE_MAX_CONNECTIONS=30
|
||||
# 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.
|
||||
#
|
||||
# SIZE IT TO THE CORES, NOT TO THE GUESTS. The earlier advice here was ~30, reasoned from
|
||||
# "~100 guests polling the feed at once" back when a feed page cost ~449 ms and connections
|
||||
# were spent waiting. Migration 024 replaced the feed view's GROUP BY with scalar subqueries
|
||||
# and a page now costs well under a millisecond, so concurrency is no longer where the time
|
||||
# goes. On a 2 vCPU box 30 simultaneous queries cannot run — they queue on the CPU instead of
|
||||
# on the pool, which is the same wait wearing a different hat, and 30 Postgres backends plus
|
||||
# shared_buffers is snug in the 1G that docker-compose.yml allots `db`.
|
||||
#
|
||||
# 15 on 2 vCPU / 4 GB. Raise toward 30 only alongside more cores AND a bigger `db` memory
|
||||
# limit — an OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
||||
DATABASE_MAX_CONNECTIONS=15
|
||||
|
||||
# Log level: see the "Logging" section near the bottom of this file.
|
||||
#
|
||||
# Defined THERE and nowhere else, deliberately. This file used to assign RUST_LOG twice —
|
||||
# once here and once there — and Compose takes the LAST assignment, so editing this line to
|
||||
# `debug` to chase a problem during the event changed nothing at all, silently. A key that
|
||||
# appears twice in a .env is a trap regardless of which value is better.
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Generate with: openssl rand -hex 64
|
||||
@@ -30,7 +64,9 @@ JWT_SECRET=change_me_to_a_random_64_byte_hex_string
|
||||
SESSION_EXPIRY_DAYS=30
|
||||
|
||||
# Admin dashboard password (bcrypt hash).
|
||||
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
||||
# Generate with an image the stack already pulls (htpasswd needs apache2-utils, which
|
||||
# a stock VPS does not have):
|
||||
# docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
|
||||
# IMPORTANT: keep the SINGLE QUOTES. A bcrypt hash is full of `$` (e.g. $2b$12$…$…),
|
||||
# and both Docker Compose's env_file interpolation and dotenvy's variable substitution
|
||||
# would otherwise eat the `$…` segments (reading them as unset vars) and corrupt the
|
||||
@@ -80,10 +116,64 @@ EXPORT_PATH=/exports
|
||||
# provisioned export headroom separately.
|
||||
|
||||
# ── Workers ───────────────────────────────────────────────────────────────────
|
||||
# Number of parallel image/video compression workers. Default 2. This is the main
|
||||
# throughput bottleneck: with 2 workers a burst of uploads can take ~5s to appear.
|
||||
# For a large event (100+ guests) 4 is a good target — but each worker can run an
|
||||
# ffmpeg transcode, so if you raise this ALSO raise the app container's memory limit
|
||||
# in docker-compose.yml (`app.deploy.resources.limits.memory`) from 1G to ~2G, or a
|
||||
# burst of large videos can OOM the box and take Postgres down with it.
|
||||
# Number of parallel media compression workers. Default 2. Boot-time only.
|
||||
#
|
||||
# CORRECTION TO EARLIER GUIDANCE: this used to say "each worker can run an ffmpeg
|
||||
# transcode, so raise the app memory limit to ~2G if you set 4". There is NO video
|
||||
# transcode anywhere in this codebase — services/video.rs runs
|
||||
# `ffmpeg -ss <t> -i <src> -vframes 1 -vf scale=...`, a single poster frame, and video
|
||||
# originals are stored and served byte-for-byte. Poster extraction costs ~150-250 MB
|
||||
# for a moment; it is not the constraint.
|
||||
#
|
||||
# The real memory consumer is the IMAGE path. `image` 0.25's resize builds an Rgba32F
|
||||
# intermediate at 16 BYTES PER PIXEL, sized (source_width x target_height) — which the
|
||||
# 256 MiB decode guard in imaging.rs does NOT cover. Peak per photo, decode + the 2048px
|
||||
# display resize: ~145 MB at 12 MP, ~223 MB at 24 MP, ~354 MB at 48 MP.
|
||||
#
|
||||
# So on a 2 vCPU / 4 GB box (e.g. Hetzner CX22) KEEP THIS AT 2:
|
||||
# * concurrency 4 would put two giants at ~1.5 GB against the 1G app limit — OOM.
|
||||
# * and app=2G + db=1G + frontend/caddy 256M each + ~370 MB of OS/Docker exceeds the
|
||||
# ~3910 MiB a "4 GB" VM actually reports. Raising the limit oversubscribes the host.
|
||||
# 4 is only reasonable on the 4 vCPU / 8 GB box README.md documents.
|
||||
#
|
||||
# The "two 48 MP photos at once" worst case this number used to be sized against is no
|
||||
# longer reachable: compression.rs takes an EXCLUSIVE `heavy` permit for any job whose
|
||||
# estimated peak exceeds HEAVY_IMAGE_BYTES (150 MiB), so two giants serialise no matter what
|
||||
# this is set to. What concurrency 2 now buys is two ORDINARY phone photos in parallel
|
||||
# (~145 MB peak each), which is both memory-safe and short enough not to starve the two
|
||||
# tokio worker threads a 2 vCPU box gets.
|
||||
#
|
||||
# Do NOT drop this to 1 hoping to protect the CPU. It halves throughput on the common light
|
||||
# path for a heavy path that is already serialised, and a longer compression backlog means
|
||||
# more feed tiles served from full-size originals (VirtualFeed falls back to /original while
|
||||
# derivatives are pending) — trading a little CPU for a lot of venue-wifi bandwidth.
|
||||
#
|
||||
# Throughput at 2 is not the bottleneck anyone thinks it is: ~2.5s per 12 MP photo, so
|
||||
# 100 photos is ~250 CPU-seconds spread over an entire evening.
|
||||
COMPRESSION_WORKER_CONCURRENCY=2
|
||||
|
||||
# ── Comments ──────────────────────────────────────────────────────────────────
|
||||
# Master switch for the comment feature. Boot-time only (NOT in the admin UI), so it
|
||||
# needs a `docker compose up -d` to apply. Anything other than false/0/no/off is on.
|
||||
#
|
||||
# When false the backend rejects NEW comments with 403 and the frontend hides the whole
|
||||
# comment UI, including in the offline keepsake viewer. Likes and captions are entirely
|
||||
# separate features and are unaffected. Existing comments stay in the database (hidden),
|
||||
# so flipping it back restores them.
|
||||
#
|
||||
# Note it gates POSTING only: GET /upload/{id}/comments still serves already-existing
|
||||
# comments, and the keepsake's data.json still embeds their text. Irrelevant if the flag
|
||||
# is off from the first boot, since no comment can ever have been written.
|
||||
# NOTE for the current deployment: `docker-compose.yml` PINS this to "false" on the app
|
||||
# service, and `environment` overrides `env_file` — so changing it here has no effect in
|
||||
# production. Remove that line from the compose file first if you want comments back.
|
||||
COMMENTS_ENABLED=true
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
# SET THIS IN PRODUCTION. Without it the app falls back to
|
||||
# `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a
|
||||
# debug line per HTTP request — including every preview and thumbnail fetch. Combined with
|
||||
# Docker's json-file driver it writes to the same filesystem as the database and the media.
|
||||
# docker-compose.yml caps each service's logs at 30 MB; this keeps the volume sane in the
|
||||
# first place. The e2e stack has always used exactly this value.
|
||||
RUST_LOG=eventsnap_backend=info,tower_http=warn
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,8 +13,16 @@ frontend/build/
|
||||
frontend/export-viewer/node_modules/
|
||||
frontend/export-viewer/.svelte-kit/
|
||||
|
||||
# Media uploads (mounted volume in production)
|
||||
media/
|
||||
# Media uploads. In production these live in the `media_data` DOCKER VOLUME, never in the
|
||||
# working tree — so this pattern is anchored to the repo root and exists only for a local
|
||||
# bind-mount experiment.
|
||||
#
|
||||
# It used to read `media/`, unanchored, which matches a directory of that name at ANY depth.
|
||||
# The only one in the repo is `e2e/fixtures/media/`, so the rule's entire practical effect was
|
||||
# to keep every E2E fixture untracked: a fresh clone got the specs and none of the images or
|
||||
# videos they read. `.github/workflows/e2e.yml` does a plain checkout and generates nothing, so
|
||||
# the committed CI job could not have run the upload, video or export suites at all.
|
||||
/media/
|
||||
|
||||
# Playwright E2E suite — runtime artifacts (the suite itself is committed)
|
||||
e2e/node_modules/
|
||||
|
||||
78
Caddyfile
78
Caddyfile
@@ -31,20 +31,27 @@
|
||||
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
||||
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# Preview/thumbnail images. These are served by the app through a visibility-checked
|
||||
# alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation can revoke access;
|
||||
# the app serves no /media route at all, so there is no direct path to the bytes.
|
||||
# Privately cacheable for a short window (the app sets the same header; this is the
|
||||
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
||||
# stops being served to a direct-URL holder promptly.
|
||||
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
||||
# Preview/thumbnail/display images. These are served by the app through a
|
||||
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail,display}) so
|
||||
# moderation can revoke access; the app serves no /media route at all, so there is no
|
||||
# direct path to the bytes. Privately cacheable for a short window (the app sets the
|
||||
# same header; this is the edge carve-out from the blanket no-store below). Kept short
|
||||
# so a moderated image stops being served to a direct-URL holder promptly.
|
||||
#
|
||||
# `display` was missing here while the backend set `private, max-age=300` on it, and
|
||||
# because `header` REPLACES, the blanket no-store below silently won. That route is the
|
||||
# ~2048px derivative the diashow uses exclusively, so a projector left running all
|
||||
# evening re-fetched a full-size JPEG for every slide — roughly 2-4 GB pulled through
|
||||
# the app over 8 hours, on the same venue uplink 100 guests are uploading over, and a
|
||||
# blank frame on every network hiccup.
|
||||
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail /api/v1/upload/*/display
|
||||
header @media_api Cache-Control "private, max-age=300"
|
||||
|
||||
# API and health — never cache, EXCEPT the gated image routes above. A cached health
|
||||
# response would report the last known state rather than the current one.
|
||||
@api {
|
||||
path /api/* /health
|
||||
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
||||
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail /api/v1/upload/*/display
|
||||
}
|
||||
header @api Cache-Control "no-store"
|
||||
|
||||
@@ -68,4 +75,59 @@
|
||||
|
||||
# Everything else goes to SvelteKit frontend
|
||||
reverse_proxy frontend:3001
|
||||
|
||||
# Last-resort page for when Caddy itself cannot reach an upstream — the app or frontend
|
||||
# container down, restarting, or still warming up after a host reboot. Without it a guest
|
||||
# gets Caddy's bodiless 502: a completely blank page, which reads as "the whole thing is
|
||||
# gone" rather than "try again in a moment".
|
||||
#
|
||||
# THIS DOES NOT TOUCH APPLICATION ERRORS. `handle_errors` fires only on errors CADDY
|
||||
# generates; a status the app returns through `reverse_proxy` is written back verbatim and
|
||||
# never reaches here. That distinction is load-bearing rather than incidental: the keepsake
|
||||
# download navigates a HIDDEN IFRAME and depends on a real 404/429 arriving from the app
|
||||
# (frontend/src/routes/export/+page.svelte), and every API route answers 403/404/429 as
|
||||
# ordinary JSON that the client parses. Swallowing those into an HTML page would be a far
|
||||
# worse regression than the blank 502 this fixes. Verified against this exact config: an
|
||||
# upstream 404 through `reverse_proxy` still arrives as `Content-Type: application/json`
|
||||
# with its body intact, while only a dial failure renders the page below.
|
||||
#
|
||||
# Scoped to 5xx so a hypothetical future Caddy-generated 4xx (there is none today) still
|
||||
# returns plainly instead of claiming the server is restarting.
|
||||
#
|
||||
# The body is inline because the caddy service mounts ONLY ./Caddyfile and caddy_data —
|
||||
# there is no volume to ship an HTML file through and the image has no build step, so a
|
||||
# static file would mean changing the deployed stack's compose definition. No external
|
||||
# font, stylesheet or image is referenced: the app may be exactly what is down.
|
||||
#
|
||||
# `handle_errors` has NO position in the directive order — Caddy hoists it into a separate
|
||||
# `errors` route list — so it cannot disturb the "first `header` directive wins" hazard
|
||||
# documented at the top of this file. The site-wide security headers still apply to it.
|
||||
handle_errors 5xx {
|
||||
header Content-Type "text/html; charset=utf-8"
|
||||
header Cache-Control "no-store"
|
||||
# {err.status_code} preserves the real status. Hardcoding 503 would mislabel a genuine
|
||||
# 502 for anything watching from outside.
|
||||
respond `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Gleich zurück</title>
|
||||
<style>
|
||||
html{background:#faf9f7;color:#1a1918;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center}
|
||||
h1{font-family:Georgia,"Times New Roman",serif;font-weight:600;font-size:1.5rem;margin:0 0 .75rem}
|
||||
p{margin:0;color:#545350;line-height:1.5}
|
||||
@media (prefers-color-scheme:dark){html{background:#100f0f;color:#f5f4f2}p{color:#a6a4a1}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Wir sind gleich zurück</h1>
|
||||
<p>Die Seite wird gerade neu gestartet.<br>Bitte lade in einem Moment neu — deine Fotos bleiben gespeichert.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
` {err.status_code}
|
||||
}
|
||||
}
|
||||
|
||||
693
DEPLOYMENT_RUNBOOK.md
Normal file
693
DEPLOYMENT_RUNBOOK.md
Normal file
@@ -0,0 +1,693 @@
|
||||
# EventSnap — Production Deployment Runbook
|
||||
|
||||
Target: **Hetzner CX22** (2 vCPU, 4 GB RAM, 40 GB disk), single host, docker compose.
|
||||
Event profile: ~100 guests, ~100 photos + a few videos, one evening, **operator attending and unavailable to troubleshoot**.
|
||||
|
||||
Everything here is written for that last constraint. Where a choice trades throughput for
|
||||
"cannot need attention on the night", it takes the stable option.
|
||||
|
||||
> **Note on this repo's own docs.** `README.md:62` and `PROJECT.md:359` specify a **CX33
|
||||
> (4 vCPU / 8 GB / 80 GB)**. You are deploying to half of that on every axis. Two pieces of
|
||||
> tuning advice in `.env.example` are calibrated for the CX33 and are actively wrong for a
|
||||
> CX22 — they are called out in §3. Trust this file over `.env.example` for sizing.
|
||||
|
||||
---
|
||||
|
||||
## 0. Timeline — the single most important control
|
||||
|
||||
### Step zero: commit everything, 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.
|
||||
|
||||
That is not a tidiness problem, it is the deployment failing in two ways at once:
|
||||
|
||||
- §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.
|
||||
|
||||
```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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
| 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‑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). |
|
||||
| **Event day** | Change nothing. Configuration tweaks via the admin dashboard only (§4). |
|
||||
|
||||
**Why the freeze matters more than anything else here.** Migrations run automatically at boot
|
||||
(`backend/src/db.rs`, `create_pool`) and `sqlx::migrate!()` is used *without* `set_ignore_missing`. An older
|
||||
image booted against a newer schema does not degrade — it **crash-loops** with `VersionMissing`.
|
||||
The repo documents this itself in `backend/migrations/014_export_epoch.up.sql:3-8`. Once the
|
||||
migration set is frozen, rollback is a one-line `.env` edit; before it is frozen, rollback means
|
||||
hand-running down-SQL under pressure.
|
||||
|
||||
---
|
||||
|
||||
## 1. Deployment strategy — build on the Mac, push, pull
|
||||
|
||||
**Decision: build `linux/amd64` images on the M3 Pro, push to `registry.mc02.dev`, pull on the server.**
|
||||
|
||||
Rejected alternatives, with the reason each loses:
|
||||
|
||||
| Option | Why not |
|
||||
|---|---|
|
||||
| **Build on the CX22** | `backend/Cargo.toml` sets `lto = true` + `codegen-units = 1` over **427 crates**. Fat LTO links the whole program in one single-threaded process; peak RSS is estimated at 2.5–4 GB against ~1.5–2.5 GB free with the stack running. OOM is the expected outcome, not a tail risk. And *rollback would also be a build* — 35–60 min under pressure. |
|
||||
| **CI (Gitea on Pi 5)** | Ruled out by you, and correct: a Pi 5 is strictly worse than the CX22 for a fat-LTO link. |
|
||||
| **True cross-compilation** (`--target x86_64-unknown-linux-musl`) | The dependency graph has **four C-compiling crates** — `ring` (per-arch assembly), `zstd-sys`, `libdeflate-sys`, `libsqlite3-sys` — so it needs a real musl cross-toolchain. Alpine has no such package for an arm64 host; the working answer is `cargo-zigbuild`, which means a new base image and a new toolchain days before an unrepeatable event. **Slow but certain beats fast but novel.** |
|
||||
|
||||
Emulated build is the right call because the cost lands where it is free: on your laptop, days
|
||||
early, with nothing depending on it. Estimated wall-clock for the backend is **25–60 min with
|
||||
Rosetta enabled** (hours without it) — start it and walk away.
|
||||
|
||||
> **Escape hatch if emulation is unbearable:** spin up a temporary Hetzner CPX41 (8 vCPU/16 GB) in
|
||||
> the same account, build natively, push, destroy it. Costs cents, same commands, no repo change.
|
||||
|
||||
---
|
||||
|
||||
## 2. Repo changes — ALREADY APPLIED
|
||||
|
||||
> **Status: these are done, in the working tree.** They are documented here so you know what
|
||||
> changed and why, not as work to repeat. Run `git diff` to review before committing.
|
||||
|
||||
### 2.1 `.dockerignore` files (were absent)
|
||||
|
||||
`backend/.dockerignore`:
|
||||
```
|
||||
target/
|
||||
```
|
||||
|
||||
`frontend/.dockerignore`:
|
||||
```
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
```
|
||||
|
||||
Without these, the first time you run `cargo build` or `npm install` locally, every image build
|
||||
ships a multi-GB context to an *emulated* builder. Worse: `frontend/Dockerfile:9` does `COPY . .`
|
||||
**after** `npm ci`, so a macOS `node_modules/` would be merged over the container's Linux one.
|
||||
|
||||
### 2.2 Switch compose from `build:` to `image:`
|
||||
|
||||
In `docker-compose.yml`, replace the `build:` block on `app` and `frontend`:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
image: registry.mc02.dev/eventsnap/app:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||
```
|
||||
```yaml
|
||||
frontend:
|
||||
image: registry.mc02.dev/eventsnap/frontend:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||
```
|
||||
|
||||
Two deliberate properties:
|
||||
- The `:?` form **fails loudly** on an unset variable instead of resolving to an empty tag.
|
||||
- **Removing `build:` entirely is a safety feature.** With no `build:` key on the server, a wrong
|
||||
tag is an instant `manifest unknown` — never a surprise 45-minute compile on the production box.
|
||||
|
||||
New `docker-compose.build.yml` (opt-in, Mac only — follows the convention stated in
|
||||
the header of `docker-compose.dev.yml` that overlays are never auto-loaded):
|
||||
|
||||
```yaml
|
||||
# Build overlay. NOT loaded automatically. Used only where images are BUILT — never on the
|
||||
# production server, which pulls.
|
||||
# docker compose -f docker-compose.yml -f docker-compose.build.yml build
|
||||
services:
|
||||
app:
|
||||
build: { context: ./backend, dockerfile: Dockerfile }
|
||||
frontend:
|
||||
build: { context: ./frontend, dockerfile: Dockerfile }
|
||||
```
|
||||
|
||||
`e2e/docker-compose.test.yml` keeps its own `build:` blocks, so the e2e gate is unaffected.
|
||||
|
||||
### 2.3 Add log rotation to every service
|
||||
|
||||
`docker-compose.yml` currently sets **no logging config**, so the default `json-file` driver keeps
|
||||
logs forever, on the same filesystem as Postgres and the media. Add to each of the four services:
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
```
|
||||
|
||||
(Equivalently, set it once in `/etc/docker/daemon.json` — but that restarts the Docker daemon,
|
||||
so do it *before* the stack is live, not after.)
|
||||
|
||||
---
|
||||
|
||||
## 3. `.env` — production values
|
||||
|
||||
```bash
|
||||
# ── Identity ──────────────────────────────────────────────────────────────
|
||||
DOMAIN=<your domain>
|
||||
EVENT_NAME=<...>
|
||||
EVENT_SLUG=<...>
|
||||
|
||||
# ── Image version (NEW — drives the image: tags in docker-compose.yml) ────
|
||||
EVENTSNAP_VERSION=v0.13.0
|
||||
|
||||
# ── Secrets — ALL of them, before the first `up -d` ───────────────────────
|
||||
JWT_SECRET=<openssl rand -hex 64>
|
||||
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
|
||||
|
||||
# ── Sizing (see the two corrections below) ────────────────────────────────
|
||||
DATABASE_MAX_CONNECTIONS=30
|
||||
COMPRESSION_WORKER_CONCURRENCY=2
|
||||
|
||||
# ── Comments off, likes + captions on ─────────────────────────────────────
|
||||
COMMENTS_ENABLED=false
|
||||
|
||||
# ── Logging (NEW — production currently defaults to DEBUG) ────────────────
|
||||
RUST_LOG=eventsnap_backend=info,tower_http=warn
|
||||
```
|
||||
|
||||
### Two corrections to the repo's own advice — do not follow `.env.example` here
|
||||
|
||||
**`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
|
||||
ffmpeg transcode". **There is no transcode anywhere in this codebase.** `services/video.rs::run_ffmpeg`
|
||||
runs `ffmpeg -ss <t> -i <src> -vframes 1 -vf scale=…` — a single poster frame. Video originals are
|
||||
stored and served byte-for-byte.
|
||||
|
||||
The real memory consumer is the **image** path: `image` 0.25's `resize` builds an `Rgba32F`
|
||||
intermediate at **16 bytes/px**, sized `source_width × target_height`, which the 256 MiB decode
|
||||
guard in `imaging::decode_limits` does not cover. Estimated peak per photo:
|
||||
|
||||
| Source | Peak (decode + display resize) |
|
||||
|---|---|
|
||||
| 12 MP (typical phone) | ~145 MB |
|
||||
| 24 MP (iPhone Pro default) | ~223 MB |
|
||||
| 48 MP ("Max" mode) | ~354 MB |
|
||||
|
||||
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.
|
||||
|
||||
**`quota_tolerance`: keep `0.75`. Raising it does not make anything more generous for a real guest.**
|
||||
See §4.
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin dashboard configuration
|
||||
|
||||
These live in the DB `config` table, not `.env`. Changes take effect on the **next request** —
|
||||
`patch_config` invalidates the cache synchronously (`admin::patch_config`). No restart needed. Set them
|
||||
after the first deploy, before the event.
|
||||
|
||||
| Key | Default | **Set to** | Why |
|
||||
|---|---|---|---|
|
||||
| `upload_rate_per_hour` | 100 | **1000** | A guest multi-selecting 100 photos hits exactly 100. Client backs off and resumes, but this makes it a non-issue. |
|
||||
| `feed_rate_per_min` | 60 | **240** | Headroom for reconnect bursts. |
|
||||
| `social_rate_per_min` | 120 | **600** | This is the **likes** limiter — the interaction you're keeping. |
|
||||
| `export_rate_per_day` | 3 | **20** | **One shared bucket across both archives** — `enforce_export_rate` keys on `export:{user_id}` regardless of which archive is being fetched. Downloading Gallery.zip + Memories.zip costs 2 of 3; one retry locks a guest out for 24 h. The limit is now charged when the download *ticket* is minted, so being over it produces a visible German error instead of a tap that silently does nothing. |
|
||||
| `max_video_size_mb` | 500 | **leave at 500** | See below. |
|
||||
| `quota_tolerance` | 0.75 | **leave at 0.75** | See below. |
|
||||
| `quota_enabled`, `storage_quota_enabled`, `rate_limits_enabled` | true | **leave on** | This is the only disk-full safety net. |
|
||||
|
||||
**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.
|
||||
|
||||
### Why quotas are already as generous as you want
|
||||
|
||||
```
|
||||
per_user_limit = floor(free_disk × quota_tolerance / max(active_uploaders, 1))
|
||||
```
|
||||
`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.**
|
||||
|
||||
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.
|
||||
|
||||
Raising `quota_tolerance` would only raise the **saturation ceiling** (media converges to
|
||||
`t/(1+t)` of free space: 43% at 0.75, 50% at 1.0). It does nothing for a real guest at your volume,
|
||||
and it eats the headroom the keepsake needs — the export preflight reserves `media × 1.10 × 2`
|
||||
because `Gallery.zip` and `Memories.zip` each store media **uncompressed** (`export.rs` — both archives store media uncompressed).
|
||||
|
||||
**Why `max_video_size_mb` stays at 500.** An earlier draft of this runbook said to lower it to 250,
|
||||
on the grounds that there is no client-side size check. That was wrong on both halves:
|
||||
|
||||
- A client guard **does** exist — `frontend/src/routes/upload/+page.svelte` rejects anything over
|
||||
`HARD_MAX_UPLOAD_BYTES` (576 MiB) before a byte leaves the phone, alongside the HEIC reject.
|
||||
- That guard is a **compile-time constant**, not the DB value. Lowering `max_video_size_mb` therefore
|
||||
changes nothing on the client: the picker still accepts the clip, the phone still starts sending
|
||||
it, and the *server* aborts it partway (`stream_field_to_file` does stop at the cap, so the AP is
|
||||
not saturated for the full file — but the guest still gets a mid-upload failure where 500 MB would
|
||||
simply have worked).
|
||||
|
||||
So lowering it is a pure capacity decision with no UX upside, and at your volume there is no capacity
|
||||
problem to solve. Leave it. If you ever do want a smaller ceiling to bite on the phone, the constant
|
||||
above has to move with it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pre-flight — T‑7 days (do NOT leave this to event week)
|
||||
|
||||
### Registry
|
||||
|
||||
From **both** the Mac and the server, as **the user who will deploy** (root's `docker login` does
|
||||
not help a non-root deploy — credentials go to `~/.docker/config.json`):
|
||||
|
||||
```bash
|
||||
getent hosts registry.mc02.dev # DNS resolves from the server
|
||||
curl -fsSI https://registry.mc02.dev/v2/ # TLS must be PUBLICLY trusted; Docker rejects
|
||||
# self-signed without extra daemon config
|
||||
docker login registry.mc02.dev
|
||||
```
|
||||
|
||||
Also confirm: the `eventsnap` repository/namespace exists if your registry requires pre-creation
|
||||
(Harbor does; plain `registry:2` does not), and the registry host has ~500 MB free per release.
|
||||
|
||||
### DNS and TLS — get this wrong and you are locked out for an hour
|
||||
|
||||
The DNS **A record must point at the CX22 before the first `docker compose up -d`**, because Caddy
|
||||
attempts certificate issuance on boot. Let's Encrypt allows only **5 failed validations per hostname
|
||||
per hour** (refilling 1 per 12 min). A misconfigured DNS record plus a few impatient restarts will
|
||||
rate-limit you out of getting a certificate at all.
|
||||
|
||||
- Deploy days early so issuance happens with no time pressure.
|
||||
- **Never delete the `caddy_data` volume** — it holds the certificate and the ACME account key.
|
||||
- Never run `docker compose down -v`. It destroys `postgres_data`, `media_data`, `exports_data`
|
||||
*and* `caddy_data`.
|
||||
|
||||
### Host preparation
|
||||
|
||||
```bash
|
||||
docker compose version # must be v2.x — the deploy.resources limits need it
|
||||
free -h && swapon --show # Hetzner images ship no swap
|
||||
df -h /var/lib/docker # want ≥ 25 GB free
|
||||
```
|
||||
|
||||
**Add 2 GB of swap** as an OOM cushion — a compression spike that would otherwise kill the container
|
||||
instead swaps out cold pages and merely runs slowly:
|
||||
|
||||
```bash
|
||||
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
sysctl -w vm.swappiness=10 && echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
|
||||
```
|
||||
|
||||
> **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:`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Build and push — on the Mac
|
||||
|
||||
Enable **Docker Desktop → Settings → General → "Use Rosetta for x86_64/amd64 emulation"**, and set
|
||||
**Resources → Memory ≥ 8 GB** (the fat-LTO step will OOM inside the VM otherwise, even with 18 GB on
|
||||
the host).
|
||||
|
||||
```bash
|
||||
docker run --rm --platform linux/amd64 alpine uname -m # must print x86_64
|
||||
|
||||
cd /Users/fabianhammprivat/Projects/EventSnap
|
||||
VERSION=v0.13.0 # latest existing tag is v0.12.0 — see §9 before reusing it
|
||||
ROLLBACK=v0.13.0-a # the SAME source, tagged twice; §9 explains why
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
docker buildx create --name eventsnap --use 2>/dev/null || docker buildx use eventsnap
|
||||
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t registry.mc02.dev/eventsnap/app:$VERSION \
|
||||
-t registry.mc02.dev/eventsnap/app:$ROLLBACK \
|
||||
-t registry.mc02.dev/eventsnap/app:$SHA \
|
||||
--push ./backend
|
||||
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t registry.mc02.dev/eventsnap/frontend:$VERSION \
|
||||
-t registry.mc02.dev/eventsnap/frontend:$ROLLBACK \
|
||||
-t registry.mc02.dev/eventsnap/frontend:$SHA \
|
||||
--push ./frontend
|
||||
```
|
||||
|
||||
### Verify the architecture — never skip this
|
||||
|
||||
An arm64 image pulls fine and then dies with `exec format error`. Catch it here, not on the server:
|
||||
|
||||
```bash
|
||||
docker buildx imagetools inspect registry.mc02.dev/eventsnap/app:$VERSION
|
||||
docker buildx imagetools inspect registry.mc02.dev/eventsnap/frontend:$VERSION
|
||||
# Both MUST report Platform: linux/amd64
|
||||
```
|
||||
|
||||
Then prove the binary actually executes. `AppConfig::from_env()` runs before any DB connection
|
||||
(`main.rs` builds `AppConfig` before touching the pool), so this needs no database:
|
||||
|
||||
```bash
|
||||
docker run --rm --platform linux/amd64 \
|
||||
-e APP_ENV=production -e JWT_SECRET=x -e DATABASE_URL=x -e EVENT_SLUG=x \
|
||||
registry.mc02.dev/eventsnap/app:$VERSION
|
||||
```
|
||||
Expect the "Refusing to start in production … placeholder" message. **That message means the amd64
|
||||
binary ran.** `exec format error` means the architecture is wrong.
|
||||
|
||||
**Tag policy: never `latest` in production.** With `latest` you cannot tell what is running,
|
||||
rollback becomes a registry re-push (impossible if the registry is down — exactly when you need it),
|
||||
and `restart: unless-stopped` after a reboot is ambiguous. Two immutable tags per build: semver and
|
||||
short SHA. Deploy by semver.
|
||||
|
||||
---
|
||||
|
||||
## 7. First deploy — on the server
|
||||
|
||||
```bash
|
||||
# Directory name matters: volumes are prefixed with it, and README's backup commands
|
||||
# hardcode the `eventsnap_` prefix.
|
||||
git clone <repo> eventsnap && cd eventsnap
|
||||
cp .env.example .env && nano .env # every value from §3
|
||||
docker login registry.mc02.dev
|
||||
docker compose pull # must fully succeed before anything starts
|
||||
```
|
||||
|
||||
### 7.1 The secret pre-flight — run this before the first `up -d`, always
|
||||
|
||||
```bash
|
||||
docker compose run --rm --no-deps app
|
||||
```
|
||||
|
||||
`--no-deps` means `db` never starts, so **no Postgres data directory is initialised**.
|
||||
`AppConfig::from_env()` runs first and reports *every* placeholder at once (`config::validate_secrets`).
|
||||
When the only remaining complaint is a database *connection* failure, the secrets are good.
|
||||
|
||||
**Why this step exists.** `POSTGRES_PASSWORD` is applied **only at initdb**. `docker-compose.yml`
|
||||
starts `db` in the same command as `app`, so a single `up -d` with a placeholder bakes the wrong
|
||||
password in permanently — the app then loops on `password authentication failed`, and the only exits
|
||||
are `ALTER ROLE` or `down -v`, which deletes the database, the media and the exports. The repo
|
||||
describes this trap at `backend/src/db.rs` (`explain_auth_failure`); this command is what avoids it.
|
||||
|
||||
### 7.2 Bring it up
|
||||
|
||||
```bash
|
||||
# `.env` is consumed by docker compose, not by your shell — load it before using $DOMAIN.
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
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
|
||||
|
||||
`MEDIA_PATH` is pinned to `/media` on the `app` service in `docker-compose.yml`. Its siblings
|
||||
are not:
|
||||
|
||||
```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.**
|
||||
|
||||
**Then actually log in to `/admin` with the real password.** This is not optional politeness:
|
||||
|
||||
- The production secret guard only rejects *placeholders* (`config::looks_placeholder`). A hash **corrupted**
|
||||
by shell or Compose escaping is not a placeholder — the app boots green, `/health` says `ok`, and
|
||||
every admin login 401s.
|
||||
- The Admin user row is created **by a successful admin login** (`auth::handlers::admin_login`), and
|
||||
promoting anyone to Host requires an Admin or Host (`auth::middleware`'s role guard). **No admin login
|
||||
⇒ no host, ever** ⇒ you cannot close the event, release the gallery, ban anyone, reset a PIN, or
|
||||
change any limit — for the whole event.
|
||||
|
||||
Per the Compose spec, single-quoted `env_file` values *are* used literally and the quotes are
|
||||
stripped, so the single-quote form in `.env.example` is correct. The comment in
|
||||
`docker-compose.dev.yml` claiming production has the same bug is **stale**. Verify anyway —
|
||||
the cost of checking is 10 seconds; the cost of being wrong is the whole event.
|
||||
|
||||
**Promote a second person to Host** once you are in, so a single lost session is not fatal.
|
||||
|
||||
---
|
||||
|
||||
## 8. Post-deploy verification
|
||||
|
||||
```bash
|
||||
set -a; . ./.env; set +a # $DOMAIN comes from .env, not your shell
|
||||
|
||||
docker compose ps # all four healthy
|
||||
docker inspect -f '{{.HostConfig.Memory}}' eventsnap-app-1 # must be 1073741824, not 0
|
||||
curl -fsS https://$DOMAIN/health # ok — now a real DB check, not a constant
|
||||
docker compose exec app printenv COMMENTS_ENABLED RUST_LOG
|
||||
docker builder prune -af && docker image prune -f # reclaim build cache
|
||||
df -h /var/lib/docker
|
||||
```
|
||||
|
||||
Then, from a phone on cellular (not the office wifi):
|
||||
|
||||
- [ ] Join as a guest with a PIN
|
||||
- [ ] Upload a photo → appears in the feed within a few seconds
|
||||
- [ ] Upload a video → plays back (iOS Safari range requests)
|
||||
- [ ] Add a caption, add a like — **no comment UI anywhere**
|
||||
- [ ] Admin login works; host dashboard reachable
|
||||
- [ ] Release the gallery on a test event and download both archives
|
||||
|
||||
---
|
||||
|
||||
## 9. Rollback
|
||||
|
||||
### There is no older image you can roll back to. Build the rollback target yourself.
|
||||
|
||||
Read this before the event, not during it. The obvious move — drop `EVENTSNAP_VERSION` back to the
|
||||
previous released tag — **takes the app down permanently** and looks like a crash loop with no
|
||||
explanation:
|
||||
|
||||
```
|
||||
$ git ls-tree --name-only v0.12.0 backend/migrations/ | wc -l
|
||||
12 # 6 migrations. HEAD has 22.
|
||||
$ git rev-list --count v0.12.0..HEAD
|
||||
154
|
||||
```
|
||||
|
||||
`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`.
|
||||
`create_pool` errors, `main` exits 1, and `restart: unless-stopped` restarts it forever — with Caddy
|
||||
still routing traffic to it. (`014_export_epoch.up.sql` documents this failure mode; §0 restates it.)
|
||||
No `v0.12.0` image was ever built or pushed either, so the pre-pull would fail with
|
||||
`manifest unknown` before you ever got that far.
|
||||
|
||||
**So: at build time, tag the SAME frozen commit twice.** Two identical images, two names. The
|
||||
rollback then swaps to a binary that is bit-for-bit what you tested and carries the identical
|
||||
migration set, which makes it a genuine no-op rather than a gamble:
|
||||
|
||||
```bash
|
||||
# In §6, push both tags from the one build:
|
||||
VERSION=v0.13.0
|
||||
ROLLBACK=v0.13.0-a # same source, different name — the rollback target
|
||||
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t registry.mc02.dev/eventsnap/app:$VERSION \
|
||||
-t registry.mc02.dev/eventsnap/app:$ROLLBACK \
|
||||
--push ./backend
|
||||
# ...and the same two tags for ./frontend
|
||||
```
|
||||
|
||||
**Rolling back — ~30 seconds, no network:**
|
||||
|
||||
```bash
|
||||
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env
|
||||
docker compose up -d app frontend
|
||||
```
|
||||
|
||||
This only works offline if both are already resident. **Pre-pull all four at T‑2:**
|
||||
|
||||
```bash
|
||||
docker pull registry.mc02.dev/eventsnap/app:v0.13.0
|
||||
docker pull registry.mc02.dev/eventsnap/frontend:v0.13.0
|
||||
docker pull registry.mc02.dev/eventsnap/app:v0.13.0-a
|
||||
docker pull registry.mc02.dev/eventsnap/frontend:v0.13.0-a
|
||||
docker image ls | grep eventsnap # confirm all four
|
||||
```
|
||||
|
||||
Once resident, `up -d`, reboots, restarts and rollbacks need **zero** registry contact. That single
|
||||
step makes a registry outage on event day irrelevant.
|
||||
|
||||
Be clear-eyed about what this buys you: an identical image cannot undo a bad *release*, only an
|
||||
image that got corrupted or a container that wedged — and `docker compose restart app` already
|
||||
covers both. It exists so that the rollback line in the emergency card is safe to run rather than
|
||||
catastrophic. **If you genuinely need to undo a code change during the event, you cannot; freeze
|
||||
early enough that you never have to.**
|
||||
|
||||
**Across a migration boundary — avoid by freezing.** If you must: all 22 migrations have paired
|
||||
`.down.sql` files, but **none of them removes its own `_sqlx_migrations` row**, so that second step
|
||||
is mandatory and undocumented:
|
||||
|
||||
```bash
|
||||
docker compose stop app
|
||||
# `sh -c` so the CONTAINER expands the credentials. They live in the container's environment
|
||||
# and in .env — not in your shell — so an unwrapped `-U "$POSTGRES_USER"` sends `-U ""` and
|
||||
# psql answers `FATAL: role "" does not exist`.
|
||||
docker compose exec -T db sh -c \
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' \
|
||||
< backend/migrations/0NN_x.down.sql
|
||||
docker compose exec -T db sh -c \
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM _sqlx_migrations WHERE version = NN;"'
|
||||
```
|
||||
|
||||
Migration **014** is the only destructive one on the way up, and the only one with a rehearsal
|
||||
harness — `backend/scripts/rehearse-014.sh`. Run it once against a real dump before the event.
|
||||
|
||||
**Registry-down transport fallback** (layers are already compressed — do not add gzip):
|
||||
|
||||
```bash
|
||||
docker save registry.mc02.dev/eventsnap/app:v0.13.0 \
|
||||
registry.mc02.dev/eventsnap/frontend:v0.13.0 | ssh root@SERVER 'docker load'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Backup
|
||||
|
||||
Full commands are in `README.md:315-435` and 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:
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 11. Disk — the numbers for 40 GB
|
||||
|
||||
Expected event (100 photos @ ~4 MB, 5 videos @ ~150 MB). Originals are **always kept** and never
|
||||
lossily recompressed; derivatives are a ≤800 px preview and a ≤2048 px display JPEG.
|
||||
|
||||
```
|
||||
media (originals + derivatives) ~1.25 GB
|
||||
peak during keepsake build (+ Gallery + Memories) ~3.5 GB
|
||||
OS + Docker images + Postgres + logs ~4.5 GB
|
||||
────────────────────────────────────────────────────────────
|
||||
total ~11–13 GB of ~36 GB usable
|
||||
```
|
||||
|
||||
**40 GB fits with roughly 3× headroom**, provided you build elsewhere (a server-side build adds
|
||||
3–5 GB of cache that permanently shrinks the guest quota, because the quota is recomputed against
|
||||
*live* free space on every upload).
|
||||
|
||||
The `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).
|
||||
|
||||
---
|
||||
|
||||
## 12. Known issues you are shipping with
|
||||
|
||||
None of these has a fix in this runbook; they are listed so nothing is a surprise. Severity is
|
||||
scored purely by "would this interrupt you during the event".
|
||||
|
||||
### Fixed in this pass
|
||||
|
||||
| Was | Fix |
|
||||
|---|---|
|
||||
| **Queued uploads never rehydrated** — `loadQueue()` had one call site, so a guest whose PWA was evicted mid-upload and reopened onto `/feed` had pending photos in IndexedDB that nothing ever read. Silent photo loss. | `loadQueue()` now runs on every authenticated boot in `+layout.svelte`. |
|
||||
| **A corrupted `ADMIN_PASSWORD_HASH` booted green** and only failed at admin login — unrecoverable mid-event. | `config.rs` now validates the bcrypt *shape*, not just placeholder-ness, and refuses to start. |
|
||||
| **SSE reconnect thundering herd** — flat 500 ms jitter regardless of backoff. | Jitter now scales with the delay; the feed's in-place refresh is spread over 800–2800 ms. |
|
||||
| **No client-side size/HEIC pre-check** — a doomed 600 MB upload saturated the venue AP before being rejected. | Certain-rejects are refused before any bytes leave the phone. |
|
||||
| **Upload-queue UI unreachable** — the badged FAB opened the picker, not the queue, so a failed upload had no retry button. | The upload sheet now shows a queue entry whenever the badge is non-zero. |
|
||||
| **A mid-event 401 left a dead screen** with no nav and no URL bar. | `api.ts` now returns the guest to `/join` (skipping the auth routes themselves). |
|
||||
| **Rate limiter could panic while holding its mutex** (`timestamps[0]` with `max == 0`), poisoning it process-wide. | Uses `first()` with a fail-open guard. |
|
||||
| **Hashtag deadlock** — `edit_upload` and `add_comment` upserted tags in client/text order. | Both now sort + dedup on the normalised key, matching the upload path. |
|
||||
| **Unbounded Docker logs + debug-level app logging.** | `logging:` caps every service at 30 MB; `RUST_LOG` documented in `.env.example`. |
|
||||
|
||||
### Fixed in the readiness pass — behaviour you should know about
|
||||
|
||||
These change what you will observe on the night, so they are listed separately from the table above.
|
||||
|
||||
| Was | Now |
|
||||
|---|---|
|
||||
| **Any ffmpeg-level failure DESTROYED the video.** A poster-frame error — ffmpeg hanging on a truncated `.mov`, an ENOSPC on `thumbnails/`, a DB blip — propagated into the give-up path, which soft-deleted the upload. Reproduced live: on a host with no ffmpeg the clip was gone ~6 s after its `201 Created`. | No failure in the video branch can fail the upload. The clip stays in the feed and plays from its original; only the poster is missing. |
|
||||
| **A lost response duplicated the photo.** Every retry minted a fresh upload id, so a phone that lost the reply and re-sent — manually, or automatically on reconnect — stored the same photo two or three times and paid quota for each. | The client sends a `client_upload_id`; migration 022 makes it unique. A retry returns `200` with the original row. Verified live: three sends → one row, quota charged once. |
|
||||
| **`/health` returned a constant `"ok"`.** The container reported healthy while every request 500'd. | Runs `SELECT 1` with a 2 s timeout: `200 ok` / `503`. Verified live: 200 → stop Postgres → 503 → start Postgres → 200, **with no app restart** (the pool revalidates on acquire). Note that Compose does not restart on an unhealthy probe — this is a diagnostic, deliberately not wired to automatic recovery, because a restart would truncate in-flight uploads to "fix" an outage that clears on its own. |
|
||||
| **Abandoned `.tmp` uploads were never reclaimed** (see the old issue #2 below — it is now fixed, not deferred). | Swept at boot and hourly, keyed on modification time so a live upload can never age into it. |
|
||||
| **A full disk made itself worse.** ENOSPC was retried three times, then refunded the quota, soft-deleted the row and *kept* the bytes — freeing nothing and inviting an immediate re-upload into the same full disk. | ENOSPC is classified separately: no retry, no refund, no delete. The row stays live and the photo is served from its original until there is room to compress it. |
|
||||
| **The keepsake download failed invisibly.** The rate limit was enforced inside the iframe navigation, so an over-limit guest tapped and *nothing happened*, forever. | Charged when the ticket is minted — a normal `fetch` — so it surfaces as a German message naming the daily window. Raise `export_rate_per_day` per §4 anyway. |
|
||||
| **`/host` and `/admin` subscribed to SSE but never opened the connection**, so the keepsake progress bar froze after a release. | Both connect on mount and disconnect on destroy, as `/export` already did. |
|
||||
| **`?limit=-5` on the feed returned a 500.** | Clamped at both ends. |
|
||||
| **All recurring hygiene lived in one unsupervised task** — one panic silently stopped session pruning, media reclamation and the temp sweep for the rest of the event. | Supervised and re-spawned, with an error log. |
|
||||
| **No pool acquire or statement timeout.** A DB blip parked every request for 30 s. | 5 s acquire, `statement_timeout=15s`, `lock_timeout=5s`. |
|
||||
|
||||
### Still open — accepted for this event
|
||||
|
||||
| # | Issue | Impact |
|
||||
|---|---|---|
|
||||
| 1 | **Guest delete/caption-edit after release invalidates both keepsake archives** (`upload.rs`), forcing a rebuild with a 20 s debounce. **No-op before release**, so it cannot bite during the event. | Post-event only. Left alone because blocking guest deletes has real privacy downsides — that is a product call, not a bug fix. |
|
||||
| 2 | **Video poster frames do not regenerate after a restart** mid-compression (the backfill filters `mime_type LIKE 'image/%'`). | Cosmetic — the video still plays; only its poster is missing. |
|
||||
| 3 | **SSE keep-alives are sent as SSE comments** (`:ping`), which the browser's EventSource parser discards without dispatching. A client therefore cannot implement a pure silence timer to detect a half-open socket. | Worked around client-side: the feed runs a jittered 60–120 s `/feed/delta` backstop and reconnects when a poll returns content the stream never delivered. A cleaner fix is to emit keep-alives as a *named* event; that is a coordinated backend+frontend change, not worth making during a freeze. |
|
||||
| 4 | **The lightbox stops at the end of the loaded page** — stepping past the last loaded photo does not fetch the next one. | The guest scrolls the feed (which does page) and re-opens. |
|
||||
|
||||
---
|
||||
|
||||
## 13. Event-day emergency card
|
||||
|
||||
Assume you have a phone and two minutes.
|
||||
|
||||
```bash
|
||||
cd ~/eventsnap
|
||||
|
||||
# FIRST LINE, ALWAYS. `.env` is read by docker compose, NOT by your shell — without this,
|
||||
# every `$DOMAIN` below expands to nothing and `curl https:///health` reads like an outage
|
||||
# when the site is fine.
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
# Is it alive? (200 = app AND database are answering; 503 = the app is up, the DB is not)
|
||||
curl -fsS https://$DOMAIN/health
|
||||
|
||||
# What is broken?
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 app
|
||||
|
||||
# Nuclear option that is SAFE (keeps all data):
|
||||
docker compose restart app
|
||||
|
||||
# Roll back to the identically-built sibling image — see §9 for what this can and cannot fix.
|
||||
# Do NOT substitute an older release tag here; it will crash-loop on the migration set.
|
||||
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env && docker compose up -d app frontend
|
||||
|
||||
# Disk check
|
||||
df -h /var/lib/docker
|
||||
```
|
||||
|
||||
**NEVER** run `docker compose down -v`. It deletes the database, all media, all exports and the TLS
|
||||
certificate. There is no undo.
|
||||
|
||||
Most limits are changeable from the **admin dashboard without a restart** — reach for that before
|
||||
touching the shell.
|
||||
109
README.md
109
README.md
@@ -97,17 +97,51 @@ eventsnap/
|
||||
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
|
||||
cd eventsnap
|
||||
|
||||
# 2. Configure environment
|
||||
# 2. Configure environment — set EVERY secret NOW, before step 3.
|
||||
cp .env.example .env
|
||||
nano .env # set DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.
|
||||
nano .env # DOMAIN, EVENT_NAME, EVENT_SLUG,
|
||||
# JWT_SECRET, ADMIN_PASSWORD_HASH,
|
||||
# POSTGRES_PASSWORD *and* the same password inside DATABASE_URL
|
||||
# (see "Generate required secrets" below)
|
||||
|
||||
# 3. Start the stack
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Set every secret before step 3 — `POSTGRES_PASSWORD` especially.** Postgres reads it **only
|
||||
> when it initialises its data directory**, which happens on the very first `docker compose up -d`.
|
||||
> Changing it in `.env` afterwards does not change the stored password: the app then authenticates
|
||||
> with the new one against a volume holding the old one, and you get a permanent restart loop with
|
||||
> `password authentication failed for user "eventsnap"`. The only fixes are restoring the old
|
||||
> password or `docker compose down -v`, which **deletes the database, the media and the exports**.
|
||||
> Getting it right once, up front, costs nothing; getting it wrong costs the volume.
|
||||
|
||||
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
|
||||
|
||||
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while `JWT_SECRET`/`ADMIN_PASSWORD_HASH` still hold the `.env.example` placeholders (this is deliberate — a publicly-known signing key is worse than downtime). Caddy then waits on the unhealthy `app` container and never serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line means you skipped step 2. Rotate the secrets (see below) and restart.
|
||||
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while
|
||||
> `JWT_SECRET`, `ADMIN_PASSWORD_HASH` or the password inside `DATABASE_URL` still hold the
|
||||
> `.env.example` placeholders (this is deliberate — a publicly-known signing key or database
|
||||
> password is worse than downtime). Caddy then waits on the unhealthy `app` container and never
|
||||
> serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line lists
|
||||
> **every** unset secret at once, so one edit fixes them all.
|
||||
>
|
||||
> **If it comes up but keeps restarting with `password authentication failed for user
|
||||
> "eventsnap"`:** `POSTGRES_PASSWORD` was changed after the database volume was created. Postgres
|
||||
> applies that variable only at initialisation, so `.env` and the stored password have drifted
|
||||
> apart permanently. `docker compose logs app` spells this out. Before the event, with nothing
|
||||
> worth keeping:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose down -v && docker compose up -d # -v DELETES db + media + exports. No undo.
|
||||
> ```
|
||||
>
|
||||
> **Once the event has real data, never do that.** Put the original password back into
|
||||
> `DATABASE_URL`, or change the stored one instead:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec db psql -U "$POSTGRES_USER" -c \
|
||||
> "ALTER ROLE eventsnap WITH PASSWORD 'the-password-now-in-your-.env';"
|
||||
> ```
|
||||
|
||||
> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:
|
||||
> ```bash
|
||||
@@ -179,10 +213,19 @@ TLS certificate and all data volumes survive.
|
||||
# JWT secret (64 random bytes)
|
||||
openssl rand -hex 64
|
||||
|
||||
# Admin password hash (bcrypt, cost 12)
|
||||
htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
||||
# Database password (goes in BOTH DATABASE_URL and POSTGRES_PASSWORD)
|
||||
openssl rand -hex 24
|
||||
|
||||
# Admin password hash (bcrypt). Uses an image the stack already pulls, so it needs
|
||||
# nothing installed on the host — `htpasswd` lives in apache2-utils, which a stock
|
||||
# VPS does not have. Emits cost 14 rather than 12; that is fine (admin login is
|
||||
# rate-limited and hashed off the async runtime), and any $2a/$2b/$2y hash verifies.
|
||||
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
|
||||
```
|
||||
|
||||
Wrap the resulting hash in **single quotes** in `.env` — see the note there; a bcrypt
|
||||
hash is full of `$`, and both Compose and dotenvy would otherwise eat those segments.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
See [.env.example](.env.example) for the full list with descriptions and defaults. Key variables:
|
||||
@@ -237,35 +280,49 @@ so a host takedown or a ban actually revokes access to the bytes.
|
||||
degrade one subsystem — Postgres stops being able to write and the whole event goes
|
||||
down.
|
||||
|
||||
Uploads are self-limiting. `per_user_limit = free_disk × quota_tolerance ÷
|
||||
active_uploaders` is recomputed against live free space on every upload, so guests
|
||||
converge on a fixed point at `tolerance / (1 + tolerance)` of the free space you
|
||||
started with — **43%** at the default 0.75. On an 80 GB box with ~70 GB free after
|
||||
the OS and images, media settles at ~30 GB and stops.
|
||||
**`Gallery.zip` and `Memories.zip` are each roughly a second copy of every original.**
|
||||
Both write their media `Compression::Stored`, and `Memories.zip` streams the untouched
|
||||
original for every video and for every image at or under 5 MB. So a release wants room
|
||||
for **two more copies of the gallery** on top of the gallery itself — which is what
|
||||
`required_free_bytes` encodes as `media × 1.1 × 2`.
|
||||
|
||||
**The keepsake is what the 80 GB baseline does not cover.** `Gallery.zip` and
|
||||
`Memories.zip` are built concurrently and each is roughly a second copy of every
|
||||
original: both write their media `Compression::Stored`, and `Memories.zip` streams the
|
||||
untouched original for every video and for every image at or under 5 MB. So a release
|
||||
wants room for **two more copies of the gallery** on top of the gallery itself.
|
||||
The per-user quota does **not** bound this. It is a fairness mechanism that divides
|
||||
free space between guests, and since it carries a floor (`MIN_QUOTA_LIMIT_BYTES`, so a
|
||||
guest's allowance stops shrinking as the party fills up) the aggregate ceiling it used
|
||||
to imply is gone. What bounds the disk is the **global gate in the upload handler**,
|
||||
which refuses any upload that would leave too little room to build the keepsake:
|
||||
|
||||
| Stage | Used | Free (80 GB box) |
|
||||
|---|---|---|
|
||||
| Fresh box (OS + images) | ~10 GB | ~70 GB |
|
||||
| Guests reach the quota fixed point | ~40 GB | ~40 GB |
|
||||
| Host releases → both archives | ~100 GB | **ENOSPC** |
|
||||
```
|
||||
free_after_upload < media_after × 1.1 × 2 + DISK_RESERVE_BYTES → refused
|
||||
```
|
||||
|
||||
Two ways to size for it:
|
||||
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:
|
||||
|
||||
| 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 |
|
||||
|
||||
**Uploads therefore stop at roughly 8 GB of media on a 40 GB box, not when the disk is
|
||||
full.** That is deliberate. 1000 photos at ~3.5 MB is ~3.5 GB and fits comfortably;
|
||||
video is what consumes the budget, so lower `max_video_size_mb` (seeded at 500) if you
|
||||
expect a lot of it. Refusing the 1001st upload is a far better outcome than accepting it
|
||||
and discovering at 01:00 that the archive can never be built.
|
||||
|
||||
Two ways to buy headroom:
|
||||
|
||||
- **Provision ~3× your expected media** on one volume (media + two archives), or
|
||||
- **give `exports_data` its own volume** so a full export cannot reach Postgres, and
|
||||
size that one at ~2× expected media.
|
||||
|
||||
This is no longer silent. The export refuses up front with the two numbers rather than
|
||||
hitting ENOSPC halfway through a multi-GB write, a rebuild reclaims the superseded
|
||||
generation before it starts (so peak is one generation, not two), and the host
|
||||
dashboard warns as soon as the keepsake would not fit — which is the only point at
|
||||
which anyone can still do something about it.
|
||||
None of this is silent. The upload gate refuses with a German message naming the cause,
|
||||
the export preflight refuses up front with both numbers rather than hitting ENOSPC
|
||||
halfway through a multi-GB write, a rebuild only reclaims the superseded generation
|
||||
**after** the new one lands (so a failed rebuild can never leave you with no archive at
|
||||
all), and the host dashboard warns as soon as the keepsake would not fit — which is the
|
||||
only point at which anyone can still do something about it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
12
backend/.dockerignore
Normal file
12
backend/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
# Build context exclusions.
|
||||
#
|
||||
# `target/` does not exist on a clean checkout, which is why builds have worked without this
|
||||
# file — but the moment anyone runs `cargo build` locally it becomes multi-GB, and every
|
||||
# `docker build` would ship all of it to the daemon for nothing (the Dockerfile only COPYs
|
||||
# Cargo.toml, Cargo.lock, src, static and migrations). On an EMULATED amd64 builder that
|
||||
# transfer is the slowest part of the build.
|
||||
target/
|
||||
|
||||
# Never let a real .env reach an image layer.
|
||||
.env
|
||||
.env.*
|
||||
192
backend/Cargo.lock
generated
192
backend/Cargo.lock
generated
@@ -65,56 +65,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -554,46 +504,12 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
"terminal_size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
@@ -677,15 +593,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -816,27 +723,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
@@ -1229,12 +1115,6 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.6.3"
|
||||
@@ -1622,7 +1502,6 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1656,12 +1535,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1795,12 +1668,6 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -2089,12 +1956,6 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oxipng"
|
||||
version = "9.1.5"
|
||||
@@ -2102,18 +1963,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"env_logger",
|
||||
"filetime",
|
||||
"glob",
|
||||
"indexmap",
|
||||
"libdeflater",
|
||||
"log",
|
||||
"rayon",
|
||||
"rgb",
|
||||
"rustc-hash",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2607,19 +2462,6 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3060,12 +2902,6 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -3120,16 +2956,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "terminal_size"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3505,12 +3331,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
@@ -4187,18 +4007,6 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
@@ -27,7 +27,17 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
sysinfo = "0.32"
|
||||
image = "0.25"
|
||||
oxipng = "9"
|
||||
# default-features = false drops "parallel", which is what actually bounds oxipng's memory:
|
||||
# with rayon it evaluates row filters concurrently, each trial holding its own full-size
|
||||
# buffer, and there is no Options knob to cap that. Without the feature, lib.rs swaps in a
|
||||
# sequential shim (oxipng's own supported path) so peak scales with ONE trial, not N.
|
||||
# PNG optimisation gets slower; it is a background, best-effort, lossless size saving.
|
||||
#
|
||||
# "filetime" must be KEPT: without it OutFile::Path { preserve_attrs: true } silently no-ops.
|
||||
# Dropping "binary" also removes clap/glob/env_logger — a CLI's dependencies that were being
|
||||
# compiled into a server image — and "zopfli", which preset 2 does not use (it selects
|
||||
# Deflaters::Libdeflater, which is not feature-gated).
|
||||
oxipng = { version = "9", default-features = false, features = ["filetime"] }
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||
include_dir = "0.7"
|
||||
infer = "0.15"
|
||||
|
||||
13
backend/migrations/021_hashtag_counts_respect_bans.down.sql
Normal file
13
backend/migrations/021_hashtag_counts_respect_bans.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Restore the pre-021 definition (no ban/hide filtering) exactly as 004 created it.
|
||||
DROP VIEW IF EXISTS v_hashtag_counts;
|
||||
|
||||
CREATE VIEW v_hashtag_counts AS
|
||||
SELECT
|
||||
h.event_id,
|
||||
h.tag,
|
||||
COUNT(uh.upload_id) AS upload_count
|
||||
FROM hashtag h
|
||||
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
|
||||
GROUP BY h.event_id, h.id, h.tag
|
||||
ORDER BY upload_count DESC;
|
||||
31
backend/migrations/021_hashtag_counts_respect_bans.up.sql
Normal file
31
backend/migrations/021_hashtag_counts_respect_bans.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- v_hashtag_counts: apply the same visibility rules as v_feed.
|
||||
--
|
||||
-- The chip row and the grid's tag picker are both fed by this view, but it has only ever
|
||||
-- filtered `u.deleted_at IS NULL`. Migration 011 added ban/hide filtering to the feed and
|
||||
-- never reached here, so the two disagreed about which uploads exist:
|
||||
--
|
||||
-- * A host bans a guest who posted 3 of the 12 `#tanz` photos. The chip keeps reading
|
||||
-- "#tanz 12"; tapping it returns 9. The count is presented as authoritative and is not.
|
||||
-- * A tag used ONLY by a banned or hidden guest stays in the chip row and in the tag
|
||||
-- picker as a selectable option that leads to an empty feed — a ghost filter that
|
||||
-- cannot be cleared because there is nothing wrong with it to see.
|
||||
--
|
||||
-- Bans are exactly the moment a host is watching these numbers to confirm the moderation
|
||||
-- took effect, so a stale count reads as "the ban didn't work".
|
||||
--
|
||||
-- Same predicate as v_feed (see 016_display_derivative.up.sql), joined through `user`.
|
||||
DROP VIEW IF EXISTS v_hashtag_counts;
|
||||
|
||||
CREATE VIEW v_hashtag_counts AS
|
||||
SELECT
|
||||
h.event_id,
|
||||
h.tag,
|
||||
COUNT(uh.upload_id) AS upload_count
|
||||
FROM hashtag h
|
||||
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||
JOIN upload u ON u.id = uh.upload_id AND u.deleted_at IS NULL
|
||||
JOIN "user" usr ON usr.id = u.user_id
|
||||
WHERE usr.uploads_hidden = FALSE
|
||||
AND usr.is_banned = FALSE
|
||||
GROUP BY h.event_id, h.id, h.tag
|
||||
ORDER BY upload_count DESC;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS upload_client_upload_id_key;
|
||||
ALTER TABLE upload DROP COLUMN IF EXISTS client_upload_id;
|
||||
25
backend/migrations/022_client_upload_idempotency.up.sql
Normal file
25
backend/migrations/022_client_upload_idempotency.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Idempotency key for uploads, supplied by the client.
|
||||
--
|
||||
-- The failure this closes is the ordinary one on a phone, not an exotic race: the server
|
||||
-- receives the body, validates it, commits the row, and the response is lost on the way back
|
||||
-- because the guest walked out of range or the AP dropped the connection. The client sees a
|
||||
-- network error with the blob still in hand, marks the item retryable, and re-sends it — both
|
||||
-- when the guest taps "Erneut" and automatically when the queue requeues on reconnect. Every
|
||||
-- attempt minted a fresh `Uuid::new_v4()` server-side, so the same photo landed in the gallery
|
||||
-- two or three times and was charged against the guest's storage quota each time.
|
||||
--
|
||||
-- The client already has a stable per-queue-item UUID, so it costs nothing to send. NULL is
|
||||
-- allowed and unconstrained: uploads that predate this column, and any client that doesn't send
|
||||
-- one, keep working exactly as before.
|
||||
ALTER TABLE upload ADD COLUMN client_upload_id UUID;
|
||||
|
||||
-- Partial rather than a plain UNIQUE. Postgres would tolerate the NULLs either way, but indexing
|
||||
-- only the rows that carry a key keeps it small and states the rule exactly: uniqueness applies
|
||||
-- where a key exists, and nowhere else.
|
||||
--
|
||||
-- Scoped globally rather than per user or per event. The key is a client-generated v4 UUID, so a
|
||||
-- collision between two different photos is not a real possibility, and a single-column index
|
||||
-- means the uniqueness check cannot be wrong about which event or user a retry belongs to.
|
||||
CREATE UNIQUE INDEX upload_client_upload_id_key
|
||||
ON upload (client_upload_id)
|
||||
WHERE client_upload_id IS NOT NULL;
|
||||
3
backend/migrations/023_derivative_attempts.down.sql
Normal file
3
backend/migrations/023_derivative_attempts.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_upload_derivative_backfill;
|
||||
ALTER TABLE upload DROP COLUMN IF EXISTS derivative_last_error;
|
||||
ALTER TABLE upload DROP COLUMN IF EXISTS derivative_attempts;
|
||||
23
backend/migrations/023_derivative_attempts.up.sql
Normal file
23
backend/migrations/023_derivative_attempts.up.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Bound how many times a permanently-failing upload can be re-processed.
|
||||
--
|
||||
-- Without this, one poisoned row is an outage. The upload row is committed BEFORE compression
|
||||
-- starts, `derivatives_rev` defaults to 0, and `set_derivatives_rev` only runs on success — so
|
||||
-- a row whose processing kills the container survives at rev 0, the unconditional startup
|
||||
-- backfill re-selects it on the next boot, and `restart: unless-stopped` turns that into an
|
||||
-- infinite kill loop. Every restart also drops every SSE stream and truncates every in-flight
|
||||
-- upload. That was reachable via a single large PNG (see services/compression.rs), but the
|
||||
-- shape is general: any input that can kill or hang the worker repeats forever.
|
||||
--
|
||||
-- The counter is incremented WRITE-AHEAD, before the work is attempted, because the failure
|
||||
-- mode being defended against is a SIGKILL — no error is returned, no handler runs, no Drop
|
||||
-- fires. A counter bumped in an error path increments zero times per crash and changes nothing.
|
||||
ALTER TABLE upload ADD COLUMN IF NOT EXISTS derivative_attempts SMALLINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Last failure text, so a row that has given up can be diagnosed without reproducing it.
|
||||
-- Nothing reads this in code; it exists for the operator.
|
||||
ALTER TABLE upload ADD COLUMN IF NOT EXISTS derivative_last_error TEXT;
|
||||
|
||||
-- Serves the backfill selection, which now filters on both columns.
|
||||
CREATE INDEX IF NOT EXISTS idx_upload_derivative_backfill
|
||||
ON upload (derivatives_rev, derivative_attempts)
|
||||
WHERE deleted_at IS NULL;
|
||||
26
backend/migrations/024_feed_scalar_counts.down.sql
Normal file
26
backend/migrations/024_feed_scalar_counts.down.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Restore the 016 definition verbatim.
|
||||
DROP VIEW IF EXISTS v_feed;
|
||||
CREATE VIEW v_feed AS
|
||||
SELECT
|
||||
u.id,
|
||||
u.event_id,
|
||||
u.user_id,
|
||||
usr.display_name AS uploader_name,
|
||||
usr.is_banned,
|
||||
usr.uploads_hidden,
|
||||
u.preview_path,
|
||||
u.thumbnail_path,
|
||||
u.display_path,
|
||||
u.mime_type,
|
||||
u.caption,
|
||||
u.created_at,
|
||||
COUNT(DISTINCT l.user_id) AS like_count,
|
||||
COUNT(DISTINCT c.id) AS comment_count
|
||||
FROM upload u
|
||||
JOIN "user" usr ON u.user_id = usr.id
|
||||
LEFT JOIN "like" l ON l.upload_id = u.id
|
||||
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND usr.uploads_hidden = FALSE
|
||||
AND usr.is_banned = FALSE
|
||||
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
|
||||
55
backend/migrations/024_feed_scalar_counts.up.sql
Normal file
55
backend/migrations/024_feed_scalar_counts.up.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Make a feed page cost a page, not the whole event.
|
||||
--
|
||||
-- The previous definition (016) computed like_count/comment_count with LEFT JOINs and a
|
||||
-- GROUP BY. Postgres CAN push the `event_id = $1` qual and the keyset predicate through the
|
||||
-- view — verified with EXPLAIN, it uses idx_upload_event_created_id — but it CANNOT push
|
||||
-- ORDER BY ... LIMIT across a GroupAggregate. So every feed request aggregated every upload in
|
||||
-- the event (times its likes and comments) and only then sorted and took 21 rows. The cost
|
||||
-- grew with the event, not with the page, and page 1 — the most expensive one — is exactly
|
||||
-- what refreshFeedInPlace refetches on every completed upload, from every open feed in the
|
||||
-- venue.
|
||||
--
|
||||
-- Correlated scalar subqueries move the counts ABOVE the Limit in the plan: they are evaluated
|
||||
-- once per returned row, so 21 index lookups instead of a full aggregation.
|
||||
--
|
||||
-- The rewrite is EXACTLY equivalent, not merely close:
|
||||
-- * "like" is keyed (upload_id, user_id), so COUNT(DISTINCT l.user_id) == count(*).
|
||||
-- * comment.id is the primary key, so COUNT(DISTINCT c.id) == count(*).
|
||||
-- * one row per upload either way — the GROUP BY was on u.id.
|
||||
-- Column names, order and types are unchanged (count(*) and COUNT(DISTINCT ...) are both
|
||||
-- bigint), so no Rust code changes.
|
||||
--
|
||||
-- No new index needed: idx_like_upload plus the (upload_id, user_id) PK serve the like
|
||||
-- subquery, and idx_comment_upload ... WHERE deleted_at IS NULL matches the comment
|
||||
-- subquery's predicate exactly.
|
||||
--
|
||||
-- One thing a future editor needs to know: the hashtag-filtered feed joins upload_hashtag
|
||||
-- against this view. That was safe before only because the GROUP BY collapsed the join
|
||||
-- fan-out; it is safe now because the view is one row per upload and upload_hashtag is keyed
|
||||
-- (upload_id, hashtag_id) with a single tag filtered. Adding a second tag filter would need
|
||||
-- fresh thought.
|
||||
|
||||
-- Not CASCADE: if something ever comes to depend on this view, the migration should fail
|
||||
-- loudly rather than silently drop it.
|
||||
DROP VIEW IF EXISTS v_feed;
|
||||
CREATE VIEW v_feed AS
|
||||
SELECT
|
||||
u.id,
|
||||
u.event_id,
|
||||
u.user_id,
|
||||
usr.display_name AS uploader_name,
|
||||
usr.is_banned,
|
||||
usr.uploads_hidden,
|
||||
u.preview_path,
|
||||
u.thumbnail_path,
|
||||
u.display_path,
|
||||
u.mime_type,
|
||||
u.caption,
|
||||
u.created_at,
|
||||
(SELECT count(*) FROM "like" l WHERE l.upload_id = u.id) AS like_count,
|
||||
(SELECT count(*) FROM comment c WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
|
||||
FROM upload u
|
||||
JOIN "user" usr ON u.user_id = usr.id
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND usr.uploads_hidden = FALSE
|
||||
AND usr.is_banned = FALSE;
|
||||
11
backend/migrations/025_reserved_names_and_pin_decay.down.sql
Normal file
11
backend/migrations/025_reserved_names_and_pin_decay.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- NOTE: the reserved-name rename in the up migration is NOT reversible. The original names
|
||||
-- are not recorded anywhere, and reversing it would in any case re-create the state that
|
||||
-- bricked admin login. Rolling back the schema does not roll back that data change.
|
||||
DELETE FROM config WHERE key IN (
|
||||
'recover_name_rate_per_15min',
|
||||
'pin_reset_ip_rate_per_min',
|
||||
'upload_edit_rate_per_min',
|
||||
'upload_edit_rate_enabled'
|
||||
);
|
||||
|
||||
ALTER TABLE "user" DROP COLUMN IF EXISTS last_failed_pin_at;
|
||||
70
backend/migrations/025_reserved_names_and_pin_decay.up.sql
Normal file
70
backend/migrations/025_reserved_names_and_pin_decay.up.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- Two independent auth defects that share a migration because they share a table.
|
||||
|
||||
-- 1. RESERVED NAMES — free any guest squatting on a name the admin path used to depend on.
|
||||
--
|
||||
-- Migration 007 made display_name unique per event case-insensitively, and `join` had no
|
||||
-- reserved-name guard. So any guest could join as "admin"/"Admin"/"ADMIN" before the operator's
|
||||
-- first admin login; admin_login then looked its user up BY NAME, missed (wrong role), fell
|
||||
-- through to creating "Admin", violated that unique index, and returned a 500 — permanently,
|
||||
-- with no in-app recovery. Moderation, config and gallery release all gone, fixed only by SQL.
|
||||
--
|
||||
-- The real fix is in code (look the admin up by role, never by name — see auth/handlers.rs).
|
||||
-- This clears the state an already-deployed database may be carrying.
|
||||
--
|
||||
-- RENAMED, NEVER DELETED: the guest keeps their uploads, their PIN and their session. Only
|
||||
-- non-admin rows are touched — a real admin row named "Admin" is the expected state.
|
||||
-- Two guards that are not optional, because this statement runs INSIDE the migration
|
||||
-- transaction on boot and a failure here exits the process — `restart: unless-stopped` then
|
||||
-- turns it into a crash loop with no in-app recovery. That is a strictly worse version of the
|
||||
-- lockout this migration exists to clean up after.
|
||||
--
|
||||
-- * role = 'guest', not role <> 'admin'. The enum also has 'host' (001), and hosts are
|
||||
-- promoted from guests at runtime — so <> 'admin' renamed a legitimately promoted staff
|
||||
-- member whose name happens to be "Host".
|
||||
-- * NOT EXISTS. The target name is derived, not unique: `idx_user_event_name_ci` (007) is a
|
||||
-- UNIQUE index on (event_id, lower(display_name)), and nothing stopped a second guest from
|
||||
-- having already joined as exactly "Admin (a1b2c3d4)" — the old code had no reserved-name
|
||||
-- guard and the join response hands each guest their own id. Rare, but the cost of losing
|
||||
-- that bet is the whole event.
|
||||
--
|
||||
-- A row that collides is simply left alone: `create_admin_user` already handles a name clash by
|
||||
-- falling back to `Admin-<8hex>`, and `admin_login` no longer resolves by name at all, so this
|
||||
-- cleanup is convenience rather than load-bearing.
|
||||
UPDATE "user" u
|
||||
SET display_name = u.display_name || ' (' || left(u.id::text, 8) || ')'
|
||||
WHERE u.role = 'guest'
|
||||
AND lower(u.display_name) IN ('admin', 'administrator', 'host', 'eventsnap')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "user" x
|
||||
WHERE x.event_id = u.event_id
|
||||
AND lower(x.display_name) = lower(u.display_name || ' (' || left(u.id::text, 8) || ')')
|
||||
);
|
||||
|
||||
-- 2. PIN LOCKOUT DECAY.
|
||||
--
|
||||
-- failed_pin_attempts only ever cleared on a successful recovery or after a lockout expired, so
|
||||
-- honest typos accumulated across days: a guest who fat-fingered their PIN twice last night
|
||||
-- arrives today already two-thirds of the way to being locked out. With the threshold now
|
||||
-- raised (see below) a decay window is what keeps that raise safe rather than merely lenient.
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS last_failed_pin_at TIMESTAMPTZ;
|
||||
|
||||
-- Rate-limit knobs introduced with this release.
|
||||
--
|
||||
-- recover_name_rate_per_15min (4, was a hardcoded 5): the per-(IP, name) ceiling. It MUST stay
|
||||
-- below the account-lock threshold, which is the whole defect — at 5-per-IP against a 3-strike
|
||||
-- lock, three requests from one IP locked any guest whose name is visible on the feed, every 15
|
||||
-- minutes, forever. The lock threshold moves to 12 in code, so locking a victim now needs at
|
||||
-- least three distinct sources while an honest guest never comes close.
|
||||
--
|
||||
-- pin_reset_ip_rate_per_min (30): /recover/request was the one unauthenticated endpoint with no
|
||||
-- per-IP ceiling at all — /join got one in 017 and /recover in 019, and this third one was
|
||||
-- simply missed. Its per-name key is attacker-chosen, so cycling names minted a fresh bucket
|
||||
-- every time and the per-IP cost was unbounded.
|
||||
--
|
||||
-- upload_edit_rate_per_min (30): PATCH /upload/{id} had no rate limit of any kind.
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('recover_name_rate_per_15min', '4'),
|
||||
('pin_reset_ip_rate_per_min', '30'),
|
||||
('upload_edit_rate_per_min', '30'),
|
||||
('upload_edit_rate_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -19,6 +19,58 @@ use crate::services::config;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Names a guest may not take.
|
||||
///
|
||||
/// Defence in depth only. The real fix for the admin-lockout defect is that `admin_login` now
|
||||
/// resolves its user by ROLE rather than by name (see `User::find_admin_for_event`), which is
|
||||
/// why homoglyph and zero-width bypasses of this list are not a concern: the name is no longer
|
||||
/// load-bearing for anything. What this buys is that a guest cannot impersonate the host in the
|
||||
/// feed's byline, and that "Admin" stays available for the admin row.
|
||||
const RESERVED_DISPLAY_NAMES: &[&str] = &["admin", "administrator", "host", "eventsnap"];
|
||||
|
||||
fn is_reserved_display_name(name: &str) -> bool {
|
||||
let name = name.trim().to_lowercase();
|
||||
RESERVED_DISPLAY_NAMES.contains(&name.as_str())
|
||||
}
|
||||
|
||||
/// Trim and bounds-check a display name.
|
||||
///
|
||||
/// Shared by `join`, `recover` and `request_pin_reset` so the length check happens BEFORE the
|
||||
/// name is used to build a rate-limiter key. It was inline in `join` only, so on the other two
|
||||
/// endpoints `format!("...:{ip}:{name_key}")` allocated from an unbounded, attacker-chosen
|
||||
/// string and stored it in a HashMap pruned once an hour with a 24 h ceiling — turning the
|
||||
/// limiter itself into the memory-exhaustion primitive it exists to prevent.
|
||||
fn validate_display_name(raw: &str) -> Result<&str, AppError> {
|
||||
let name = raw.trim();
|
||||
let chars = name.chars().count();
|
||||
if chars == 0 || chars > 50 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
|
||||
));
|
||||
}
|
||||
// No control characters. NUL is the hard requirement — Postgres rejects 0x00 in TEXT with a
|
||||
// 500, so catching it here turns an internal error into a clean 400 — but the rest matter
|
||||
// too, and for reasons beyond tidiness:
|
||||
//
|
||||
// * Newlines make the name a LOG INJECTION vector. Several 4xx messages interpolate it
|
||||
// ("Der Name \"X\" ist bereits vergeben.") and those are logged; a name carrying a
|
||||
// newline plus a plausible timestamp prefix lets two unauthenticated requests forge
|
||||
// entries in the only forensic record an unattended event has. `error.rs` escapes on the
|
||||
// way out as well — this is the other half, and the half that keeps the forged text out
|
||||
// of the database and out of the feed byline in the first place.
|
||||
// * A bare CR or a bidi override renders as a name that is not what was typed, in the feed,
|
||||
// the host dashboard's moderation list and the keepsake.
|
||||
//
|
||||
// Deliberately NOT a whitelist: guests have accents, emoji and non-Latin scripts in their
|
||||
// names, and rejecting those would be worse than the problem.
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Name enthält ungültige Zeichen.".into(),
|
||||
));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinRequest {
|
||||
pub display_name: String,
|
||||
@@ -62,19 +114,13 @@ pub async fn join(
|
||||
}
|
||||
}
|
||||
|
||||
let display_name = body.display_name.trim();
|
||||
let name_chars = display_name.chars().count();
|
||||
if name_chars == 0 || name_chars > 50 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
|
||||
));
|
||||
}
|
||||
// Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers
|
||||
// see a clean 400 instead of an internal error.
|
||||
if display_name.contains('\0') {
|
||||
return Err(AppError::BadRequest(
|
||||
"Name enthält ungültige Zeichen.".into(),
|
||||
));
|
||||
let display_name = validate_display_name(&body.display_name)?;
|
||||
if is_reserved_display_name(display_name) {
|
||||
// 409, matching the name-taken response below, so the frontend's existing handling
|
||||
// works unchanged. See RESERVED_DISPLAY_NAMES for why this exists.
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Der Name \"{display_name}\" ist reserviert. Bitte wähle einen anderen."
|
||||
)));
|
||||
}
|
||||
|
||||
// Per-guest bucket, keyed like the `recover:{ip}:{name}` limiter below. This carries
|
||||
@@ -151,6 +197,38 @@ pub async fn join(
|
||||
))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Hard ceiling on the CONFIGURED per-(IP, name) limit, whatever an operator sets.
|
||||
///
|
||||
/// The ordering `3 x ceiling <= PIN_LOCK_THRESHOLD` is the entire control that stops one source
|
||||
/// from locking a victim out: display names are public on the feed, so if a single IP can spend
|
||||
/// the whole lock threshold it can lock any guest it likes, repeatedly. That ordering was asserted
|
||||
/// in a comment and in a test — but the test pinned the DEFAULT constant, while the handler reads
|
||||
/// the config value, and `patch_config` accepted anything from 1 to 100_000. So an operator
|
||||
/// 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, 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
|
||||
/// number, was the defect. Display names are public on the feed, so three requests from a single
|
||||
/// IP locked any guest out of their own account, repeatable every 15 minutes, indefinitely. The
|
||||
/// tier meant to protect a guest was the easiest way to attack them.
|
||||
///
|
||||
/// Raised deliberately far above the per-IP tier so the two do different jobs. The per-(IP, name)
|
||||
/// bucket is what stops a guesser, and it costs the ATTACKER. This tier is the last line against
|
||||
/// a DISTRIBUTED guesser, and it is the only one an attacker can turn on a victim — so reaching
|
||||
/// it must require at least three distinct sources inside the decay window.
|
||||
///
|
||||
/// Brute-force cost is unchanged: 12 attempts per 15 minutes is 48/hour against one account, so
|
||||
/// 10 000 four-digit PINs still take ~208 hours no matter how many IPs are used. An honest guest
|
||||
/// fat-fingering a 4-digit PIN never comes close, and `increment_failed_pin` now decays the
|
||||
/// streak after 15 minutes so yesterday's typos don't count toward today's.
|
||||
const PIN_LOCK_THRESHOLD: i16 = 12;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RecoverRequest {
|
||||
pub display_name: String,
|
||||
@@ -182,7 +260,32 @@ fn dummy_pin_hash() -> &'static str {
|
||||
/// flood of `/recover` or `/admin/login` attempts stalls every other request on the box,
|
||||
/// including the feed. Offloading moves that cost to the blocking pool, which is sized for
|
||||
/// exactly this and whose saturation degrades logins rather than the whole app.
|
||||
/// Process-wide ceiling on CONCURRENT bcrypt work.
|
||||
///
|
||||
/// bcrypt is deliberately expensive — ~250 ms of a core at cost 12, and this deployment's own
|
||||
/// runbook generates the admin hash at a higher cost than that. Every call is correctly on
|
||||
/// `spawn_blocking`, but tokio's blocking pool defaults to 512 threads, so "off the async
|
||||
/// runtime" is not the same as "bounded": enough concurrent hashes will preempt both async
|
||||
/// worker threads a 2 vCPU box gets, and uploads, feed and SSE stall behind them.
|
||||
///
|
||||
/// Three unauthenticated endpoints reach bcrypt — `/join` (hash), `/recover` (verify, including
|
||||
/// a deliberate throwaway verify for unknown names) and `/admin/login` (verify) — each with only
|
||||
/// a per-IP bucket in front, and at a venue every guest shares one public IP. A per-IP limit
|
||||
/// therefore bounds nothing globally.
|
||||
///
|
||||
/// `cores - 1` leaves a core for actually serving requests. Excess callers WAIT on the permit
|
||||
/// rather than burning CPU, so a flood degrades to latency instead of an outage.
|
||||
static BCRYPT_PERMITS: std::sync::LazyLock<tokio::sync::Semaphore> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
tokio::sync::Semaphore::new(cores.saturating_sub(1).max(1))
|
||||
});
|
||||
|
||||
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))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
@@ -191,6 +294,9 @@ async fn verify_password(candidate: String, hash: String) -> bool {
|
||||
/// 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;
|
||||
tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
|
||||
@@ -203,13 +309,15 @@ pub async fn recover(
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<RecoverRequest>,
|
||||
) -> Result<Json<RecoverResponse>, AppError> {
|
||||
let display_name = body.display_name.trim();
|
||||
// Validated BEFORE it is used as a rate-limiter key — see `validate_display_name`. The
|
||||
// per-IP ceiling below is keyed only on the IP, so it is safe to run either side of this;
|
||||
// the per-NAME bucket is not.
|
||||
let display_name = validate_display_name(&body.display_name)?;
|
||||
|
||||
// Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this
|
||||
// an attacker who knows a display name (they're visible on the feed) can
|
||||
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
||||
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
||||
// softens that into a real cost.
|
||||
// Per-IP+name throttle BEFORE the per-user lockout counter. Without this an attacker who
|
||||
// knows a display name (they're visible on the feed) can burn through the victim's wrong-PIN
|
||||
// budget and lock them out, repeatedly. The ceiling here MUST stay below
|
||||
// PIN_LOCK_THRESHOLD — see the constant for why that ordering is the whole control.
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
||||
@@ -234,10 +342,20 @@ pub async fn recover(
|
||||
));
|
||||
}
|
||||
|
||||
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}"),
|
||||
5,
|
||||
name_ceiling,
|
||||
Duration::from_secs(15 * 60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
@@ -317,7 +435,7 @@ pub async fn recover(
|
||||
attempts,
|
||||
"recover: wrong PIN"
|
||||
);
|
||||
if attempts >= 3 {
|
||||
if attempts >= PIN_LOCK_THRESHOLD {
|
||||
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
||||
User::lock_pin(&state.pool, user.id, lockout).await?;
|
||||
tracing::warn!(
|
||||
@@ -346,6 +464,18 @@ pub struct AdminLoginResponse {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// Requests per minute per IP that may reach `verify_password` at all.
|
||||
///
|
||||
/// 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.
|
||||
const ADMIN_LOGIN_CPU_CEILING: usize = 30;
|
||||
|
||||
pub async fn admin_login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
@@ -358,21 +488,29 @@ pub async fn admin_login(
|
||||
));
|
||||
}
|
||||
|
||||
// Throttle password attempts. The admin password is bcrypt-hashed (slow to
|
||||
// verify) but with no IP-level limit a determined attacker can still mount
|
||||
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
||||
// honest typos.
|
||||
// Throttling here is in two parts, and the ORDER is the whole point.
|
||||
//
|
||||
// A single tight IP-keyed bucket checked before the password was verified made this
|
||||
// endpoint a denial-of-service against its own operator. Every guest at the venue shares
|
||||
// one public IP behind NAT, `/admin/login` is a public linkable page, and the check ran
|
||||
// BEFORE `verify_password` — so five requests a minute from any phone in the room kept the
|
||||
// bucket permanently full and the admin, on that same IP, could never spend a slot.
|
||||
// Successful logins consumed budget too, so a typo plus a retry on two devices did it by
|
||||
// accident. And the escape hatch was circular: `admin_login_rate_enabled` can only be
|
||||
// flipped through `PATCH /admin/config`, which needs the session being blocked.
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let admin_rate_on =
|
||||
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||
// Stays keyed by IP on purpose: this guards a single shared credential, so a per-user
|
||||
// or per-name key would just hand an attacker a fresh bucket per guess.
|
||||
|
||||
// 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
|
||||
&& admin_rate_on
|
||||
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("admin_login:{ip}"),
|
||||
5,
|
||||
format!("admin_login_cpu:{ip}"),
|
||||
ADMIN_LOGIN_CPU_CEILING,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
{
|
||||
@@ -389,6 +527,24 @@ pub async fn admin_login(
|
||||
.await;
|
||||
|
||||
if !valid {
|
||||
// Part 2: the tight bucket, charged ONLY on a wrong password. A correct password is
|
||||
// never rate-limited, so no amount of guessing by anyone else can lock the operator
|
||||
// out — which also dissolves the circular escape hatch above. Brute force is still
|
||||
// bounded: every wrong guess costs a slot, and slots are per-IP.
|
||||
if rate_limits_on
|
||||
&& admin_rate_on
|
||||
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("admin_login_fail:{ip}"),
|
||||
5,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
{
|
||||
tracing::warn!(ip = %ip, "admin_login: wrong password, failure bucket exhausted");
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
||||
}
|
||||
@@ -400,27 +556,16 @@ pub async fn admin_login(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Find or create the admin user for this event
|
||||
let admin_name = "Admin";
|
||||
let users = User::find_by_event_and_name(&state.pool, event.id, admin_name).await?;
|
||||
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
|
||||
u
|
||||
} else {
|
||||
// Admin authenticates via password, but the schema still requires a PIN
|
||||
// hash. Generate a random unguessable PIN so the recovery path remains
|
||||
// unusable as an escalation route even if the role flag ever got cleared.
|
||||
let dummy_pin: String = (0..32)
|
||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||
.collect();
|
||||
let dummy_hash = hash_password(dummy_pin.clone(), 4).await?;
|
||||
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
||||
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
User::find_by_id(&state.pool, user.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
|
||||
// Find or create the admin user for this event — BY ROLE, never by name.
|
||||
//
|
||||
// The name lookup this replaces is what made admin login brickable. Migration 007 makes
|
||||
// display_name unique per event case-insensitively and `join` had no reserved-name guard,
|
||||
// so a guest joining as "admin" before the operator's first login made the lookup miss on
|
||||
// role, the fallback `create("Admin")` violate that index, and `?` return a permanent 500 —
|
||||
// taking out moderation, config and gallery release with no in-app recovery.
|
||||
let admin_user = match User::find_admin_for_event(&state.pool, event.id).await? {
|
||||
Some(u) => u,
|
||||
None => create_admin_user(&state, event.id).await?,
|
||||
};
|
||||
|
||||
tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success");
|
||||
@@ -445,6 +590,51 @@ pub async fn admin_login(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create this event's admin row on first successful admin login.
|
||||
///
|
||||
/// Prefers the name "Admin". If a legacy database has a guest squatting on it — the state
|
||||
/// migration 023 renames away, but a row could also predate that or be created between
|
||||
/// migrations — falls back to a suffixed name rather than failing the login.
|
||||
///
|
||||
/// PROMOTING THE SQUATTING ROW WOULD BE A SERIOUS MISTAKE, and is the obvious-looking fix, so
|
||||
/// it is spelled out: that row carries a `recovery_pin_hash` the guest knows. Setting
|
||||
/// `role = 'admin'` on it would hand them the admin dashboard through `/recover`, permanently,
|
||||
/// via a path that needs no password. A separate row under an uglier name is worse UX and much
|
||||
/// better security — and since the lookup is now by role, the fallback name never has to be
|
||||
/// guessed again on a later login.
|
||||
async fn create_admin_user(state: &AppState, event_id: Uuid) -> Result<User, AppError> {
|
||||
// Admin authenticates via password, but the schema still requires a PIN hash. Generate a
|
||||
// random unguessable one so the recovery path stays unusable as an escalation route even if
|
||||
// the role flag were ever cleared.
|
||||
let dummy_pin: String = (0..32)
|
||||
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
||||
.collect();
|
||||
let dummy_hash = hash_password(dummy_pin, 4).await?;
|
||||
|
||||
match User::create_with_role(&state.pool, event_id, "Admin", &dummy_hash, UserRole::Admin).await
|
||||
{
|
||||
Ok(u) => Ok(u),
|
||||
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
|
||||
let fallback = format!("Admin-{}", &Uuid::new_v4().to_string()[..8]);
|
||||
tracing::warn!(
|
||||
%event_id, %fallback,
|
||||
"the name \"Admin\" is held by a non-admin user; creating the admin under a \
|
||||
fallback name. Rename that guest to free it — do NOT promote their row, they \
|
||||
know its recovery PIN."
|
||||
);
|
||||
Ok(User::create_with_role(
|
||||
&state.pool,
|
||||
event_id,
|
||||
&fallback,
|
||||
&dummy_hash,
|
||||
UserRole::Admin,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
|
||||
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -476,9 +666,38 @@ pub async fn request_pin_reset(
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<PinResetRequestBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let display_name = body.display_name.trim();
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
|
||||
// Coarse per-IP ceiling FIRST, keyed only on the IP so its key is bounded by construction.
|
||||
// /join got one of these in migration 017 and /recover in 019; this third unauthenticated
|
||||
// endpoint was simply missed — migration 019's own comment describes exactly this attack.
|
||||
// Without it, the per-name bucket below is no ceiling at all: the name is attacker-chosen,
|
||||
// so cycling names mints a fresh bucket every request.
|
||||
if rate_limits_on {
|
||||
let ip_ceiling =
|
||||
config::get_usize(&state.config_cache, "pin_reset_ip_rate_per_min", 30).await;
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("pin_reset_ip:{ip}"),
|
||||
ip_ceiling,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validated BEFORE the per-name key is built, so an unbounded name can never be retained in
|
||||
// the limiter map. NOTE the 204: this endpoint's contract is that it answers identically
|
||||
// whether or not the name exists, so it cannot enumerate guests. A 400 here would be a new
|
||||
// signal — it would distinguish a malformed name from a well-formed unknown one. Silence is
|
||||
// the correct response, and matches what an empty name already did.
|
||||
let Ok(display_name) = validate_display_name(&body.display_name) else {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
};
|
||||
|
||||
if rate_limits_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
@@ -492,9 +711,6 @@ pub async fn request_pin_reset(
|
||||
));
|
||||
}
|
||||
}
|
||||
if display_name.is_empty() {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// Single statement so the existing-name and unknown-name paths do IDENTICAL work
|
||||
// (same event+user index scans, an INSERT that matches 0 rows for an unknown name) —
|
||||
@@ -524,3 +740,79 @@ pub async fn request_pin_reset(
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Control characters are rejected at the door. Newlines in particular: several 4xx messages
|
||||
/// interpolate the display name and those are logged, so a name carrying a newline plus a
|
||||
/// plausible prefix would let two unauthenticated requests forge lines in the event's only
|
||||
/// forensic record. `error.rs` escapes on output too; this keeps it out of the database and
|
||||
/// the feed byline in the first place.
|
||||
#[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");
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// THE defect, stated as arithmetic: the account-lock threshold sat BELOW the per-(IP, name)
|
||||
/// attempt ceiling, so a single IP could exhaust it and lock any guest whose display name is
|
||||
/// visible on the feed — every 15 minutes, indefinitely. The tier meant to protect a guest
|
||||
/// was the cheapest way to attack them.
|
||||
///
|
||||
/// The fix is the ORDERING, not either number on its own, so that is what this pins.
|
||||
#[test]
|
||||
fn one_ip_cannot_reach_the_account_lock() {
|
||||
assert!(
|
||||
PIN_LOCK_THRESHOLD as usize >= RECOVER_NAME_CEILING_MAX * 3,
|
||||
"locking a victim must require at least three distinct sources; \
|
||||
threshold {PIN_LOCK_THRESHOLD} vs the ENFORCED per-IP ceiling \
|
||||
{RECOVER_NAME_CEILING_MAX} (the default is {RECOVER_NAME_CEILING_DEFAULT})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Raising the threshold must not quietly weaken brute-force resistance. 4-digit PINs, and
|
||||
/// the lockout window is 15 minutes, so an attacker gets PIN_LOCK_THRESHOLD tries per window.
|
||||
#[test]
|
||||
fn the_raised_threshold_still_makes_guessing_a_four_digit_pin_impractical() {
|
||||
let attempts_per_hour = PIN_LOCK_THRESHOLD as u64 * 4; // four 15-minute windows
|
||||
let hours_for_full_keyspace = 10_000 / attempts_per_hour;
|
||||
assert!(
|
||||
hours_for_full_keyspace >= 168,
|
||||
"exhausting 10k PINs would take {hours_for_full_keyspace}h — under a week is too fast"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_names_are_matched_case_insensitively_and_trimmed() {
|
||||
for name in ["admin", "Admin", "ADMIN", " Host ", "EventSnap"] {
|
||||
assert!(is_reserved_display_name(name), "{name} must be reserved");
|
||||
}
|
||||
}
|
||||
|
||||
/// A substring match here would reject perfectly ordinary names, which is a worse outcome
|
||||
/// than the impersonation the list guards against.
|
||||
#[test]
|
||||
fn names_that_merely_contain_a_reserved_word_are_allowed() {
|
||||
for name in ["Administrata", "Hostess", "Adminah", "Ghost", "hosting"] {
|
||||
assert!(!is_reserved_display_name(name), "{name} must be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_names_are_bounded_before_they_can_become_a_rate_limit_key() {
|
||||
assert!(validate_display_name(" Lena ").is_ok());
|
||||
assert_eq!(validate_display_name(" Lena ").unwrap(), "Lena");
|
||||
// The case that made the limiter itself the exhaustion primitive.
|
||||
assert!(validate_display_name(&"a".repeat(51)).is_err());
|
||||
assert!(validate_display_name(&"a".repeat(2_000_000)).is_err());
|
||||
assert!(validate_display_name(" ").is_err());
|
||||
assert!(validate_display_name("bad\0name").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,92 @@ fn looks_placeholder(s: &str) -> bool {
|
||||
|| lower.contains("placeholder")
|
||||
}
|
||||
|
||||
/// A bcrypt hash is exactly 60 characters and opens with `$2<variant>$<cost>$`.
|
||||
///
|
||||
/// Checking the SHAPE, not just placeholder-ness, is what catches a hash silently mangled in
|
||||
/// transit. The `$` segments are variable-expansion bait for both shell quoting and Docker
|
||||
/// Compose's `env_file` parsing, and a mangled hash is not a placeholder — so without this it
|
||||
/// passes every other guard here, the app boots green, `/health` returns `ok`, and every admin
|
||||
/// login 401s.
|
||||
///
|
||||
/// That failure is unrecoverable mid-event, which is why it is worth a hard fail at boot: the
|
||||
/// Admin row is created BY a successful admin login (`auth/handlers.rs`), and only an Admin or
|
||||
/// Host can promote a Host. No admin login therefore means no host at all — the event cannot be
|
||||
/// closed, the gallery cannot be released, and nothing can be moderated.
|
||||
fn looks_bcrypt(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
s.len() == 60
|
||||
&& b[0] == b'$'
|
||||
&& b[1] == b'2'
|
||||
&& matches!(b[2], b'a' | b'b' | b'x' | b'y')
|
||||
&& b[3] == b'$'
|
||||
&& b[4].is_ascii_digit()
|
||||
&& b[5].is_ascii_digit()
|
||||
&& b[6] == b'$'
|
||||
}
|
||||
|
||||
/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
|
||||
/// with a publicly-known signing key is worse than one that refuses to start.
|
||||
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
|
||||
fn validate_secrets(is_prod: bool, jwt_secret: &str, admin_password_hash: &str) -> Result<()> {
|
||||
///
|
||||
/// EVERY failure is collected and reported together. Returning on the first one made fixing two
|
||||
/// secrets cost two boot cycles — the operator rotates JWT_SECRET, restarts, and only then learns
|
||||
/// about ADMIN_PASSWORD_HASH. Restarting this stack is not free (Caddy waits on the unhealthy app),
|
||||
/// and each avoidable cycle is another chance to reach for `down -v`.
|
||||
fn validate_secrets(
|
||||
is_prod: bool,
|
||||
jwt_secret: &str,
|
||||
admin_password_hash: &str,
|
||||
database_url: &str,
|
||||
) -> Result<()> {
|
||||
if is_prod {
|
||||
let mut problems: Vec<&str> = Vec::new();
|
||||
if looks_placeholder(jwt_secret) {
|
||||
return Err(anyhow!(
|
||||
"Refusing to start in production with a placeholder JWT_SECRET — \
|
||||
rotate it (openssl rand -hex 64)."
|
||||
));
|
||||
}
|
||||
if jwt_secret.len() < 32 {
|
||||
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
|
||||
problems.push(
|
||||
"JWT_SECRET is still the .env.example placeholder — rotate it \
|
||||
(openssl rand -hex 64).",
|
||||
);
|
||||
} else if jwt_secret.len() < 32 {
|
||||
problems.push("JWT_SECRET must be at least 32 characters.");
|
||||
}
|
||||
if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
|
||||
problems.push(
|
||||
"ADMIN_PASSWORD_HASH is unset or still the .env.example placeholder — generate one \
|
||||
(docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>').",
|
||||
);
|
||||
} else if !looks_bcrypt(admin_password_hash) {
|
||||
problems.push(
|
||||
"ADMIN_PASSWORD_HASH is not a well-formed bcrypt hash: expected exactly 60 \
|
||||
characters starting `$2b$12$…`. First look at the value that actually reached \
|
||||
the app — `docker compose exec app printenv ADMIN_PASSWORD_HASH` — and compare \
|
||||
it to .env character for character. In .env, SINGLE-QUOTE the hash \
|
||||
('$2b$12$…'): Compose uses single-quoted env_file values literally, so the `$` \
|
||||
segments survive. Double them to `$$` ONLY when setting the value under \
|
||||
`environment:` in docker-compose.yml — doing that in .env corrupts a hash that \
|
||||
would otherwise have worked. Regenerate with: \
|
||||
docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>'",
|
||||
);
|
||||
}
|
||||
// The DATABASE_URL carries the Postgres password, so a placeholder here means the stack is
|
||||
// running on `CHANGE_ME_use_a_strong_password` — a credential published in the repo. The
|
||||
// app used to boot green on it, because this guard only ever covered the two secrets it
|
||||
// was written for and nothing else looked at POSTGRES_PASSWORD at all.
|
||||
//
|
||||
// Read POSTGRES_PASSWORD's docs before changing this: it is applied ONLY at initdb, so the
|
||||
// remedy is not "edit .env and restart" — see the 28P01 diagnostic in db.rs.
|
||||
if looks_placeholder(database_url) {
|
||||
problems.push(
|
||||
"DATABASE_URL still carries the .env.example placeholder password — set a strong \
|
||||
one (openssl rand -hex 24) in BOTH DATABASE_URL and POSTGRES_PASSWORD.",
|
||||
);
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Refusing to start in production without a real ADMIN_PASSWORD_HASH — \
|
||||
generate one (htpasswd -bnBC 12 '' <password> | tr -d ':\\n')."
|
||||
"Refusing to start in production — {} secret(s) still unset or placeholder:\n - {}\n\
|
||||
ALL secrets must be set BEFORE the first `docker compose up -d`: Postgres bakes \
|
||||
POSTGRES_PASSWORD into its data directory on first boot and ignores later changes.",
|
||||
problems.len(),
|
||||
problems.join("\n - ")
|
||||
));
|
||||
}
|
||||
} else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
|
||||
@@ -89,11 +157,12 @@ impl AppConfig {
|
||||
|
||||
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
|
||||
let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
|
||||
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
|
||||
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
|
||||
validate_secrets(is_prod, &jwt_secret, &admin_password_hash, &database_url)?;
|
||||
|
||||
Ok(Self {
|
||||
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?,
|
||||
database_url,
|
||||
jwt_secret,
|
||||
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
@@ -140,13 +209,22 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
|
||||
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuv.wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012";
|
||||
// A structurally valid bcrypt hash: exactly 60 chars, `$2y$12$` + 53 of salt/digest.
|
||||
// The shape matters — `looks_bcrypt` enforces it, so a fixture of the wrong length
|
||||
// would assert the opposite of what these tests claim.
|
||||
const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY01";
|
||||
const REAL_DB_URL: &str = "postgres://eventsnap:7f3a9c1e5b2d8a4f@db:5432/eventsnap";
|
||||
|
||||
#[test]
|
||||
fn prod_rejects_shipped_placeholder_secret() {
|
||||
// The exact string shipped in `.env` — >32 chars, so it must be caught by
|
||||
// the substring guard, not the length check.
|
||||
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
|
||||
let err = validate_secrets(
|
||||
true,
|
||||
"change_me_to_a_random_64_byte_hex_string",
|
||||
REAL_HASH,
|
||||
REAL_DB_URL,
|
||||
);
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"placeholder JWT_SECRET must be rejected in prod"
|
||||
@@ -155,29 +233,133 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prod_rejects_dev_sentinel_and_short_secret() {
|
||||
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH).is_err());
|
||||
assert!(validate_secrets(true, "tooshort", REAL_HASH).is_err());
|
||||
assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH, REAL_DB_URL).is_err());
|
||||
assert!(validate_secrets(true, "tooshort", REAL_HASH, REAL_DB_URL).is_err());
|
||||
}
|
||||
|
||||
/// The failure this guards is silent and unrecoverable mid-event: a hash whose `$`
|
||||
/// segments were eaten by shell or Compose interpolation is NOT a placeholder, so every
|
||||
/// other guard passes, the app boots green and `/health` reports ok — and then every
|
||||
/// admin login 401s, which (because the Admin row is created by a successful login, and
|
||||
/// only an Admin/Host can promote a Host) means no host exists for the whole event.
|
||||
#[test]
|
||||
fn prod_rejects_mangled_admin_hash() {
|
||||
// What `$2y$12$…` degrades to once `$2y`/`$12` are read as unset variables.
|
||||
assert!(validate_secrets(true, REAL_SECRET, "abcdefghijklmnop", REAL_DB_URL).is_err());
|
||||
// Right prefix, truncated body — still not a usable hash.
|
||||
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$tooshort", REAL_DB_URL).is_err());
|
||||
// Correct length but no bcrypt prefix at all.
|
||||
assert!(validate_secrets(true, REAL_SECRET, &"x".repeat(60), REAL_DB_URL).is_err());
|
||||
// All three shipped bcrypt variants stay acceptable.
|
||||
let body = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY01";
|
||||
for variant in ["2a", "2b", "2y"] {
|
||||
let hash = format!("${variant}$12${body}");
|
||||
assert_eq!(hash.len(), 60);
|
||||
assert!(
|
||||
validate_secrets(true, REAL_SECRET, &hash, REAL_DB_URL).is_ok(),
|
||||
"bcrypt variant ${variant}$ must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prod_rejects_missing_or_placeholder_admin_hash() {
|
||||
assert!(validate_secrets(true, REAL_SECRET, "").is_err());
|
||||
assert!(validate_secrets(true, REAL_SECRET, "$2y$12$placeholder_replace_me").is_err());
|
||||
assert!(validate_secrets(true, REAL_SECRET, "", REAL_DB_URL).is_err());
|
||||
assert!(
|
||||
validate_secrets(
|
||||
true,
|
||||
REAL_SECRET,
|
||||
"$2y$12$placeholder_replace_me",
|
||||
REAL_DB_URL
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// The stack used to come up GREEN on the database password published in the repo: this guard
|
||||
/// covered the two secrets it was written for, and nothing anywhere looked at the Postgres
|
||||
/// credential. README step 2 doesn't name POSTGRES_PASSWORD either, so following the
|
||||
/// documented procedure verbatim shipped it.
|
||||
#[test]
|
||||
fn prod_rejects_the_shipped_placeholder_database_password() {
|
||||
let shipped = "postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap";
|
||||
let err = validate_secrets(true, REAL_SECRET, REAL_HASH, shipped).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("DATABASE_URL"),
|
||||
"the refusal must name DATABASE_URL, not just fail: {err}"
|
||||
);
|
||||
// And it must point at the initdb trap, or the operator edits .env, restarts, and lands
|
||||
// in a permanent auth-failure loop instead.
|
||||
assert!(
|
||||
err.to_string().contains("POSTGRES_PASSWORD"),
|
||||
"the refusal must name POSTGRES_PASSWORD as the other half: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every problem in ONE message. Reporting them one per boot made fixing two secrets cost two
|
||||
/// restart cycles, on a stack where Caddy waits on the unhealthy app the whole time.
|
||||
#[test]
|
||||
fn prod_reports_every_placeholder_at_once() {
|
||||
let err = validate_secrets(
|
||||
true,
|
||||
"change_me_to_a_random_64_byte_hex_string",
|
||||
"$2y$12$placeholder_replace_me",
|
||||
"postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap",
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
for expected in ["JWT_SECRET", "ADMIN_PASSWORD_HASH", "DATABASE_URL"] {
|
||||
assert!(err.contains(expected), "{expected} missing from: {err}");
|
||||
}
|
||||
assert!(
|
||||
err.contains("3 secret(s)"),
|
||||
"the count must match what is listed: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prod_accepts_real_secrets() {
|
||||
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH).is_ok());
|
||||
assert!(validate_secrets(true, REAL_SECRET, REAL_HASH, REAL_DB_URL).is_ok());
|
||||
}
|
||||
|
||||
/// A real password that happens to contain no placeholder substring must pass — including one
|
||||
/// with URL-ish punctuation, so the guard can't be mistaken for a URL validator.
|
||||
#[test]
|
||||
fn prod_accepts_a_real_database_url_with_awkward_punctuation() {
|
||||
assert!(
|
||||
validate_secrets(
|
||||
true,
|
||||
REAL_SECRET,
|
||||
REAL_HASH,
|
||||
"postgres://eventsnap:aB3%24xY9-_.qW@db:5432/eventsnap"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// The e2e stack runs without APP_ENV=production, so none of this applies there — but assert
|
||||
/// it, because a guard that tripped in e2e would be found the hard way.
|
||||
#[test]
|
||||
fn non_prod_ignores_a_placeholder_database_url() {
|
||||
assert!(
|
||||
validate_secrets(
|
||||
false,
|
||||
REAL_SECRET,
|
||||
"",
|
||||
"postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_prod_tolerates_dev_sentinel() {
|
||||
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "").is_ok());
|
||||
assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "", REAL_DB_URL).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_prod_still_rejects_short_non_sentinel_secret() {
|
||||
assert!(validate_secrets(false, "tooshort", "").is_err());
|
||||
assert!(validate_secrets(false, "tooshort", "", REAL_DB_URL).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -185,9 +367,23 @@ mod tests {
|
||||
// looks_placeholder lowercases before matching — an upper/mixed-case
|
||||
// placeholder must still be rejected in prod.
|
||||
assert!(
|
||||
validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err()
|
||||
validate_secrets(
|
||||
true,
|
||||
"CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING",
|
||||
REAL_HASH,
|
||||
REAL_DB_URL
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_secrets(
|
||||
true,
|
||||
REAL_SECRET,
|
||||
"$2Y$12$PLACEHOLDER_replace_me",
|
||||
REAL_DB_URL
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +393,7 @@ mod tests {
|
||||
const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
|
||||
assert_eq!(LEN_32.len(), 32);
|
||||
assert_eq!(LEN_31.len(), 31);
|
||||
assert!(validate_secrets(true, LEN_32, REAL_HASH).is_ok());
|
||||
assert!(validate_secrets(true, LEN_31, REAL_HASH).is_err());
|
||||
assert!(validate_secrets(true, LEN_32, REAL_HASH, REAL_DB_URL).is_ok());
|
||||
assert!(validate_secrets(true, LEN_31, REAL_HASH, REAL_DB_URL).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,90 @@ use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
|
||||
|
||||
/// SQLSTATE for `invalid_password`.
|
||||
const PG_INVALID_PASSWORD: &str = "28P01";
|
||||
|
||||
/// Turn the one connect failure with an unguessable cause into a self-explaining one.
|
||||
///
|
||||
/// `POSTGRES_PASSWORD` is honoured ONLY when Postgres initialises its data directory. Change it in
|
||||
/// `.env` afterwards and the app authenticates with the new password against a volume that still
|
||||
/// holds the old one — a permanent restart loop whose only symptom is
|
||||
/// `password authentication failed`.
|
||||
///
|
||||
/// The production secret guard makes that sequence NEARLY CERTAIN rather than rare: it stops the
|
||||
/// app on the first `docker compose up -d`, but not the `db` service in that same command, which
|
||||
/// initialises and bakes in whatever password was in `.env` at that moment. So the intended
|
||||
/// recovery — see the refusal, fix your secrets, boot again — is exactly the sequence that breaks
|
||||
/// it. Nothing in the error names the cause, and the remedy destroys data, so it is the last thing
|
||||
/// an operator should guess at.
|
||||
fn explain_auth_failure(err: &sqlx::Error) {
|
||||
let is_auth_failure = match err {
|
||||
sqlx::Error::Database(db) => db.code().as_deref() == Some(PG_INVALID_PASSWORD),
|
||||
_ => false,
|
||||
};
|
||||
if !is_auth_failure {
|
||||
return;
|
||||
}
|
||||
tracing::error!(
|
||||
"Postgres rejected the credentials in DATABASE_URL (SQLSTATE {PG_INVALID_PASSWORD}).\n\
|
||||
\n\
|
||||
This almost always means POSTGRES_PASSWORD was changed AFTER the database volume was \
|
||||
first created. Postgres applies that variable only when it initialises its data \
|
||||
directory; editing .env and restarting does not change the stored password, so the two \
|
||||
drift apart permanently.\n\
|
||||
\n\
|
||||
If the event has NOT started and you have no data worth keeping:\n\n \
|
||||
docker compose down -v && docker compose up -d\n\n\
|
||||
(-v DELETES the database, the uploaded media and the exports. There is no undo.)\n\
|
||||
\n\
|
||||
If you DO have data: restore the old password into DATABASE_URL instead, or change the \
|
||||
stored one with ALTER ROLE inside the running db container. Never reach for -v to fix a \
|
||||
login problem on a live event."
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
let pool = match PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
// Fail fast instead of parking. sqlx's default is 30s, which on a DB blip means every
|
||||
// request AND all ~100 SSE session revalidations sit on the pool for half a minute
|
||||
// before erroring — the app looks hung rather than degraded, and the backlog outlives
|
||||
// the blip. Five seconds is far longer than a healthy acquire ever takes.
|
||||
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||
// Keep a couple of connections warm so the first request after an idle stretch (the gap
|
||||
// between setting the venue up and the guests arriving) doesn't pay TCP + auth.
|
||||
.min_connections(2)
|
||||
// Bound every statement server-side. Without this a single pathological query holds a
|
||||
// pool slot indefinitely and no client-side timeout can take it back — the slot is only
|
||||
// released when Postgres finishes. `lock_timeout` covers the same hazard for a row lock
|
||||
// contended by, say, a release running against an in-flight upload.
|
||||
.after_connect(|conn, _meta| {
|
||||
Box::pin(async move {
|
||||
// Two statements, two round-trips, deliberately. `sqlx::query` uses the extended
|
||||
// query protocol, which permits exactly ONE statement per call — sending them as
|
||||
// `SET a; SET b` makes every new connection fail, which surfaces as the pool
|
||||
// never opening one at all and `create_pool` reporting a connect timeout.
|
||||
sqlx::query("SET statement_timeout = '15s'")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query("SET lock_timeout = '5s'").execute(conn).await?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.connect(database_url)
|
||||
.await
|
||||
.context("failed to connect to database")?;
|
||||
{
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
explain_auth_failure(&e);
|
||||
return Err(e).context("failed to connect to database");
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
|
||||
@@ -19,6 +19,12 @@ pub enum AppError {
|
||||
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
|
||||
/// retrying a permanently-failing upload forever.
|
||||
QuotaExceeded(String),
|
||||
/// The server is temporarily unable to serve this request — currently only pool
|
||||
/// saturation. Distinct from `Internal` because it is TRANSIENT and the client should be
|
||||
/// told so: a 500 reads as "this request is broken", while a 503 + Retry-After reads as
|
||||
/// "come back shortly", which is what the upload queue's retry classifier needs to make
|
||||
/// the right call. Second field: optional retry-after seconds.
|
||||
ServiceUnavailable(String, Option<u64>),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -33,6 +39,9 @@ impl AppError {
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
|
||||
Self::ServiceUnavailable(..) => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable")
|
||||
}
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||
}
|
||||
}
|
||||
@@ -46,6 +55,7 @@ impl AppError {
|
||||
| Self::NotFound(msg)
|
||||
| Self::Conflict(msg) => msg.clone(),
|
||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||
Self::ServiceUnavailable(msg, _) => msg.clone(),
|
||||
Self::QuotaExceeded(msg) => msg.clone(),
|
||||
Self::Internal(err) => {
|
||||
tracing::error!("internal error: {err:#}");
|
||||
@@ -58,13 +68,61 @@ impl AppError {
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = self.status_and_code();
|
||||
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
|
||||
Some(*secs)
|
||||
} else {
|
||||
None
|
||||
// BOTH retry-carrying variants must be matched here. `message()` would fail to
|
||||
// compile on a missing arm; this one would not — it would silently drop the header and
|
||||
// the `retry_after_secs` body field, which is exactly the sort of omission that only
|
||||
// shows up under the load the 503 exists for.
|
||||
let retry_after_secs = match &self {
|
||||
Self::TooManyRequests(_, secs) | Self::ServiceUnavailable(_, secs) => *secs,
|
||||
_ => None,
|
||||
};
|
||||
let message = self.message();
|
||||
|
||||
// Log every 4xx. Until now they were invisible at ANY log level: tower_http's
|
||||
// `ServerErrorsAsFailures` classifier counts a 4xx as a *success*, so it goes to
|
||||
// `DefaultOnResponse` at DEBUG, and production runs at `info`. The consequence is that a
|
||||
// misconfigured limit leaves no trace at all — if guests spend the evening hitting 429s
|
||||
// on `upload_rate_per_hour`, or 413s on the storage quota, `docker compose logs` after
|
||||
// the event contains nothing about it and the cause is unknowable.
|
||||
//
|
||||
// WARN rather than INFO because every variant here is a request that did not do what
|
||||
// the guest asked. 5xx is excluded: `Internal` already logs with its full source chain
|
||||
// in `message()` above, and the pool-exhaustion 503 logs at construction — logging again
|
||||
// here would double every server-side failure.
|
||||
//
|
||||
// No request context is available: `into_response` receives only the error, so there is
|
||||
// no path, method or user id to attach. Status + code + message is what can honestly be
|
||||
// reported from here, and it is enough to see the SHAPE of a bad evening. Raising
|
||||
// `tower_http` to DEBUG instead was considered and rejected — see the note in main.rs.
|
||||
//
|
||||
// `detail = ?message`, NOT `%message`. Two reasons, both learned the hard way:
|
||||
//
|
||||
// * `message` is tracing's own reserved field for an event's format literal, so `%message`
|
||||
// printed unlabelled and would collide under a JSON layer.
|
||||
// * Debug formatting QUOTES AND ESCAPES the string, and several 4xx messages interpolate
|
||||
// attacker-chosen text — the guest's name in `Der Name "X" ist bereits vergeben.`, and
|
||||
// multipart/parse errors that echo their input. With Display formatting, a value
|
||||
// carrying a newline plus a plausible log prefix lets two unauthenticated requests
|
||||
// forge lines in the only forensic record an unattended event has.
|
||||
// `validate_display_name` now rejects control characters, so the name route is closed
|
||||
// at the source as well — but that is ONE input, and this line formats every 4xx
|
||||
// message in the app. Escaping here is what makes the guarantee general; do not
|
||||
// "simplify" it to `%message` on the grounds that names are already validated.
|
||||
//
|
||||
// 401 and 404 are logged at DEBUG rather than WARN. They carry no operator signal (an
|
||||
// expired session, a mistyped URL) and they are the cheapest lines for a scanner to
|
||||
// generate — at ~260 bytes each against the 30 MB the json-file driver retains
|
||||
// (docker-compose.yml), a sustained flood could otherwise roll the whole window in
|
||||
// minutes and destroy the post-event forensics this logging exists to provide.
|
||||
if status.is_client_error() {
|
||||
let noisy = status == StatusCode::UNAUTHORIZED || status == StatusCode::NOT_FOUND;
|
||||
if noisy {
|
||||
tracing::debug!(status = status.as_u16(), code, detail = ?message, "request rejected");
|
||||
} else {
|
||||
tracing::warn!(status = status.as_u16(), code, detail = ?message, "request rejected");
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"error": code,
|
||||
"message": message,
|
||||
@@ -93,6 +151,121 @@ impl From<anyhow::Error> for AppError {
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
Self::Internal(err.into())
|
||||
match err {
|
||||
// Pool saturation is load, not a bug. Reporting it as a 500 was actively harmful:
|
||||
// the frontend's upload-queue classifier treats 5xx as transient and retries, so
|
||||
// the retries piled straight back into the saturated pool with no Retry-After to
|
||||
// pace them. A 503 says the same thing honestly and carries the backoff.
|
||||
//
|
||||
// `PoolClosed` stays `Internal` — it only happens during shutdown, where a 503
|
||||
// would invite a retry against a server that is going away.
|
||||
sqlx::Error::PoolTimedOut => {
|
||||
tracing::warn!("database pool exhausted; shedding a request with 503");
|
||||
Self::ServiceUnavailable(
|
||||
"Server ist gerade ausgelastet. Bitte versuche es in ein paar Sekunden erneut."
|
||||
.into(),
|
||||
Some(POOL_TIMEOUT_RETRY_AFTER_SECS),
|
||||
)
|
||||
}
|
||||
other => Self::Internal(other.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry-After for a shed request. Short: pool saturation clears in seconds once the queue
|
||||
/// drains, and a long value would make a brief spike feel like an outage.
|
||||
const POOL_TIMEOUT_RETRY_AFTER_SECS: u64 = 3;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `into_response` extracts `retry_after_secs` by MATCHING ON VARIANTS, so unlike
|
||||
/// `message()` a missing arm is not a compile error — it silently drops the header. Pin the
|
||||
/// behaviour for both retry-carrying variants.
|
||||
#[test]
|
||||
fn both_retry_carrying_variants_emit_retry_after() {
|
||||
for err in [
|
||||
AppError::TooManyRequests("slow down".into(), Some(42)),
|
||||
AppError::ServiceUnavailable("busy".into(), Some(3)),
|
||||
] {
|
||||
let expected = match &err {
|
||||
AppError::TooManyRequests(_, Some(s)) | AppError::ServiceUnavailable(_, Some(s)) => {
|
||||
s.to_string()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(axum::http::header::RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some(expected.as_str()),
|
||||
"a shed/throttled client must be told when to come back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 4xx must be logged and 5xx must not be logged HERE — `Internal` logs its source chain in
|
||||
/// `message()` and the pool-exhaustion 503 logs at construction, so a second line in
|
||||
/// `into_response` would double every server-side failure in the post-event logs.
|
||||
///
|
||||
/// The guard is `status.is_client_error()`, so this pins the classification rather than the
|
||||
/// logging itself (which needs a subscriber to observe).
|
||||
#[test]
|
||||
fn only_client_errors_are_in_the_logged_band() {
|
||||
for err in [
|
||||
AppError::BadRequest("x".into()),
|
||||
AppError::Unauthorized("x".into()),
|
||||
AppError::Forbidden("x".into()),
|
||||
AppError::UploadsLocked("x".into()),
|
||||
AppError::NotFound("x".into()),
|
||||
AppError::Conflict("x".into()),
|
||||
AppError::TooManyRequests("x".into(), Some(1)),
|
||||
AppError::QuotaExceeded("x".into()),
|
||||
] {
|
||||
let (status, _) = err.status_and_code();
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"{status} should be in the 4xx band this logs"
|
||||
);
|
||||
}
|
||||
|
||||
for err in [
|
||||
AppError::ServiceUnavailable("x".into(), Some(3)),
|
||||
AppError::Internal(anyhow::anyhow!("boom")),
|
||||
] {
|
||||
let (status, _) = err.status_and_code();
|
||||
assert!(
|
||||
!status.is_client_error(),
|
||||
"{status} logs elsewhere; logging it here would double it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pool saturation is load, not a bug. A 500 makes the frontend's retry classifier pile
|
||||
/// straight back into the saturated pool with no backoff to pace it.
|
||||
#[test]
|
||||
fn pool_exhaustion_sheds_with_503_but_shutdown_does_not() {
|
||||
let shed: AppError = sqlx::Error::PoolTimedOut.into();
|
||||
assert_eq!(
|
||||
shed.status_and_code(),
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable")
|
||||
);
|
||||
|
||||
// PoolClosed only happens during shutdown; a 503 there would invite a retry against a
|
||||
// server that is going away.
|
||||
let closing: AppError = sqlx::Error::PoolClosed.into();
|
||||
assert_eq!(
|
||||
closing.status_and_code(),
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
);
|
||||
|
||||
// Everything else must keep its existing mapping.
|
||||
let missing: AppError = sqlx::Error::RowNotFound.into();
|
||||
assert_eq!(
|
||||
missing.status_and_code(),
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use uuid::Uuid;
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
use crate::services::config;
|
||||
use crate::services::sse_tickets::TicketKind;
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
@@ -132,6 +133,16 @@ pub async fn patch_config(
|
||||
("social_rate_per_min", true, 1.0, 100_000.0),
|
||||
("quota_tolerance", false, 0.0, 1.0),
|
||||
("estimated_guest_count", true, 1.0, 1_000_000.0),
|
||||
// The three limiters migration 025 introduced. All are READ at runtime
|
||||
// (`upload.rs` for the edit limiter, `auth/handlers.rs` for the other two) and 025
|
||||
// INSERTs all of them into `config`, so `GET /admin/config` listed them while
|
||||
// `PATCH /admin/config` answered "Unbekannter Konfigurationsschlüssel" — the same
|
||||
// dead-key defect the comment under BOOL_KEYS says was fixed for the two login
|
||||
// toggles. These are precisely the knobs an operator reaches for while abuse is
|
||||
// happening, which is the one moment a restart to change them is unaffordable.
|
||||
("upload_edit_rate_per_min", true, 1.0, 100_000.0),
|
||||
("recover_name_rate_per_15min", true, 1.0, 100_000.0),
|
||||
("pin_reset_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||
];
|
||||
const BOOL_KEYS: &[&str] = &[
|
||||
"rate_limits_enabled",
|
||||
@@ -145,6 +156,9 @@ pub async fn patch_config(
|
||||
"admin_login_rate_enabled",
|
||||
"recover_rate_enabled",
|
||||
"social_rate_enabled",
|
||||
// Read by `upload::edit_upload`, inserted by migration 025, and until now unreachable
|
||||
// from this endpoint — see the note in NUMERIC_SPECS.
|
||||
"upload_edit_rate_enabled",
|
||||
"quota_enabled",
|
||||
"storage_quota_enabled",
|
||||
"upload_count_quota_enabled",
|
||||
@@ -197,6 +211,23 @@ pub async fn patch_config(
|
||||
"Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}–{max})."
|
||||
)));
|
||||
}
|
||||
// Zero is in range and catastrophic. `quota_tolerance` is the multiplier in
|
||||
// `free_disk * tolerance / active_uploaders`, so 0 makes every per-user limit 0 and
|
||||
// refuses EVERY upload — mid-event, with "Du hast dein Upload-Limit für dieses Event
|
||||
// erreicht", an error naming the wrong cause entirely. `storage_quota_enabled` is the
|
||||
// intended off-switch.
|
||||
//
|
||||
// Rejecting the value rather than raising the floor: very small tolerances are
|
||||
// legitimate (they are how a large disk is throttled down to a sensible per-guest
|
||||
// ceiling, and how the e2e quota tests steer it — around 1e-5 on a 174 GB volume), so
|
||||
// a floor of, say, 0.01 would forbid real configurations to prevent one typo.
|
||||
if key_str == "quota_tolerance" && n == 0.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"quota_tolerance = 0 würde jeden Upload blockieren. Zum Abschalten der \
|
||||
Speicher-Quote stattdessen „Speicher-Quote aktiv“ ausschalten."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
} else if BOOL_KEYS.contains(&key_str) {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
|
||||
@@ -318,16 +349,84 @@ pub struct DownloadQuery {
|
||||
/// 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.
|
||||
#[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.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn export_ticket(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
|
||||
auth: crate::auth::middleware::AuthUser,
|
||||
) -> Json<serde_json::Value> {
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
|
||||
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
|
||||
// released — Spec design choice"). The export is read-only, so it stays available
|
||||
// to them, consistent with the read-only-ban model.
|
||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
||||
Json(serde_json::json!({ "ticket": ticket }))
|
||||
|
||||
// The rate limit is enforced HERE rather than on the download itself, and that placement is
|
||||
// the whole point: the download is an iframe navigation, so its response is invisible to the
|
||||
// page. Limiting it there meant a guest over the limit tapped "Herunterladen", the ticket
|
||||
// POST returned 200, the iframe silently received a 429, and absolutely nothing happened —
|
||||
// forever, with no explanation, on the one screen that is the emotional payoff of the app.
|
||||
// Minting is a normal `fetch`, so a 429 here reaches the user as a German message.
|
||||
//
|
||||
// Moving it does not weaken the limit: 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.
|
||||
// Confirm the archive actually EXISTS before spending anything on it.
|
||||
//
|
||||
// `export_status` — which is what enables the Download button — reports `done` from
|
||||
// `export_job`, while the download resolves through `export_current.file_path` plus a
|
||||
// `Path::exists()`. Those are different sources of truth and can legitimately disagree: a
|
||||
// row can say done while the file is gone, or an epoch bump can retire it between the page
|
||||
// rendering and the guest tapping. When they disagreed the guest got the worst possible
|
||||
// shape of failure — a green "Download gestartet" toast, a consumed single-use ticket, one
|
||||
// of only three daily slots spent, and nothing in their Downloads folder, repeatable until
|
||||
// the day's allowance was gone.
|
||||
//
|
||||
// Checking here, before `enforce_export_rate`, turns that into an honest error on a plain
|
||||
// `fetch` that the existing `toastError` path already renders. This is NOT the HEAD probe
|
||||
// ruled out elsewhere: it reads the same indexed row the download will read and touches no
|
||||
// ticket, so it cannot consume anything.
|
||||
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 msg = if export_type == "zip" {
|
||||
"Der ZIP-Export ist noch nicht verfügbar."
|
||||
} else {
|
||||
"Der HTML-Export ist noch nicht verfügbar."
|
||||
};
|
||||
resolve_export_file(&state, export_type, msg).await?;
|
||||
}
|
||||
|
||||
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.
|
||||
let ticket = state
|
||||
.sse_tickets
|
||||
.issue(auth.token_hash, TicketKind::Download)
|
||||
.ok_or_else(|| {
|
||||
AppError::ServiceUnavailable(
|
||||
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
|
||||
Some(30),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!({ "ticket": ticket })))
|
||||
}
|
||||
|
||||
/// Validate a download ticket (single-use) and confirm its session still exists.
|
||||
@@ -336,7 +435,7 @@ pub async fn export_ticket(
|
||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
|
||||
let token_hash = state
|
||||
.sse_tickets
|
||||
.consume(ticket)
|
||||
.consume(ticket, TicketKind::Download)
|
||||
.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
|
||||
@@ -349,8 +448,9 @@ pub async fn download_zip(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, user_id).await?;
|
||||
// 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?;
|
||||
|
||||
let path =
|
||||
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||
@@ -402,8 +502,8 @@ pub async fn download_html(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, user_id).await?;
|
||||
// See `download_zip`: the limit is charged at ticket mint, where the client can see it.
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
|
||||
let path =
|
||||
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
|
||||
@@ -519,8 +619,13 @@ async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppE
|
||||
limit,
|
||||
Duration::from_secs(86400),
|
||||
) {
|
||||
// Names the real window. The generic "warte kurz" wording this used to share with the
|
||||
// per-minute limiters is actively wrong here — the bucket is a DAY, so a guest told to
|
||||
// wait a moment would keep tapping a button that cannot work again until tomorrow.
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
"Du hast das Tageslimit für Downloads erreicht. Versuch es später noch einmal — \
|
||||
deine Galerie bleibt gespeichert."
|
||||
.into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,43 @@ use crate::state::AppState;
|
||||
pub struct FeedQuery {
|
||||
pub cursor: Option<Uuid>,
|
||||
pub limit: Option<i64>,
|
||||
/// Single tag (list view). Kept alongside `hashtags` so existing callers keep working.
|
||||
pub hashtag: Option<String>,
|
||||
/// Comma-separated tags, combined with **OR** — the grid's chip semantics
|
||||
/// (USER_JOURNEYS §8). Filtering moved server-side because the client could only ever
|
||||
/// filter the pages it had already loaded: with page 1 = 20 items out of a 1000-photo
|
||||
/// event, selecting a tag showed a handful of tiles and looked complete. Matching is
|
||||
/// now the exact `hashtag` row in both views, so the grid and the list can no longer
|
||||
/// disagree about which photos carry a tag (the client matched a caption SUBSTRING, so
|
||||
/// `#tanz` also matched `#tanzflaeche`).
|
||||
pub hashtags: Option<String>,
|
||||
/// Exact uploader display name, combined with the tag group using **AND**.
|
||||
pub uploader: Option<String>,
|
||||
}
|
||||
|
||||
/// Merge the single-tag and CSV tag params into one normalised, de-duplicated list.
|
||||
///
|
||||
/// Normalisation mirrors `Hashtag::upsert` exactly (trim, drop a leading `#`, lowercase), so
|
||||
/// a chip built from a display string like `#Tanz` matches the stored `tanz` row. Returns
|
||||
/// `None` when no usable tag was supplied, which makes the SQL predicate a no-op — an empty
|
||||
/// list must mean "no tag filter", never "match nothing".
|
||||
fn normalize_tags(single: Option<&str>, csv: Option<&str>) -> Option<Vec<String>> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |raw: &str| {
|
||||
let t = raw.trim().trim_start_matches('#').to_lowercase();
|
||||
if !t.is_empty() && !out.contains(&t) {
|
||||
out.push(t);
|
||||
}
|
||||
};
|
||||
if let Some(s) = single {
|
||||
push(s);
|
||||
}
|
||||
if let Some(s) = csv {
|
||||
for part in s.split(',') {
|
||||
push(part);
|
||||
}
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -80,7 +116,9 @@ pub async fn feed(
|
||||
}
|
||||
}
|
||||
|
||||
let limit = q.limit.unwrap_or(20).min(100);
|
||||
// Clamped at BOTH ends: only the upper bound was enforced, so `?limit=-5` reached Postgres
|
||||
// as `LIMIT -4` and answered a hand-written URL with a 500.
|
||||
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
// Resolve the cursor to a (created_at, id) position. The pair is compared as a
|
||||
// tuple so ties on created_at break on id — keyset pagination on created_at
|
||||
@@ -93,44 +131,42 @@ pub async fn feed(
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
let rows = if let Some(hashtag) = &q.hashtag {
|
||||
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
|
||||
sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
|
||||
v.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
|
||||
v.created_at
|
||||
FROM v_feed v
|
||||
JOIN upload_hashtag uh ON uh.upload_id = v.id
|
||||
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
|
||||
WHERE v.event_id = $2
|
||||
AND ($3::timestamptz IS NULL OR (v.created_at, v.id) < ($3, $4))
|
||||
ORDER BY v.created_at DESC, v.id DESC
|
||||
LIMIT $5",
|
||||
)
|
||||
.bind(&tag)
|
||||
.bind(auth.event_id)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(limit + 1)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
|
||||
display_path, mime_type, caption, like_count, comment_count, created_at
|
||||
FROM v_feed
|
||||
WHERE event_id = $1
|
||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(limit + 1)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
};
|
||||
// Tags from either param, normalised the same way `Hashtag::upsert` stores them
|
||||
// (trimmed, leading `#` dropped, lowercased) so the comparison is exact.
|
||||
let tags = normalize_tags(q.hashtag.as_deref(), q.hashtags.as_deref());
|
||||
let uploader = q
|
||||
.uploader
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// ONE statement for every combination, rather than a branch per filter. `EXISTS` with
|
||||
// `= ANY($4)` gives OR across the tag group without the row multiplication a JOIN would
|
||||
// cause when a photo carries two selected tags; the uploader predicate ANDs on top. Both
|
||||
// are no-ops when NULL, so the unfiltered feed takes the same path.
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
|
||||
v.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
|
||||
v.created_at
|
||||
FROM v_feed v
|
||||
WHERE v.event_id = $1
|
||||
AND ($2::timestamptz IS NULL OR (v.created_at, v.id) < ($2, $3))
|
||||
AND ($4::text[] IS NULL OR EXISTS (
|
||||
SELECT 1 FROM upload_hashtag uh
|
||||
JOIN hashtag h ON h.id = uh.hashtag_id
|
||||
WHERE uh.upload_id = v.id AND h.tag = ANY($4)))
|
||||
AND ($5::text IS NULL OR v.uploader_name = $5)
|
||||
ORDER BY v.created_at DESC, v.id DESC
|
||||
LIMIT $6",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(tags.as_deref())
|
||||
.bind(uploader)
|
||||
.bind(limit + 1)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let has_more = rows.len() as i64 > limit;
|
||||
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
|
||||
@@ -357,6 +393,32 @@ pub async fn hashtags(
|
||||
))
|
||||
}
|
||||
|
||||
/// Every uploader who has at least one visible upload, for the grid's "Nutzer suchen" picker.
|
||||
///
|
||||
/// The picker used to derive names from the uploads currently in memory — page 1, 20 items —
|
||||
/// so typing a guest's name found nothing whenever their photos happened to sit below the
|
||||
/// fold, which reads as "search is broken". This is the authoritative list.
|
||||
///
|
||||
/// Reads `v_feed`, so it inherits exactly the feed's visibility rules: soft-deleted uploads,
|
||||
/// banned uploaders and hidden uploaders are all excluded, and a guest who has not uploaded
|
||||
/// anything never appears. Uncapped on purpose — one short string per uploader, bounded by
|
||||
/// the guest count, and truncating it would reintroduce the very bug this replaces.
|
||||
/// Deliberately NOT the host-only `/host/users` route: that one lists every joined guest and
|
||||
/// exposes moderation state.
|
||||
pub async fn uploaders(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT uploader_name FROM v_feed WHERE event_id = $1 ORDER BY uploader_name",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(rows.into_iter().map(|(name,)| name).collect()))
|
||||
}
|
||||
|
||||
/// Resolve a cursor id to its `(created_at, id)` position. Both are needed:
|
||||
/// `created_at` alone isn't unique, so pagination must break ties on `id` to
|
||||
/// avoid silently dropping rows that share a timestamp across a page boundary.
|
||||
@@ -388,3 +450,41 @@ async fn get_liked_set(
|
||||
|
||||
rows.into_iter().map(|r| r.0).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_tags;
|
||||
|
||||
/// The chips carry display strings (`#Tanz`), the `hashtag` table stores `tanz`. If these
|
||||
/// two drift the filter silently returns nothing, which is indistinguishable from "no
|
||||
/// photos have this tag" — so pin the normalisation to `Hashtag::upsert`'s rule.
|
||||
#[test]
|
||||
fn tags_are_normalised_like_upsert_stores_them() {
|
||||
assert_eq!(
|
||||
normalize_tags(Some("#Tanz"), None),
|
||||
Some(vec!["tanz".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_tags(None, Some(" #Buffet , reden ")),
|
||||
Some(vec!["buffet".to_string(), "reden".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty list must mean "no filter", never "match nothing" — returning `Some(vec![])`
|
||||
/// would make `= ANY('{}')` false for every row and blank the feed.
|
||||
#[test]
|
||||
fn blank_input_disables_the_filter() {
|
||||
assert_eq!(normalize_tags(None, None), None);
|
||||
assert_eq!(normalize_tags(Some(" "), Some(" , ,#")), None);
|
||||
}
|
||||
|
||||
/// Both params feed one list, de-duplicated: the list view sends `hashtag`, the grid sends
|
||||
/// `hashtags`, and carrying a filter across views can legitimately set both to the same tag.
|
||||
#[test]
|
||||
fn single_and_csv_merge_without_duplicates() {
|
||||
assert_eq!(
|
||||
normalize_tags(Some("tanz"), Some("tanz,buffet")),
|
||||
Some(vec!["tanz".to_string(), "buffet".to_string()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,6 @@ pub struct EventStatus {
|
||||
pub disk_low: bool,
|
||||
}
|
||||
|
||||
/// Absolute floor below which free space is worth surfacing regardless of gallery size — the
|
||||
/// threshold the README has carried on the roadmap since v1.
|
||||
const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
||||
|
||||
/// Is free space low enough that the host needs to know?
|
||||
///
|
||||
@@ -59,8 +56,25 @@ const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
||||
/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they
|
||||
/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end
|
||||
/// 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`;
|
||||
/// warning at `free < keepsake_required` alone meant the two differed by the whole reserve, so
|
||||
/// the wall was always hit FIRST. Every guest would be blocked from uploading while this
|
||||
/// dashboard showed a comfortable disk and no banner at all — on the shipped 40 GB box, uploads
|
||||
/// stopping with ~27 GB free and nothing on screen to explain it, with no operator present.
|
||||
///
|
||||
/// The 25% margin makes it a warning rather than an obituary: the host sees it while there is
|
||||
/// still room to act (delete a few large videos, which refunds immediately and reopens the gate).
|
||||
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
||||
free < LOW_DISK_FLOOR_BYTES || free < keepsake_required
|
||||
let gate_closes_at = keepsake_required.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
|
||||
let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4);
|
||||
// No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here,
|
||||
// and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at
|
||||
// least 1.25x it (12.5 GB) — always above the 10 GB floor. Two tests were named after that
|
||||
// clause and neither could fail if it were deleted. Keeping dead code that tests claim to
|
||||
// cover is worse than not having it.
|
||||
free < warn_at
|
||||
}
|
||||
|
||||
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
||||
@@ -812,23 +826,69 @@ pub async fn release_gallery(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
|
||||
use super::disk_is_low;
|
||||
use crate::handlers::upload::DISK_RESERVE_BYTES;
|
||||
use crate::services::export::required_free_bytes;
|
||||
|
||||
const GB: u64 = 1_000_000_000;
|
||||
|
||||
#[test]
|
||||
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
|
||||
assert!(!disk_is_low(40 * GB, 25 * GB));
|
||||
// Room for the keepsake AND the reserve the upload gate holds back, with margin.
|
||||
assert!(!disk_is_low(60 * GB, 25 * GB));
|
||||
}
|
||||
|
||||
/// Renamed from `the_absolute_floor_fires_...`: there is no separate floor clause any more
|
||||
/// (see `disk_is_low`). What still has to hold is the behaviour the floor was there FOR — a
|
||||
/// nearly-empty disk is low even when the gallery is small enough that the keepsake term
|
||||
/// alone would clear it, because all three volumes share one filesystem and Postgres needs
|
||||
/// room to write.
|
||||
#[test]
|
||||
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
|
||||
fn a_nearly_empty_disk_is_low_even_when_the_gallery_is_tiny() {
|
||||
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
|
||||
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
|
||||
// the keepsake test comfortably; the floor is what catches this.
|
||||
assert!(disk_is_low(5 * GB, GB));
|
||||
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
|
||||
assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
|
||||
assert!(disk_is_low(9 * GB, 0));
|
||||
assert!(!disk_is_low(20 * GB, 0), "a roomy empty disk is not low");
|
||||
}
|
||||
|
||||
/// THE PROPERTY THIS EXISTS FOR: the host must be warned BEFORE guests are blocked.
|
||||
///
|
||||
/// `handlers::upload` refuses at `free < keepsake_required + DISK_RESERVE_BYTES`. If the
|
||||
/// banner fires only at or below that, the host's first signal is 100 guests being unable
|
||||
/// to upload while the dashboard shows a comfortable disk — with nobody on site to ask.
|
||||
#[test]
|
||||
fn the_banner_always_fires_before_the_upload_gate_closes() {
|
||||
// Asserting `disk_is_low(gate_closes_at, required)` is what this used to do, and it was a
|
||||
// tautology: `disk_is_low` recomputes the same `gate_closes_at` internally and compares
|
||||
// against `gate + gate/4`, so the assertion reduced to `G < G + G/4` — true for every G,
|
||||
// for any margin, even a margin of zero. It could not detect the banner being moved to
|
||||
// exactly the gate, which is the regression it is named for.
|
||||
//
|
||||
// So pin the GAP instead: find the free-space level at which the banner starts, and
|
||||
// require it to be strictly above the level at which the gate closes, by a usable amount.
|
||||
for media_gb in [0u64, 1, 4, 8, 16, 32] {
|
||||
let required = required_free_bytes(media_gb * GB, 2);
|
||||
let gate_closes_at = required + DISK_RESERVE_BYTES as u64;
|
||||
|
||||
// Just above the gate: guests can still upload, and the host must already be warned.
|
||||
assert!(
|
||||
disk_is_low(gate_closes_at + 1, required),
|
||||
"at media={media_gb}GB the banner is not yet showing while the gate still allows uploads"
|
||||
);
|
||||
|
||||
// The warning must lead by a margin the host can act inside, not by one byte.
|
||||
let mut warn_starts_at = gate_closes_at;
|
||||
while disk_is_low(warn_starts_at, required) {
|
||||
warn_starts_at += GB / 10;
|
||||
}
|
||||
assert!(
|
||||
warn_starts_at >= gate_closes_at + gate_closes_at / 5,
|
||||
"at media={media_gb}GB the banner leads the gate by only {} bytes",
|
||||
warn_starts_at - gate_closes_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -841,13 +901,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_keepsake_trigger_is_exact_at_the_boundary() {
|
||||
assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough");
|
||||
assert!(disk_is_low(66 * GB - 1, 66 * GB));
|
||||
// The boundary is the UPLOAD GATE's threshold plus a 25% margin, not the bare keepsake
|
||||
// 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 warn_at = gate + gate / 4;
|
||||
assert!(!disk_is_low(warn_at, required), "exactly enough is enough");
|
||||
assert!(disk_is_low(warn_at - 1, required));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
|
||||
assert!(!disk_is_low(11 * GB, 0));
|
||||
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));
|
||||
assert!(disk_is_low(9 * GB, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ pub struct MeContextDto {
|
||||
/// The gallery has been released and the export snapshotted — uploads are permanently
|
||||
/// closed for this run (release ⇒ lock, and reopening regenerates).
|
||||
pub gallery_released: bool,
|
||||
/// This guest is banned: a deliberately READ-ONLY ban (see `handlers/host.rs`) — they keep
|
||||
/// the feed and the keepsake, but every write is refused.
|
||||
///
|
||||
/// Exposed so the UI can SAY so. Without it the client had no idea, so the upload button,
|
||||
/// the like button and "Löschen" all rendered enabled and returned 403 "Du bist gesperrt."
|
||||
/// on every tap — a guest tapping upload repeatedly with nobody to ask. The lock case
|
||||
/// (`uploads_locked`) has always been surfaced for exactly this reason; a ban was not.
|
||||
pub is_banned: bool,
|
||||
}
|
||||
|
||||
pub async fn get_context(
|
||||
@@ -110,5 +118,6 @@ pub async fn get_context(
|
||||
storage_quota_enabled,
|
||||
uploads_locked,
|
||||
gallery_released,
|
||||
is_banned: user.is_banned,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -195,7 +195,14 @@ pub async fn add_comment(
|
||||
|
||||
// Insert the comment and link its hashtags atomically, so a crash mid-loop
|
||||
// can't leave a committed comment with only some of its tags indexed.
|
||||
let tags = hashtag::extract_hashtags(text);
|
||||
let mut tags = hashtag::extract_hashtags(text);
|
||||
// Deterministic lock order, matching the upload path. `Hashtag::upsert` takes row locks,
|
||||
// so two transactions touching the same two tags in OPPOSITE order deadlock — Postgres
|
||||
// aborts one after ~1s and that guest's comment 500s. `extract_hashtags` returns them in
|
||||
// text order, which is exactly the unordered case. Sort on the NORMALISED form, because
|
||||
// that is the key `upsert` locks on.
|
||||
tags.sort_by_key(|t| t.trim().trim_start_matches('#').to_lowercase());
|
||||
tags.dedup_by_key(|t| t.trim().trim_start_matches('#').to_lowercase());
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
|
||||
for tag in &tags {
|
||||
|
||||
@@ -13,6 +13,7 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::models::session::Session;
|
||||
use crate::services::sse_tickets::TicketKind;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -38,7 +39,26 @@ pub async fn issue_ticket(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<StreamTicketResponse>, AppError> {
|
||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
||||
// The endpoint had no rate limit at all. Authentication is not a bound here: one valid
|
||||
// session could loop it freely. 60/min is far above a real client (one ticket per SSE
|
||||
// (re)connect, and reconnects are backed off) while capping a loop.
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("sse_ticket:{}", auth.user_id),
|
||||
60,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Verbindungsversuche. Bitte warte kurz.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
|
||||
let ticket = state.sse_tickets.issue(auth.token_hash, TicketKind::Sse).ok_or_else(|| {
|
||||
AppError::ServiceUnavailable(
|
||||
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
|
||||
Some(30),
|
||||
)
|
||||
})?;
|
||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
@@ -56,7 +76,7 @@ pub async fn stream(
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
|
||||
let token_hash = state
|
||||
.sse_tickets
|
||||
.consume(&q.ticket)
|
||||
.consume(&q.ticket, TicketKind::Sse)
|
||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||
|
||||
// NOTE: this authenticates via ticket→session, not the `AuthUser` extractor. The
|
||||
|
||||
@@ -111,6 +111,12 @@ pub async fn truncate_all(
|
||||
// steers the per-user limit off `free_disk_bytes`), i.e. two holes were masking each other.
|
||||
state.disk_cache.invalidate();
|
||||
|
||||
// `media_total` caches SUM(user.total_upload_bytes) for the upload gate's keepsake-headroom
|
||||
// check. TRUNCATE has just zeroed every one of those rows, so a surviving reading would make
|
||||
// the next test's first upload measure its headroom against the previous test's gallery —
|
||||
// and that gate REFUSES uploads, so the failure would look like a spurious quota rejection.
|
||||
state.media_total.invalidate();
|
||||
|
||||
// `sse_tickets` maps a ticket to a session token hash. TRUNCATE deletes the sessions, so every
|
||||
// surviving ticket is a dangling reference to a user that no longer exists.
|
||||
state.sse_tickets.clear();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,15 @@ async fn main() -> Result<()> {
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
// `info`, not `debug`. A stock deploy sets RUST_LOG nowhere (it is absent from
|
||||
// .env.example and was absent from docker-compose.yml), so this fallback IS the
|
||||
// production level — and at `debug` the TraceLayer below emits a line per request
|
||||
// AND per response, into a log file that had no rotation. `tower_http=warn`
|
||||
// rather than `info` states the intent: those spans are diagnostics, not an
|
||||
// access log, and a future `DefaultOnResponse::new().level(Level::INFO)` should
|
||||
// not silently re-enable them.
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
|
||||
.unwrap_or_else(|_| "eventsnap_backend=info,tower_http=warn".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
@@ -51,6 +58,12 @@ async fn main() -> Result<()> {
|
||||
// originals are never touched, so a failure just retries on the next start.
|
||||
state.compression.backfill_stale_derivatives().await;
|
||||
|
||||
// Re-extract poster frames for videos a restart interrupted. `startup_recovery` above
|
||||
// marks their compression `failed` but nothing re-enqueued them, so `thumbnail_path`
|
||||
// stayed NULL for the rest of the event. Shares the attempt budget with the image
|
||||
// backfill, so a clip that genuinely yields no frame stops being retried.
|
||||
state.compression.backfill_video_posters().await;
|
||||
|
||||
// Re-spawn exports for events that were released but whose keepsake never finished
|
||||
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
|
||||
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
|
||||
@@ -129,6 +142,7 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/feed", get(handlers::feed::feed))
|
||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
|
||||
.route("/api/v1/uploaders", get(handlers::feed::uploaders))
|
||||
// Social
|
||||
.route(
|
||||
"/api/v1/upload/{id}/like",
|
||||
@@ -248,7 +262,24 @@ async fn main() -> Result<()> {
|
||||
// four subtrees. Deleting the route removes the vector outright rather than racing the
|
||||
// decoder; `/media/**` now 404s regardless of encoding.
|
||||
let router = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
// ONE probe, and it touches the database. The merge of the unattended-blockers work
|
||||
// brought a competing design — a dependency-free `/health` for the compose gate plus
|
||||
// a DB-backed `/health/ready` for an external monitor. That split is defensible, and
|
||||
// it was rejected deliberately:
|
||||
//
|
||||
// * `/health` returning a constant "ok" is the exact defect faea555 fixed and
|
||||
// verified live (stop Postgres → 503 → start → 200, with no app restart). Every
|
||||
// request path touches the database, so a constant probe reports healthy while
|
||||
// the app is useless — the disk-full endgame stayed green all the way down.
|
||||
// * The split's motive was that Caddy's `depends_on: app: service_healthy` would
|
||||
// be blocked by a Postgres hiccup at boot. But `app` itself already gates on
|
||||
// `db: service_healthy`, so the DB is up before this probe ever runs, and the
|
||||
// healthcheck carries a 20s start_period plus 5 retries on top.
|
||||
// * The two handlers were the same `SELECT 1` with the same 2s timeout under two
|
||||
// names, so keeping both bought nothing.
|
||||
//
|
||||
// The external uptime monitor the runbook now calls for points at this route.
|
||||
.route("/health", get(health))
|
||||
.merge(api)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
@@ -268,6 +299,47 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long `/health` waits for the database before calling the app unhealthy. Deliberately
|
||||
/// short: the point is to answer "can this process actually serve a request right now", and a
|
||||
/// probe that blocks for the acquire timeout is itself a symptom.
|
||||
const HEALTH_DB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Readiness probe — the Docker healthcheck, Caddy's `depends_on` gate, and the runbook's
|
||||
/// event-day `curl` all hit this.
|
||||
///
|
||||
/// It used to return the literal string `"ok"` and touch nothing. Every request in the app needs
|
||||
/// the database, so that answered a question nobody asked: the container reported healthy while
|
||||
/// every real request 500'd, and with no operator watching during the event there was no signal
|
||||
/// at all. Note what this does NOT buy: Compose's `restart: unless-stopped` does not react to
|
||||
/// healthcheck state, so nothing restarts on a red probe — this is a diagnostic, and it is
|
||||
/// deliberately not wired to automatic recovery, because the pool already heals itself across a
|
||||
/// Postgres restart (sqlx revalidates on acquire) and an auto-restart would truncate every
|
||||
/// in-flight upload to "fix" an outage that was about to clear on its own.
|
||||
async fn health(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
use axum::http::StatusCode;
|
||||
match tokio::time::timeout(
|
||||
HEALTH_DB_TIMEOUT,
|
||||
sqlx::query("SELECT 1").execute(&state.pool),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => (StatusCode::OK, "ok"),
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = ?e, "health check: database query failed");
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "database unavailable")
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
|
||||
"health check: database did not respond"
|
||||
);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "database timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard cap on how long we wait for in-flight connections to drain after a shutdown
|
||||
/// signal. Uploads (streamed to disk) finish in well under this; the cap exists because
|
||||
/// long-lived SSE streams never end on their own and would otherwise keep the graceful
|
||||
|
||||
@@ -63,6 +63,33 @@ impl Hashtag {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The upload's current tags, lowercased and sorted — the comparable form.
|
||||
///
|
||||
/// Exists so `edit_upload` can tell a real hashtag change from a re-send of the same list.
|
||||
/// Without it, `PATCH {"hashtags": []}` in a loop retired the HTML keepsake on every request
|
||||
/// (readiness is derived from `event.export_epoch`), and every armed rebuild was superseded
|
||||
/// before the debounce let it start — so the viewer 404'd for the rest of the event at zero
|
||||
/// cost to the client. One indexed lookup on `upload_hashtag(upload_id)` is a fair price for
|
||||
/// closing that.
|
||||
pub async fn normalized_for_upload<'e, E>(
|
||||
executor: E,
|
||||
upload_id: Uuid,
|
||||
) -> Result<Vec<String>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT lower(h.tag) FROM hashtag h
|
||||
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
|
||||
WHERE uh.upload_id = $1
|
||||
ORDER BY lower(h.tag)",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(t,)| t).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
|
||||
|
||||
@@ -53,6 +53,9 @@ pub struct VisibleMedia {
|
||||
impl Upload {
|
||||
/// Takes any executor so the caller can run it inside a transaction (atomic
|
||||
/// quota + insert) or standalone against the pool.
|
||||
// Eight arguments, one per column the INSERT writes, with exactly one call site. A params
|
||||
// struct here would restate the column list a second time and buy nothing.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create<'e, E>(
|
||||
executor: E,
|
||||
event_id: Uuid,
|
||||
@@ -61,13 +64,23 @@ impl Upload {
|
||||
mime_type: &str,
|
||||
original_size_bytes: i64,
|
||||
caption: Option<&str>,
|
||||
) -> Result<Self, sqlx::Error>
|
||||
client_upload_id: Option<Uuid>,
|
||||
) -> Result<Option<Self>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
// `Ok(None)` means this exact `client_upload_id` is already stored — the caller's request
|
||||
// is a retry of one that already succeeded, and it must replay the original row rather
|
||||
// than create a second. Letting the unique index raise instead would work, but only after
|
||||
// the whole transaction had aborted, and it would arrive as an opaque database error the
|
||||
// caller would have to string-match to recognise.
|
||||
//
|
||||
// The conflict target repeats the index's `WHERE` clause because it is a partial index;
|
||||
// without it Postgres cannot prove which index to use and rejects the statement.
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"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
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(event_id)
|
||||
@@ -76,7 +89,29 @@ impl Upload {
|
||||
.bind(mime_type)
|
||||
.bind(original_size_bytes)
|
||||
.bind(caption)
|
||||
.fetch_one(executor)
|
||||
.bind(client_upload_id)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Look up a live upload by the idempotency key its client sent.
|
||||
///
|
||||
/// Scoped to the user as well as the key: the key alone is unique, but a lookup that ignored
|
||||
/// ownership would let one guest's retry return another guest's row if a key ever repeated.
|
||||
/// Soft-deleted rows are excluded on purpose — if the guest deleted the photo and their queue
|
||||
/// later retries, they should get a fresh upload rather than a resurrection of a deleted one.
|
||||
pub async fn find_by_client_upload_id(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
client_upload_id: Uuid,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM upload
|
||||
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(client_upload_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -150,10 +185,62 @@ impl Upload {
|
||||
|
||||
/// Stamp which revision of the derivative pipeline produced this row's preview/display,
|
||||
/// so the startup backfill can find rows generated by an older one exactly once.
|
||||
///
|
||||
/// Also clears the attempt counter: success is the only thing that resets it, and folding
|
||||
/// the reset in here means both the live path and the backfill get it with no extra call
|
||||
/// site to forget.
|
||||
pub async fn set_derivatives_rev(pool: &PgPool, id: Uuid, rev: i16) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE upload SET derivatives_rev = $2 WHERE id = $1")
|
||||
sqlx::query(
|
||||
"UPDATE upload
|
||||
SET derivatives_rev = $2, derivative_attempts = 0, derivative_last_error = NULL
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(rev)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record that derivative processing is ABOUT to be attempted, returning the new count.
|
||||
///
|
||||
/// WRITE-AHEAD ON PURPOSE. The failure this bounds is a cgroup SIGKILL: the process
|
||||
/// vanishes mid-work, so no `Err` is returned, no error handler runs and no `Drop` fires.
|
||||
/// A counter incremented after a failure would increment zero times per crash and the
|
||||
/// boot loop would be unchanged. Counting the ATTEMPT is the only thing that survives the
|
||||
/// process dying. The cost is that a genuinely transient failure also burns an attempt —
|
||||
/// acceptable, because the retry budget is per-boot-loop, not per-request, and success
|
||||
/// resets it to zero.
|
||||
/// `None` when the row no longer exists (hard-deleted, or an e2e TRUNCATE landed while the
|
||||
/// task waited on the semaphore) — the caller should abandon quietly rather than treat a
|
||||
/// missing row as a processing failure.
|
||||
pub async fn begin_derivative_attempt(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
) -> Result<Option<i16>, sqlx::Error> {
|
||||
sqlx::query_scalar(
|
||||
"UPDATE upload
|
||||
SET derivative_attempts = derivative_attempts + 1
|
||||
WHERE id = $1
|
||||
RETURNING derivative_attempts",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Store why the last derivative attempt failed. Diagnostics only — nothing branches on it.
|
||||
pub async fn record_derivative_failure(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
error: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
// Bounded: an anyhow chain can be long, and this is written on a failure path that may
|
||||
// repeat across every row of a bad batch.
|
||||
let truncated: String = error.chars().take(500).collect();
|
||||
sqlx::query("UPDATE upload SET derivative_last_error = $2 WHERE id = $1")
|
||||
.bind(id)
|
||||
.bind(rev)
|
||||
.bind(truncated)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -172,40 +259,8 @@ impl Upload {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
|
||||
/// Done in a single transaction so a crash between the two writes can't leave
|
||||
/// the quota counter pointing at bytes the user has already deleted (which would
|
||||
/// silently lock them out of future uploads).
|
||||
///
|
||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
||||
/// delete action double-decrementing the counter.
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some((user_id, bytes)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
||||
/// Soft-deletes an upload within its event and refunds the uploader's
|
||||
/// `total_upload_bytes`, in one transaction. Returns `false` if no row
|
||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||
/// can return a clean 404 instead of silently no-op'ing.
|
||||
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
|
||||
|
||||
@@ -60,6 +60,54 @@ impl User {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a user with an explicit role, in ONE statement.
|
||||
///
|
||||
/// `create` + a separate `UPDATE ... SET role` is not equivalent: a crash or a pool error
|
||||
/// between the two leaves a GUEST row holding a reserved name, which is exactly the
|
||||
/// poisoned state that bricked admin login — now self-inflicted, and invisible to a
|
||||
/// role-based lookup, so the next login would create yet another.
|
||||
pub async fn create_with_role(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
display_name: &str,
|
||||
pin_hash: &str,
|
||||
role: UserRole,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(display_name)
|
||||
.bind(pin_hash)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The event's admin, looked up BY ROLE.
|
||||
///
|
||||
/// The name is not the identity and never was. Looking the admin up by `display_name`
|
||||
/// meant any guest who joined as "Admin" first made the lookup miss, and the fallback
|
||||
/// `create` then violated the case-insensitive unique index from migration 007 — a
|
||||
/// permanent 500 on admin login, recoverable only by hand-editing the database.
|
||||
///
|
||||
/// `ORDER BY created_at` so a database that somehow acquired two admin rows resolves to a
|
||||
/// stable one rather than alternating between them.
|
||||
pub async fn find_admin_for_event(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM \"user\" WHERE event_id = $1 AND role = 'admin'
|
||||
ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>("SELECT * FROM \"user\" WHERE id = $1")
|
||||
.bind(id)
|
||||
@@ -96,14 +144,31 @@ impl User {
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// Window after which a failed-PIN streak is forgotten. Matches the lockout duration, so
|
||||
/// "wait out the cooldown" and "start clean" are the same interval to a guest.
|
||||
const PIN_ATTEMPT_DECAY_MINUTES: i64 = 15;
|
||||
|
||||
/// Record a wrong PIN and return the CURRENT streak length.
|
||||
///
|
||||
/// The counter decays: before this, it only ever cleared on a successful recovery or after
|
||||
/// a lockout expired, so ordinary typos accumulated across days and a guest could arrive at
|
||||
/// an event already most of the way to being locked out by mistakes made the night before.
|
||||
/// Decay is what makes the raised lock threshold safe rather than merely lenient.
|
||||
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
|
||||
let row: (i16,) = sqlx::query_as(
|
||||
"UPDATE \"user\"
|
||||
SET failed_pin_attempts = failed_pin_attempts + 1
|
||||
SET failed_pin_attempts = CASE
|
||||
WHEN last_failed_pin_at IS NULL
|
||||
OR last_failed_pin_at < NOW() - ($2 || ' minutes')::interval
|
||||
THEN 1
|
||||
ELSE failed_pin_attempts + 1
|
||||
END,
|
||||
last_failed_pin_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING failed_pin_attempts",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(Self::PIN_ATTEMPT_DECAY_MINUTES.to_string())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
@@ -124,7 +189,9 @@ impl User {
|
||||
|
||||
pub async fn reset_pin_attempts(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET failed_pin_attempts = 0, pin_locked_until = NULL WHERE id = $1",
|
||||
"UPDATE \"user\"
|
||||
SET failed_pin_attempts = 0, pin_locked_until = NULL, last_failed_pin_at = NULL
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
|
||||
@@ -58,6 +58,21 @@ impl CompressionWorker {
|
||||
/// next start. Rev 1 = EXIF orientation is applied.
|
||||
const DERIVATIVES_REV: i16 = 1;
|
||||
|
||||
/// How many times derivative generation may be ATTEMPTED for one upload before it is left
|
||||
/// alone. Counted write-ahead and reset on success — see `Upload::begin_derivative_attempt`.
|
||||
///
|
||||
/// This is what turns a fatal input from an outage into a blemish. The startup backfill
|
||||
/// runs unconditionally on every boot, so before this bound a row whose processing killed
|
||||
/// the process was re-selected and re-run forever, and `restart: unless-stopped` made that
|
||||
/// an infinite loop that also dropped every SSE stream and truncated every in-flight
|
||||
/// upload on each cycle. Three attempts absorbs genuinely transient infrastructure
|
||||
/// failures (an ENOSPC spike, a pool blip) without ever becoming unbounded.
|
||||
const MAX_DERIVATIVE_ATTEMPTS: i16 = 3;
|
||||
|
||||
/// Rows regenerated per boot. Bounds both the query and the amount of work a single start
|
||||
/// can queue; whatever is left is picked up on the next boot.
|
||||
const BACKFILL_BATCH: i64 = 200;
|
||||
|
||||
/// Spawn a background task to process an uploaded file.
|
||||
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
|
||||
let worker = self.clone();
|
||||
@@ -88,7 +103,8 @@ impl CompressionWorker {
|
||||
Ok(v) => break Ok(v),
|
||||
Err(e)
|
||||
if attempt < Self::MAX_PROCESS_ATTEMPTS
|
||||
&& !crate::services::imaging::is_permanent_image_error(&e) =>
|
||||
&& !crate::services::imaging::is_permanent_image_error(&e)
|
||||
&& !crate::services::imaging::is_storage_full_error(&e) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
error = ?e, %upload_id, attempt,
|
||||
@@ -113,39 +129,75 @@ impl CompressionWorker {
|
||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) if crate::services::imaging::is_storage_full_error(&e) => {
|
||||
// Out of disk. Keep the row AND the original — the opposite of the branch
|
||||
// below, and for the same reason it retains the file: nothing here is the
|
||||
// guest's fault and nothing about the photo is wrong.
|
||||
//
|
||||
// Soft-deleting on ENOSPC was strictly harmful. It refunded the quota while
|
||||
// keeping the bytes, so it freed nothing, removed the photo from the feed
|
||||
// seconds after a `201 Created`, and handed the guest the allowance to
|
||||
// upload it again into the same full disk. Leaving the row live costs
|
||||
// nothing instead: every client already falls back to the original when
|
||||
// `preview_url` and `thumbnail_url` are NULL, so the photo stays visible —
|
||||
// just uncompressed — and `backfill_stale_derivatives` regenerates the
|
||||
// derivatives on the next start, once there is room for them.
|
||||
tracing::error!(
|
||||
%upload_id,
|
||||
"compression failed: the media filesystem is out of space. The upload is \
|
||||
kept and served from its original; free disk space and restart to \
|
||||
regenerate derivatives: {e:#}"
|
||||
);
|
||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||
// Not an "error" event: nothing was lost and there is nothing for the guest
|
||||
// to act on. Clients treat this purely as "refetch me", which is what makes
|
||||
// the card appear with its original as the image source.
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-processed".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
||||
);
|
||||
// Refund + soft-delete (one tx, so v_feed excludes it) so a failed
|
||||
// transcode doesn't leave a permanently broken feed card or silently
|
||||
// charge the uploader's quota. Then tell the uploader (upload-error
|
||||
// toast) and evict the card everywhere (upload-deleted).
|
||||
// KEEP THE ROW. This used to soft-delete, which made a derivative failure
|
||||
// indistinguishable — to the guest — from their photo being deleted: they
|
||||
// got a `201 Created`, watched the card appear, and then watched it vanish.
|
||||
// The row left `v_feed`, `find_visible_media` and BOTH keepsake archives,
|
||||
// so the photo was gone from the product's core promise while its bytes sat
|
||||
// on disk for 14 days waiting for a `cleanup_deleted_media` that nothing
|
||||
// told anyone about. There is no host or admin screen listing compression
|
||||
// failures, so recovery meant hand-written SQL that also had to re-add the
|
||||
// refunded quota bytes. Against "0 lost uploads", that was silent per-photo
|
||||
// loss on any error the ENOSPC arm above doesn't catch — a HEIC that slipped
|
||||
// the allowlist, a truncated frame, an ffmpeg hiccup, a pool blip.
|
||||
//
|
||||
// The ORIGINAL IS DELIBERATELY KEPT. This path used to `remove_file` it
|
||||
// unconditionally, which meant any transient error — a disk-full blip
|
||||
// while saving a derivative, a pool hiccup, a panic in the image codec —
|
||||
// irreversibly destroyed the guest's only copy of a photo they can never
|
||||
// retake. The row is only soft-deleted, so keeping the bytes makes the
|
||||
// upload fully recoverable; the file is orphaned rather than lost, and
|
||||
// the path is logged so it can be found. `backfill_stale_derivatives`
|
||||
// already refuses to destroy data on error for exactly this reason.
|
||||
// This is exactly what the ENOSPC arm already does and documents as correct:
|
||||
// every client falls back to the original when `preview_url` and
|
||||
// `thumbnail_url` are NULL, so the photo stays visible and downloadable —
|
||||
// just uncompressed — and `backfill_stale_derivatives` retries it on the
|
||||
// next boot, now bounded by `derivative_attempts` so a poisoned row cannot
|
||||
// loop. The quota stays charged, which is correct: the bytes are still on
|
||||
// disk and still the guest's.
|
||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
|
||||
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
||||
}
|
||||
tracing::warn!(
|
||||
%upload_id,
|
||||
path = %worker.media_path.join(&original_path).display(),
|
||||
"original retained for recovery after compression failure"
|
||||
"derivatives failed; the upload is kept and served from its original"
|
||||
);
|
||||
// `upload-error` still fires so the uploader learns the photo will look
|
||||
// uncompressed. `upload-deleted` deliberately does NOT — nothing was
|
||||
// deleted, and evicting the card was the visible half of the data loss.
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-error".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
||||
.to_string(),
|
||||
});
|
||||
// Tell every client to refetch, so the card re-renders from the original
|
||||
// instead of sitting on a stale "processing" placeholder forever.
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-deleted".to_string(),
|
||||
event_type: "upload-processed".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
});
|
||||
}
|
||||
@@ -164,6 +216,21 @@ impl CompressionWorker {
|
||||
let original = self.media_path.join(original_path);
|
||||
|
||||
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? {
|
||||
Some(attempts) if attempts > Self::MAX_DERIVATIVE_ATTEMPTS => {
|
||||
anyhow::bail!(
|
||||
"derivative generation gave up after {} attempt(s)",
|
||||
attempts - 1
|
||||
);
|
||||
}
|
||||
Some(_) => {}
|
||||
// The row vanished while this task waited on the semaphore. Nothing to do, and
|
||||
// reporting a failure would broadcast into a stream that no longer has a card.
|
||||
None => return Ok(()),
|
||||
}
|
||||
let (preview_rel, display_rel) = self
|
||||
.generate_image_derivatives(upload_id, &original, mime_type)
|
||||
.await?;
|
||||
@@ -172,9 +239,48 @@ impl CompressionWorker {
|
||||
Upload::set_derivatives_rev(&self.pool, upload_id, Self::DERIVATIVES_REV).await?;
|
||||
tracing::info!("preview + display generated for upload {upload_id}");
|
||||
} else if mime_type.starts_with("video/") {
|
||||
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?;
|
||||
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
||||
// A missing poster must NOT fail the upload. `set_thumbnail_path` is only reached when
|
||||
// a file really exists, so `thumbnail_path` stays NULL otherwise — which every consumer
|
||||
// already handles (FeedListCard, VirtualFeed, LightboxModal are all null-safe).
|
||||
//
|
||||
// The `?` here used to hide the defect; making the check strict without also making
|
||||
// this non-fatal would have been far worse than the bug. Every clip of a second or less
|
||||
// would fail compression, exhaust its retries and be soft-deleted — a cosmetic defect
|
||||
// turned into data loss, on exactly the mis-tap/Live-Photo clips guests produce most.
|
||||
// Handling only the `Ok(None)` arm was not enough: the `?` on the call itself still
|
||||
// routed every OTHER poster failure into the give-up path. `extract_poster_frame`
|
||||
// returns `Err` when ffmpeg is missing from the image, when it hangs on a truncated
|
||||
// `.mov` and trips FFMPEG_TIMEOUT, or when `thumbnails/` can't be created — and
|
||||
// `set_thumbnail_path` returns `Err` on any DB blip. None of those say anything about
|
||||
// the video itself, yet each one destroyed it. Confirmed live: on a box with no ffmpeg
|
||||
// the spawn error propagated, exhausted all three attempts and soft-deleted the clip.
|
||||
//
|
||||
// Nothing about a video post depends on the poster — `get_original` serves the file
|
||||
// byte-for-byte and the tile falls back to the video element — so no failure in this
|
||||
// branch may fail the upload.
|
||||
match self.generate_video_thumbnail(upload_id, &original).await {
|
||||
Ok(Some(thumb_rel)) => {
|
||||
match Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await {
|
||||
Ok(()) => tracing::info!("thumbnail generated for upload {upload_id}"),
|
||||
Err(e) => tracing::warn!(
|
||||
error = ?e, %upload_id,
|
||||
"poster extracted but could not be recorded; the video keeps its own tile"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
%upload_id,
|
||||
"no poster frame could be extracted; the video keeps its own tile"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = ?e, %upload_id,
|
||||
"poster extraction failed; the video keeps its own tile"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
|
||||
@@ -188,6 +294,25 @@ impl CompressionWorker {
|
||||
/// Longest edge of the phone-feed "preview" (data-saver default).
|
||||
const PREVIEW_MAX_EDGE: u32 = 800;
|
||||
|
||||
/// Above this pixel count the PNG original is stored as uploaded, unoptimised.
|
||||
///
|
||||
/// oxipng's peak memory scales with PIXELS, not file size: it decodes the PNG itself and
|
||||
/// then evaluates row filters, each trial holding a full-size buffer. That is why a 2.82
|
||||
/// MiB file could measure 1250 MiB of peak RSS inside a 1 GiB container — smooth,
|
||||
/// synthetic content compresses to almost nothing on disk while still being 8000x8000.
|
||||
/// 8 MP covers every real phone photo; beyond it we decline the (lossless, cosmetic)
|
||||
/// saving rather than risk the OOM kill.
|
||||
const OXIPNG_MAX_PIXELS: u64 = 8_000_000;
|
||||
|
||||
|
||||
/// Wall-clock ceiling for one oxipng run.
|
||||
///
|
||||
/// Bounds TIME, NOT MEMORY — oxipng checks the deadline between trials, so a single trial
|
||||
/// still allocates in full. The pixel gate above and the sequential build (see
|
||||
/// `default-features = false` in Cargo.toml) are what bound memory. Do not treat this
|
||||
/// constant as the OOM fix.
|
||||
const OXIPNG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Decode the image ONCE and emit both derivatives — the 800px `preview` (phone feed)
|
||||
/// and the 2048px `display` (diashow). Returns `(preview_rel, display_rel)`.
|
||||
async fn generate_image_derivatives(
|
||||
@@ -206,53 +331,28 @@ impl CompressionWorker {
|
||||
let display_path = displays_dir.join(&filename);
|
||||
let original = original.to_path_buf();
|
||||
let mime_owned = mime_type.to_string();
|
||||
let preview_max = Self::PREVIEW_MAX_EDGE;
|
||||
let display_max = Self::DISPLAY_MAX_EDGE;
|
||||
|
||||
// Estimate the peak from the HEADER (no pixels decoded — the same kind of cheap probe
|
||||
// the upload handler already does via `exceeds_decode_budget`) and, if this job is a
|
||||
// giant, take the exclusive permit so it cannot overlap another giant. Held for the
|
||||
// whole blocking section, released on drop including on error.
|
||||
let estimate =
|
||||
crate::services::imaging::estimated_processing_peak_bytes(&original, Self::DISPLAY_MAX_EDGE);
|
||||
let _heavy_permit = match estimate {
|
||||
Some(bytes) if bytes > crate::services::imaging::HEAVY_IMAGE_BYTES => {
|
||||
tracing::debug!(
|
||||
%upload_id,
|
||||
estimated_mib = bytes / (1024 * 1024),
|
||||
"waiting for the heavy-image permit"
|
||||
);
|
||||
Some(crate::services::imaging::HEAVY_IMAGE_PERMITS.acquire().await)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Run blocking image operations in a spawn_blocking task
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||
// services::imaging for why neither may be skipped.
|
||||
let img = crate::services::imaging::decode_oriented(&original)?;
|
||||
|
||||
// Preview: max 800px, preserving aspect ratio (data-saver feed).
|
||||
img.resize(
|
||||
preview_max,
|
||||
preview_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
.save_with_format(&preview_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save preview")?;
|
||||
|
||||
// Display: max 2048px for the diashow. Only DOWNSCALE — never upscale a smaller
|
||||
// original (that adds bytes with no quality gain); re-encode it as JPEG as-is.
|
||||
let display = if img.width() > display_max || img.height() > display_max {
|
||||
img.resize(
|
||||
display_max,
|
||||
display_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
display
|
||||
.save_with_format(&display_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save display")?;
|
||||
|
||||
// If the original is PNG, try lossless compression in-place
|
||||
if mime_owned == "image/png" {
|
||||
let opts = oxipng::Options::from_preset(2);
|
||||
let _ = oxipng::optimize(
|
||||
&oxipng::InFile::Path(original),
|
||||
&oxipng::OutFile::Path {
|
||||
path: None,
|
||||
preserve_attrs: true,
|
||||
},
|
||||
&opts,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
tokio::task::spawn_blocking(move || {
|
||||
write_image_derivatives(upload_id, &original, &mime_owned, &preview_path, &display_path)
|
||||
})
|
||||
.await??;
|
||||
|
||||
@@ -273,17 +373,29 @@ impl CompressionWorker {
|
||||
///
|
||||
/// Unlike the failure path in `process`, a backfill error is logged and skipped — it must
|
||||
/// NEVER destroy or soft-delete an upload that already has a working preview.
|
||||
///
|
||||
/// Bounded in three ways, all of them load-bearing on a box that restarts itself:
|
||||
/// `derivative_attempts` stops a fatal row being replayed on every boot, `BACKFILL_BATCH`
|
||||
/// stops one start queueing unbounded work, and the whole thing runs as ONE task walking
|
||||
/// the rows sequentially rather than N tasks racing for the same semaphore.
|
||||
pub async fn backfill_stale_derivatives(&self) {
|
||||
// `original_path IS NOT NULL` was dead — the column is NOT NULL. What actually needs
|
||||
// excluding is the blanked path `cleanup_deleted_media` leaves behind.
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
|
||||
"SELECT id, original_path, mime_type FROM upload
|
||||
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||
AND original_path IS NOT NULL
|
||||
AND original_path <> ''
|
||||
AND derivative_attempts < $2
|
||||
AND (
|
||||
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||
OR derivatives_rev < $1
|
||||
)",
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(Self::DERIVATIVES_REV)
|
||||
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||
.bind(Self::BACKFILL_BATCH)
|
||||
.fetch_all(&self.pool)
|
||||
.await;
|
||||
let rows = match rows {
|
||||
@@ -293,14 +405,32 @@ impl CompressionWorker {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.report_exhausted_derivatives().await;
|
||||
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::info!("regenerating derivatives for {} upload(s)", rows.len());
|
||||
for (id, original_path, mime_type) in rows {
|
||||
let worker = self.clone();
|
||||
tokio::spawn(async move {
|
||||
|
||||
// ONE task for the whole batch. The previous shape spawned a task per row, so a large
|
||||
// backlog created thousands of live tasks that each held a pool handle and queued on
|
||||
// the same two semaphore permits, competing with live uploads for the entire boot.
|
||||
let worker = self.clone();
|
||||
tokio::spawn(async move {
|
||||
for (id, original_path, mime_type) in rows {
|
||||
let _permit = worker.semaphore.acquire().await;
|
||||
// Write-ahead, exactly as in the live path: if this row is the one that kills
|
||||
// the process, this increment is the only thing that outlives the SIGKILL.
|
||||
match Upload::begin_derivative_attempt(&worker.pool, id).await {
|
||||
Ok(Some(n)) if n > Self::MAX_DERIVATIVE_ATTEMPTS => continue,
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %id, "could not record a backfill attempt; skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let original = worker.media_path.join(&original_path);
|
||||
match worker
|
||||
.generate_image_derivatives(id, &original, &mime_type)
|
||||
@@ -309,6 +439,8 @@ impl CompressionWorker {
|
||||
Ok((preview_rel, display_rel)) => {
|
||||
let _ = Upload::set_preview_path(&worker.pool, id, &preview_rel).await;
|
||||
let _ = Upload::set_display_path(&worker.pool, id, &display_rel).await;
|
||||
// Clears derivative_attempts too, so a row that failed transiently is
|
||||
// not one boot closer to being abandoned.
|
||||
let _ =
|
||||
Upload::set_derivatives_rev(&worker.pool, id, Self::DERIVATIVES_REV)
|
||||
.await;
|
||||
@@ -316,64 +448,400 @@ impl CompressionWorker {
|
||||
}
|
||||
Err(e) => {
|
||||
// Leave the existing derivatives and the original intact; this row is
|
||||
// simply retried on the next start. The rev stays behind, which is the
|
||||
// marker that it still needs doing.
|
||||
// retried on the next start until its attempt budget runs out. The rev
|
||||
// stays behind, which is the marker that it still needs doing.
|
||||
tracing::warn!(error = ?e, %id, "derivative backfill failed; leaving as-is");
|
||||
let _ =
|
||||
Upload::record_derivative_failure(&worker.pool, id, &format!("{e:#}"))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-extract poster frames for videos that never got one.
|
||||
///
|
||||
/// A video interrupted by a restart is stranded: `startup_recovery` flips its
|
||||
/// `compression_status` from `processing` to `failed` and nothing re-enqueues it, so
|
||||
/// `thumbnail_path` stays NULL forever while the clip itself plays fine. The feed shows a
|
||||
/// posterless tile for the rest of the event, and after
|
||||
/// `FAILED_ORIGINAL_RETENTION_DAYS` the reclaim sweep is entitled to the original.
|
||||
///
|
||||
/// Shares `derivative_attempts` with the image backfill on purpose. Note the consequence,
|
||||
/// which is intended rather than a bug to fix later: `extract_poster_frame` returning
|
||||
/// `Ok(false)` is a NORMAL, permanent outcome for a sub-second clip (Live Photos,
|
||||
/// mis-taps), and since the counter is write-ahead and only cleared by a real success,
|
||||
/// those clips stop being re-ffmpeg'd on every boot once the budget is spent.
|
||||
pub async fn backfill_video_posters(&self) {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String)>(
|
||||
"SELECT id, original_path FROM upload
|
||||
WHERE deleted_at IS NULL AND mime_type LIKE 'video/%'
|
||||
AND thumbnail_path IS NULL
|
||||
AND original_path <> ''
|
||||
AND derivative_attempts < $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2",
|
||||
)
|
||||
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||
.bind(Self::BACKFILL_BATCH)
|
||||
.fetch_all(&self.pool)
|
||||
.await;
|
||||
let rows = match rows {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "video poster backfill query failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::info!("re-extracting posters for {} video(s)", rows.len());
|
||||
|
||||
let worker = self.clone();
|
||||
tokio::spawn(async move {
|
||||
for (id, original_path) in rows {
|
||||
let _permit = worker.semaphore.acquire().await;
|
||||
match Upload::begin_derivative_attempt(&worker.pool, id).await {
|
||||
Ok(Some(n)) if n > Self::MAX_DERIVATIVE_ATTEMPTS => continue,
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %id, "could not record a poster attempt; skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let original = worker.media_path.join(&original_path);
|
||||
match worker.generate_video_thumbnail(id, &original).await {
|
||||
Ok(Some(thumb_rel)) => {
|
||||
if Upload::set_thumbnail_path(&worker.pool, id, &thumb_rel)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
// Clears the attempt counter: a video that eventually succeeded
|
||||
// must not carry a budget scar into a future pipeline revision.
|
||||
let _ = Upload::set_derivatives_rev(
|
||||
&worker.pool,
|
||||
id,
|
||||
Self::DERIVATIVES_REV,
|
||||
)
|
||||
.await;
|
||||
tracing::info!("poster regenerated for upload {id}");
|
||||
}
|
||||
}
|
||||
// No frame at all — normal for a very short clip. The tile stays
|
||||
// posterless and the attempt is spent, which is what stops the retry.
|
||||
Ok(None) => {
|
||||
tracing::debug!(%id, "still no poster frame; leaving the tile as-is");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %id, "poster backfill failed; leaving as-is");
|
||||
let _ =
|
||||
Upload::record_derivative_failure(&worker.pool, id, &format!("{e:#}"))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Say out loud, once per boot, that some uploads have stopped being retried.
|
||||
///
|
||||
/// Without this the give-up is invisible: the loop stops (which is the point) but the
|
||||
/// affected photos keep a stale or missing derivative forever with nothing to notice. The
|
||||
/// originals are untouched, so this is recoverable once the cause is fixed — reset
|
||||
/// `derivative_attempts` to 0 and restart.
|
||||
async fn report_exhausted_derivatives(&self) {
|
||||
let exhausted: Result<i64, _> = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM upload
|
||||
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
|
||||
AND derivative_attempts >= $2
|
||||
AND (
|
||||
(display_path IS NULL AND preview_path IS NOT NULL)
|
||||
OR derivatives_rev < $1
|
||||
)",
|
||||
)
|
||||
.bind(Self::DERIVATIVES_REV)
|
||||
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
if let Ok(count) = exhausted
|
||||
&& count > 0
|
||||
{
|
||||
tracing::error!(
|
||||
count,
|
||||
"{count} upload(s) exhausted derivative regeneration and will no longer be \
|
||||
retried; their originals are intact — see upload.derivative_last_error, fix \
|
||||
the cause, then reset derivative_attempts to 0 and restart"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
|
||||
/// Extract the feed poster for a video. `Ok(None)` when the clip yields no frame — see
|
||||
/// [`crate::services::video::extract_poster_frame`], which owns the seek order, the timeout and
|
||||
/// the artifact check that this function used to be missing.
|
||||
async fn generate_video_thumbnail(
|
||||
&self,
|
||||
upload_id: Uuid,
|
||||
original: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
let thumbs_dir = self.media_path.join("thumbnails");
|
||||
tokio::fs::create_dir_all(&thumbs_dir).await?;
|
||||
|
||||
let thumb_filename = format!("{upload_id}.jpg");
|
||||
let thumb_path = thumbs_dir.join(&thumb_filename);
|
||||
|
||||
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a
|
||||
// cap, the held compression-worker semaphore permit is never released and the
|
||||
// pool eventually deadlocks (no further uploads ever processed). 120s is well
|
||||
// above the time to extract one frame from any sane input.
|
||||
let mut child = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
original.to_str().unwrap_or_default(),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vf",
|
||||
"scale=800:-1",
|
||||
"-y",
|
||||
thumb_path.to_str().unwrap_or_default(),
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("failed to spawn ffmpeg")?;
|
||||
let produced =
|
||||
crate::services::video::extract_poster_frame(original, &thumb_path, 800).await?;
|
||||
|
||||
let status =
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(120), child.wait()).await {
|
||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
anyhow::bail!("ffmpeg timeout after 120s");
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
// Best-effort: drain stderr for the log.
|
||||
let mut stderr = Vec::new();
|
||||
if let Some(mut handle) = child.stderr.take() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let _ = handle.read_to_end(&mut stderr).await;
|
||||
}
|
||||
anyhow::bail!("ffmpeg failed: {}", String::from_utf8_lossy(&stderr));
|
||||
}
|
||||
|
||||
Ok(format!("thumbnails/{thumb_filename}"))
|
||||
Ok(produced.then(|| format!("thumbnails/{thumb_filename}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking half of [`CompressionWorker::generate_image_derivatives`]: decode once, write
|
||||
/// both derivatives, then optionally shrink a PNG original in place.
|
||||
///
|
||||
/// A free function rather than an inline closure so its memory behaviour is directly testable —
|
||||
/// this is the code path that OOM-killed the container, and the fix is a scoping property that a
|
||||
/// future edit could silently undo.
|
||||
fn write_image_derivatives(
|
||||
upload_id: Uuid,
|
||||
original: &Path,
|
||||
mime_type: &str,
|
||||
preview_path: &Path,
|
||||
display_path: &Path,
|
||||
) -> Result<()> {
|
||||
let preview_max = CompressionWorker::PREVIEW_MAX_EDGE;
|
||||
let display_max = CompressionWorker::DISPLAY_MAX_EDGE;
|
||||
|
||||
// THE FULL-SIZE DECODE IS SCOPED TO THIS BLOCK ON PURPOSE, and the block yields the
|
||||
// DISPLAY derivative rather than the original.
|
||||
//
|
||||
// `img` is up to 256 MiB (imaging::decode_limits max_alloc) and `resize` only BORROWS it,
|
||||
// so it used to stay alive through both resizes AND the oxipng call below — which decodes
|
||||
// the PNG a second time and holds a full-size buffer per filter trial. That measured
|
||||
// ~1250 MiB of peak RSS for a 2.8 MiB input, inside a 1 GiB cgroup: the container was
|
||||
// SIGKILLed, taking every SSE stream and every in-flight upload with it.
|
||||
//
|
||||
// A block rather than a bare `drop(img)` because a `drop` call is one careless edit away
|
||||
// from being removed as redundant-looking — and note the `else` arm MOVES `img` out, which
|
||||
// is what makes "the block's value is the only survivor" true in both arms.
|
||||
let (display, width, height) = {
|
||||
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||
// services::imaging for why neither may be skipped.
|
||||
let img = crate::services::imaging::decode_oriented(original)?;
|
||||
let (width, height) = (img.width(), img.height());
|
||||
|
||||
// Display: max 2048px for the diashow. Only DOWNSCALE — never upscale a smaller
|
||||
// original (that adds bytes with no quality gain); re-encode it as JPEG as-is.
|
||||
let display = if width > display_max || height > display_max {
|
||||
img.resize(
|
||||
display_max,
|
||||
display_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
(display, width, height)
|
||||
};
|
||||
|
||||
display
|
||||
.save_with_format(display_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save display")?;
|
||||
|
||||
// Preview: max 800px, derived from the DISPLAY, not from the original.
|
||||
//
|
||||
// Both derivatives used to resize the full-size decode independently, so a 8000x8000
|
||||
// original paid for two full-size Lanczos passes and their intermediates — measured 520
|
||||
// MiB peak even after the scoping fix above, which two concurrent workers cannot fit in a
|
||||
// 1 GiB container. Chaining 8000 -> 2048 -> 800 makes the second pass operate on 2048px
|
||||
// input, and the full-size buffer is already freed by the time it runs. Quality is not the
|
||||
// trade-off here: a staged Lanczos3 downscale to 800px is visually indistinguishable from
|
||||
// a single-step one (and is a standard technique for large ratios).
|
||||
display
|
||||
.resize(
|
||||
preview_max,
|
||||
preview_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
.save_with_format(preview_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save preview")?;
|
||||
drop(display);
|
||||
|
||||
let pixels = u64::from(width) * u64::from(height);
|
||||
|
||||
// If the original is PNG, try lossless compression in place — but only when its pixel count
|
||||
// is inside the budget, and never for longer than OXIPNG_TIMEOUT. This is a best-effort size
|
||||
// saving: declining it costs disk, while attempting it unbounded cost the whole container.
|
||||
if mime_type == "image/png" {
|
||||
if pixels <= CompressionWorker::OXIPNG_MAX_PIXELS {
|
||||
let mut opts = oxipng::Options::from_preset(2);
|
||||
opts.timeout = Some(CompressionWorker::OXIPNG_TIMEOUT);
|
||||
let _ = oxipng::optimize(
|
||||
&oxipng::InFile::Path(original.to_path_buf()),
|
||||
&oxipng::OutFile::Path {
|
||||
path: None,
|
||||
preserve_attrs: true,
|
||||
},
|
||||
&opts,
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
%upload_id, pixels,
|
||||
"skipping oxipng: above the pixel budget; the original is stored as uploaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Peak resident set of THIS process, in bytes, from `/proc/self/status`.
|
||||
fn peak_rss_bytes() -> u64 {
|
||||
let status = std::fs::read_to_string("/proc/self/status").expect("procfs");
|
||||
let line = status
|
||||
.lines()
|
||||
.find(|l| l.starts_with("VmHWM:"))
|
||||
.expect("VmHWM");
|
||||
let kb: u64 = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.expect("VmHWM value");
|
||||
kb * 1024
|
||||
}
|
||||
|
||||
/// Reset the kernel's peak-RSS watermark so the measurement covers only what follows.
|
||||
/// Linux 4.0+; writing "5" to `clear_refs` resets `VmHWM` to the current RSS.
|
||||
fn reset_peak_rss() {
|
||||
let _ = std::fs::write("/proc/self/clear_refs", "5");
|
||||
}
|
||||
|
||||
/// The pixel gate has to sit below what the axis limits allow, or it can never fire.
|
||||
#[test]
|
||||
fn the_oxipng_gate_is_reachable_within_the_decode_limits() {
|
||||
const _: () = {
|
||||
// imaging::decode_limits permits 12_000 x 12_000 = 144 MP. A gate above that would
|
||||
// never skip anything.
|
||||
assert!(CompressionWorker::OXIPNG_MAX_PIXELS < 12_000 * 12_000);
|
||||
// ...and it must stay above a 48 MP camera, so real photos still get optimised.
|
||||
assert!(CompressionWorker::OXIPNG_MAX_PIXELS >= 8_000_000);
|
||||
};
|
||||
}
|
||||
|
||||
/// The heavy-image gate has to classify the two cases the way the sizing assumed:
|
||||
/// an ordinary phone photo must NOT serialise, and the giant must.
|
||||
#[test]
|
||||
fn the_heavy_gate_separates_a_phone_photo_from_a_giant() {
|
||||
let dir = std::env::temp_dir().join(format!("es-heavy-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// 12 MP, the shape of a default phone capture.
|
||||
let ordinary = dir.join("ordinary.jpg");
|
||||
image::RgbImage::new(4032, 3024).save(&ordinary).unwrap();
|
||||
let ordinary_peak = crate::services::imaging::estimated_processing_peak_bytes(
|
||||
&ordinary,
|
||||
CompressionWorker::DISPLAY_MAX_EDGE,
|
||||
)
|
||||
.expect("header readable");
|
||||
assert!(
|
||||
ordinary_peak <= crate::services::imaging::HEAVY_IMAGE_BYTES,
|
||||
"a 12 MP photo estimated at {} MiB would serialise the common path",
|
||||
ordinary_peak / 1048576
|
||||
);
|
||||
|
||||
// The 64 MP RGBA case that measured ~516 MiB peak.
|
||||
let giant = dir.join("giant.png");
|
||||
image::RgbaImage::new(8000, 8000).save(&giant).unwrap();
|
||||
let giant_peak = crate::services::imaging::estimated_processing_peak_bytes(
|
||||
&giant,
|
||||
CompressionWorker::DISPLAY_MAX_EDGE,
|
||||
)
|
||||
.expect("header readable");
|
||||
assert!(
|
||||
giant_peak > crate::services::imaging::HEAVY_IMAGE_BYTES,
|
||||
"an 8000x8000 RGBA original estimated at only {} MiB would be allowed to run \
|
||||
concurrently with another one — 2x its real ~516 MiB peak does not fit in 1 GiB",
|
||||
giant_peak / 1048576
|
||||
);
|
||||
// The estimate must also be in the right ballpark, not merely on the right side of the
|
||||
// threshold: 244 MiB decode + 262 MiB f32 resize intermediate.
|
||||
assert!(
|
||||
(400..700).contains(&(giant_peak / 1048576)),
|
||||
"estimate {} MiB is far from the measured ~516 MiB peak",
|
||||
giant_peak / 1048576
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The OOM that took the container down, measured rather than argued.
|
||||
///
|
||||
/// An 8000x8000 RGBA PNG passes admission: 256,000,000 bytes is just under the 256 MiB
|
||||
/// `max_alloc`, and smooth content is a few MB on disk, far under any size cap. The old
|
||||
/// code kept that ~244 MiB decode alive across an unbounded, multi-threaded oxipng run and
|
||||
/// peaked at ~1250 MiB — inside a 1 GiB cgroup. Being SIGKILLed there is not a blip: the
|
||||
/// row was already committed, so the boot backfill replayed the identical workload on every
|
||||
/// restart.
|
||||
///
|
||||
/// `#[ignore]` because it allocates ~250 MiB and takes a few seconds. Run explicitly:
|
||||
/// cargo test --release oom -- --ignored --nocapture --test-threads=1
|
||||
/// It must run ALONE — `VmHWM` is per process, so a concurrent test would pollute it.
|
||||
#[test]
|
||||
#[ignore = "heavy: allocates ~250 MiB; run with --ignored --test-threads=1"]
|
||||
fn a_large_png_stays_far_below_the_container_limit() {
|
||||
const EDGE: u32 = 8_000;
|
||||
let dir = std::env::temp_dir().join(format!("es-oom-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("big.png");
|
||||
|
||||
// Smooth gradient: ~244 MiB decoded, a couple of MB on disk. That gap is the whole
|
||||
// point — file size tells you nothing about what a PNG costs to process.
|
||||
{
|
||||
let mut buf = image::RgbaImage::new(EDGE, EDGE);
|
||||
for (x, y, px) in buf.enumerate_pixels_mut() {
|
||||
*px = image::Rgba([(x >> 5) as u8, (y >> 5) as u8, ((x + y) >> 6) as u8, 255]);
|
||||
}
|
||||
buf.save(&original).unwrap();
|
||||
}
|
||||
|
||||
// Everything above is fixture setup, not the code under test.
|
||||
reset_peak_rss();
|
||||
let before = peak_rss_bytes();
|
||||
|
||||
write_image_derivatives(
|
||||
Uuid::new_v4(),
|
||||
&original,
|
||||
"image/png",
|
||||
&dir.join("preview.jpg"),
|
||||
&dir.join("display.jpg"),
|
||||
)
|
||||
.expect("derivatives");
|
||||
|
||||
let peak = peak_rss_bytes();
|
||||
let on_disk = std::fs::metadata(&original).unwrap().len();
|
||||
eprintln!(
|
||||
"input {:.2} MiB on disk ({EDGE}x{EDGE}); peak RSS {:.0} MiB (was {:.0} MiB before)",
|
||||
on_disk as f64 / 1048576.0,
|
||||
peak as f64 / 1048576.0,
|
||||
before as f64 / 1048576.0
|
||||
);
|
||||
|
||||
assert!(dir.join("preview.jpg").exists() && dir.join("display.jpg").exists());
|
||||
// The container gets 1 GiB and runs two of these concurrently. 600 MiB is a generous
|
||||
// ceiling that the old code (~1250 MiB) could not have met.
|
||||
assert!(
|
||||
peak < 600 * 1024 * 1024,
|
||||
"peak RSS {} MiB — the decode is being held across oxipng again, or the pixel \
|
||||
gate stopped firing",
|
||||
peak / 1048576
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +483,60 @@ pub fn spawn_export_jobs(
|
||||
});
|
||||
}
|
||||
|
||||
/// Preflight that will sacrifice the previous generation rather than deadlock, and the order is
|
||||
/// the whole point.
|
||||
///
|
||||
/// Deferring the prune (so a failed rebuild can never leave the event with no archive at all) has
|
||||
/// a cost the first version of this did not follow through on: at rebuild time the previous
|
||||
/// generation is still on disk and still counted against free space, so the preflight demands room
|
||||
/// for BOTH. That halves the gallery size a rebuild can survive relative to the size the upload
|
||||
/// gate allows — and every path that bumps the epoch (a guest deleting their own photo, a caption
|
||||
/// edit, a ban, "Neu erzeugen") retires the current keepsake IMMEDIATELY on commit. So above that
|
||||
/// threshold the download 404s, the rebuild is refused, and the only thing that could free the
|
||||
/// space is the prune that now only runs on success. Permanently stuck, unreachable from any
|
||||
/// handler.
|
||||
///
|
||||
/// So: try to build while preserving the old generation. If that genuinely does not fit, the old
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// it must be the caller's OWN prefix: pruning the other half's archives from here would reclaim
|
||||
/// space a sibling worker is about to need, on its behalf, without its knowledge.
|
||||
async fn ensure_export_space_reclaiming(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_path: &Path,
|
||||
prefix: &str,
|
||||
epoch: i64,
|
||||
) -> Result<()> {
|
||||
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"
|
||||
);
|
||||
prune_superseded_archives(pool, export_path, prefix, event_id, epoch).await;
|
||||
ensure_export_space(pool, event_id, export_path).await
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// producers of heavy image work agree on what "heavy" means and serialise against each other
|
||||
/// rather than each against itself.
|
||||
async fn heavy_permit_for(path: &Path) -> Option<tokio::sync::SemaphorePermit<'static>> {
|
||||
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()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZIP export ───────────────────────────────────────────────────────────────
|
||||
|
||||
async fn run_zip_export(
|
||||
@@ -499,14 +553,11 @@ async fn run_zip_export(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reclaim BEFORE measuring: the superseded archive is already unreachable, and the space it
|
||||
// holds is very often exactly the space this rebuild needs.
|
||||
prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await;
|
||||
// AFTER the claim, not before. A preflight that bailed before claiming would leave the row
|
||||
// `pending` with no worker and no error — the spinner-forever state `mark_failed`'s status
|
||||
// guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the
|
||||
// host gets the reason.
|
||||
ensure_export_space(pool, event_id, export_path).await?;
|
||||
ensure_export_space_reclaiming(pool, event_id, export_path, "Gallery", epoch).await?;
|
||||
|
||||
// On error, mark THIS generation failed — a no-op if we've since been superseded (the
|
||||
// caller in `spawn_export_jobs` does it, epoch-guarded). Temp artifacts are cleaned up
|
||||
@@ -517,6 +568,25 @@ async fn run_zip_export(
|
||||
tokio::fs::remove_file(export_path.join(gen_name(event_id, "Gallery", epoch, ".tmp")))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Reclaim the PREVIOUS generation only once this one has actually landed.
|
||||
//
|
||||
// This used to run before the preflight, reasoning that the superseded archive is already
|
||||
// unreachable and its space is usually exactly what the rebuild needs. That is true about
|
||||
// REACHABILITY and false about RECOVERABILITY: an epoch is a database value that can be
|
||||
// rolled back, deleted bytes cannot. Any rebuild that then failed — ENOSPC mid-write, an
|
||||
// OOM, a hung ffmpeg, a host tapping "Neu erzeugen" on a bad day — left the event with NO
|
||||
// archive at all, which is the one outcome the whole product exists to prevent, at the one
|
||||
// moment nobody is watching.
|
||||
//
|
||||
// The cost of deferring is that a rebuild now needs room for both generations at once, and
|
||||
// `ensure_export_space` above no longer gets to count the old archive's bytes as available.
|
||||
// That is the correct trade: it converts "silently destroyed the only copy" into "refused
|
||||
// to start, and said why".
|
||||
if res.is_ok() {
|
||||
prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await;
|
||||
}
|
||||
|
||||
abandon_if_superseded("ZIP", event_id, epoch, res)
|
||||
}
|
||||
|
||||
@@ -636,12 +706,18 @@ async fn run_zip_export_inner(
|
||||
// Note what ISN'T here any more: the ready-flag flip. Readiness is derived from
|
||||
// (released AND job.epoch = event.epoch AND status = 'done'), so writing `done` at a live epoch
|
||||
// IS the publish, atomically. A worker at a dead epoch simply writes a row nobody can see.
|
||||
if !finalize_job(pool, event_id, "zip", epoch, &format!("exports/{out_name}")).await {
|
||||
if !finalize_job(pool, event_id, "zip", epoch, &format!("exports/{out_name}")).await? {
|
||||
let _ = tokio::fs::remove_file(&out_path).await;
|
||||
tracing::info!(
|
||||
"ZIP export for event {event_id} superseded (epoch {epoch} retired); discarded"
|
||||
);
|
||||
return Ok(());
|
||||
// Err, NOT Ok. `abandon_if_superseded` swallows this sentinel for the caller, so the
|
||||
// outcome is unchanged — but `run_*_export`'s deferred prune keys off `res.is_ok()`,
|
||||
// and a worker that LOST the epoch race reporting success made it reclaim generations
|
||||
// older than its own RETIRED epoch. At that moment the winning generation is still
|
||||
// `pending` with no file, so nothing protected the last good archive and it was deleted
|
||||
// with no replacement on disk — the precise outcome deferring the prune exists to stop.
|
||||
return Err(Superseded.into());
|
||||
}
|
||||
|
||||
prune_stale_export_files(pool, &exports_dir, "Gallery", event_id, epoch).await;
|
||||
@@ -690,10 +766,9 @@ async fn run_html_export(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// See run_zip_export: reclaim the superseded generation first, then refuse at the door rather
|
||||
// than ENOSPC mid-write.
|
||||
prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await;
|
||||
ensure_export_space(pool, event_id, export_path).await?;
|
||||
// See run_zip_export: refuse at the door rather than ENOSPC mid-write, and reclaim the
|
||||
// superseded generation only AFTER this one lands — unless it is the only way to land at all.
|
||||
ensure_export_space_reclaiming(pool, event_id, export_path, "Memories", epoch).await?;
|
||||
|
||||
let res = run_html_export_inner(
|
||||
epoch,
|
||||
@@ -716,6 +791,13 @@ async fn run_html_export(
|
||||
tokio::fs::remove_dir_all(export_path.join(format!("viewer_tmp_{event_id}_{epoch}")))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Only once the new keepsake exists — see the reasoning in run_zip_export. A failed rebuild
|
||||
// must never be the reason the previous one is gone.
|
||||
if res.is_ok() {
|
||||
prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await;
|
||||
}
|
||||
|
||||
abandon_if_superseded("HTML", event_id, epoch, res)
|
||||
}
|
||||
|
||||
@@ -785,38 +867,32 @@ async fn run_html_export_inner(
|
||||
let full_ext = ext_from_path(&row.original_path);
|
||||
let full = format!("{id_str}.{full_ext}");
|
||||
|
||||
// Video thumbnail via ffmpeg
|
||||
// Poster frame via the shared helper, which owns the seek order, the 120s timeout
|
||||
// (this call site had NONE — a hung ffmpeg would strand the export at `running`
|
||||
// forever) and the artifact check.
|
||||
let thumb_path = media_tmp.join(&thumb);
|
||||
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
src.to_str().unwrap_or_default(),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vf",
|
||||
"scale=400:-1",
|
||||
"-y",
|
||||
thumb_path.to_str().unwrap_or_default(),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match ffmpeg_result {
|
||||
Ok(output) if output.status.success() => {}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"ffmpeg thumbnail failed for upload {}, skipping thumb",
|
||||
row.id
|
||||
);
|
||||
// Missing thumb entry — viewer handles missing thumbs gracefully.
|
||||
}
|
||||
let produced =
|
||||
match crate::services::video::extract_poster_frame(&src, &thumb_path, 400).await {
|
||||
Ok(produced) => produced,
|
||||
Err(e) => {
|
||||
tracing::warn!("poster extraction errored for upload {}: {e:#}", row.id);
|
||||
false
|
||||
}
|
||||
};
|
||||
if !produced {
|
||||
tracing::info!(
|
||||
upload_id = %row.id,
|
||||
"no poster frame for this video; exporting it without one"
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the video full-res straight from the original at ZIP time — no
|
||||
// copy to temp (that used to transiently double disk usage per video).
|
||||
(thumb, full, MediaSource::Original(src.clone()))
|
||||
(
|
||||
produced.then(|| thumb.clone()),
|
||||
full,
|
||||
MediaSource::Original(src.clone()),
|
||||
)
|
||||
} else {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let ext = ext_from_path(&row.original_path);
|
||||
@@ -827,6 +903,12 @@ async fn run_html_export_inner(
|
||||
let thumb_path = media_tmp.join(&thumb);
|
||||
let thumb_path_clone = thumb_path.clone();
|
||||
|
||||
// Same process-wide memory permit the compression worker takes. Without it, a
|
||||
// release fired while the last phone photos were still compressing put an export
|
||||
// decode and a heavy compression job in the same 1 GiB cgroup — and the OOM kill
|
||||
// marks the export failed, which `recover_exports` then re-spawns into the same
|
||||
// conditions on the next boot.
|
||||
let _heavy = heavy_permit_for(&src).await;
|
||||
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// `decode_oriented`, not `image::open`: the latter ignores the EXIF
|
||||
// orientation tag AND applies no decode limits. Using it here is why every
|
||||
@@ -842,9 +924,17 @@ async fn run_html_export_inner(
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Err(e) = thumb_result {
|
||||
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
|
||||
}
|
||||
// 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
|
||||
// entry the archive didn't contain. An undecodable image is rarer than a sub-second
|
||||
// clip, but the broken tile is identical.
|
||||
let thumb_ok = match thumb_result {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Full variant: compress to temp if >5MB, otherwise stream the original
|
||||
// as-is (no temp copy). `src_meta` was stat'd once at the top of the loop.
|
||||
@@ -853,6 +943,9 @@ async fn run_html_export_inner(
|
||||
let full_path = media_tmp.join(&full);
|
||||
let full_path_clone = full_path.clone();
|
||||
|
||||
// See the thumbnail above. This branch is the more expensive of the two: it
|
||||
// only runs for originals over 5 MB, i.e. exactly the giants.
|
||||
let _heavy = heavy_permit_for(&src).await;
|
||||
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Same reason as the thumbnail above. This branch only runs for originals
|
||||
// over 5 MB, which is why the viewer's full image looked correct for small
|
||||
@@ -882,15 +975,16 @@ async fn run_html_export_inner(
|
||||
MediaSource::Original(src.clone())
|
||||
};
|
||||
|
||||
(thumb, full, full_source)
|
||||
(thumb_ok.then_some(thumb), full, full_source)
|
||||
};
|
||||
|
||||
// Register this post's two media entries. Thumbnails always come from temp
|
||||
// (they're freshly generated); the full variant's source was decided above.
|
||||
media_manifest.push((
|
||||
thumb_name.clone(),
|
||||
MediaSource::Temp(media_tmp.join(&thumb_name)),
|
||||
));
|
||||
// Register this post's media entries. The thumbnail is registered ONLY when one was
|
||||
// actually produced: pushing a manifest entry for a file that doesn't exist made the ZIP
|
||||
// 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((full_name.clone(), full_source));
|
||||
|
||||
// Build comments for this upload
|
||||
@@ -925,7 +1019,15 @@ async fn run_html_export_inner(
|
||||
} else {
|
||||
"image".to_string()
|
||||
},
|
||||
thumb: format!("media/{thumb_name}"),
|
||||
// Empty when there is no poster. The viewer already guards on this
|
||||
// (`{#if post.media.thumb}` → a video tile with a play glyph, or the placeholder
|
||||
// icon for an image), so telling it the truth is the entire fix — no schema
|
||||
// change, no viewer rebuild. What was broken was the backend always claiming a
|
||||
// thumbnail existed.
|
||||
thumb: thumb_name
|
||||
.as_ref()
|
||||
.map(|n| format!("media/{n}"))
|
||||
.unwrap_or_default(),
|
||||
full: format!("media/{full_name}"),
|
||||
},
|
||||
});
|
||||
@@ -1051,13 +1153,19 @@ async fn run_html_export_inner(
|
||||
epoch,
|
||||
&format!("exports/{out_name}"),
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
let _ = tokio::fs::remove_file(&out_path).await;
|
||||
tracing::info!(
|
||||
"HTML export for event {event_id} superseded (epoch {epoch} retired); discarded"
|
||||
);
|
||||
return Ok(());
|
||||
// Err, NOT Ok. `abandon_if_superseded` swallows this sentinel for the caller, so the
|
||||
// outcome is unchanged — but `run_*_export`'s deferred prune keys off `res.is_ok()`,
|
||||
// and a worker that LOST the epoch race reporting success made it reclaim generations
|
||||
// older than its own RETIRED epoch. At that moment the winning generation is still
|
||||
// `pending` with no file, so nothing protected the last good archive and it was deleted
|
||||
// with no replacement on disk — the precise outcome deferring the prune exists to stop.
|
||||
return Err(Superseded.into());
|
||||
}
|
||||
|
||||
prune_stale_export_files(pool, &exports_dir, "Memories", event_id, epoch).await;
|
||||
@@ -1166,14 +1274,27 @@ async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str, epoch: i64)
|
||||
/// AND status = 'done')`, so a single row write makes the keepsake downloadable — there is no
|
||||
/// second flag to flip and therefore no window between "done" and "visible". A worker whose epoch
|
||||
/// was retired matches nothing here and must discard its output.
|
||||
///
|
||||
/// ERRORS ARE DISTINGUISHED FROM A LOST RACE, for the same reason `claim_job` distinguishes them —
|
||||
/// and the consequence here is strictly worse. This used to end `.unwrap_or(false)`, collapsing a
|
||||
/// pool timeout into "we were superseded". At that point the archive is already built, fsynced and
|
||||
/// renamed into place, so the caller went on to DELETE the finished multi-GB file and return the
|
||||
/// `Superseded` sentinel — which `abandon_if_superseded` swallows into `Ok(())`, so
|
||||
/// `spawn_export_jobs` never called `mark_failed` either. The row stayed `running` at 99% at the
|
||||
/// LIVE epoch, which the host dashboard renders as "Wird erstellt (99 %)" with the download
|
||||
/// disabled, forever: no sweep re-examines `running` rows, and `recover_exports` runs only at boot.
|
||||
///
|
||||
/// A pool timeout is not exotic here. `max_connections` is 10, `acquire_timeout` 5s, and this fires
|
||||
/// at the end of a full-gallery export while ~100 guests are uploading. Returning `Err` instead
|
||||
/// leaves the finished archive on disk and lets the caller's `mark_failed` record a real reason.
|
||||
async fn finalize_job(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_type: &str,
|
||||
epoch: i64,
|
||||
file_path: &str,
|
||||
) -> bool {
|
||||
sqlx::query(
|
||||
) -> Result<bool> {
|
||||
let r = sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'done', progress_pct = 100, file_path = $3, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = $2::export_type
|
||||
@@ -1185,8 +1306,8 @@ async fn finalize_job(
|
||||
.bind(epoch)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map(|r| r.rows_affected() > 0)
|
||||
.unwrap_or(false)
|
||||
.context("finalizing export job")?;
|
||||
Ok(r.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Parse the trailing generation number out of `<prefix><n><suffix>` (e.g.
|
||||
@@ -1218,16 +1339,19 @@ async fn protected_files(pool: &PgPool, event_id: Uuid) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reclaim superseded FINAL archives BEFORE this generation starts writing its own.
|
||||
/// Reclaim superseded FINAL archives.
|
||||
///
|
||||
/// Peak disk usage used to be two full generations, because the only prune ran after the new archive
|
||||
/// was written, renamed and finalised. That ordering reads as durability ("don't delete the good
|
||||
/// keepsake before the replacement is safe") but it buys nothing: readiness is derived from
|
||||
/// `job.epoch = event.export_epoch AND status = 'done'`, so the moment `invalidate_and_arm` bumps
|
||||
/// the epoch the old archive is ALREADY unreachable — `GET /export/zip` 404s whether the file is on
|
||||
/// disk or not. Keeping it only reserves gigabytes for a download nobody can perform, and for
|
||||
/// `Affects::Both` (a takedown) it is content someone has explicitly asked to have removed. So a
|
||||
/// rebuild reclaims first and peaks at one generation.
|
||||
/// CALLED AFTER A SUCCESSFUL BUILD, not before one. This doc used to argue the opposite at
|
||||
/// length — that since readiness is derived from `job.epoch = event.export_epoch`, a superseded
|
||||
/// archive is already unreachable and keeping it "buys nothing". That reasoning is right about
|
||||
/// REACHABILITY and wrong about RECOVERABILITY: an epoch is a database value that can be rolled
|
||||
/// back, deleted bytes cannot. Pruning first meant any rebuild that then failed — ENOSPC, an OOM,
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
@@ -1321,7 +1445,7 @@ const EXPORT_SIZE_OVERHEAD_PCT: u64 = 110;
|
||||
/// Computed in `u128` and clamped, NOT with `saturating_mul`: saturating first and then dividing by
|
||||
/// 100 quietly turns an overflow into a number ~100x too small, which is the one direction that
|
||||
/// matters here — an under-estimate authorises the very write the preflight exists to refuse.
|
||||
fn required_free_bytes(media_bytes: u64, armed: i64) -> u64 {
|
||||
pub(crate) fn required_free_bytes(media_bytes: u64, armed: i64) -> u64 {
|
||||
let needed = media_bytes as u128 * EXPORT_SIZE_OVERHEAD_PCT as u128 / 100
|
||||
* armed.max(1).min(i64::from(u32::MAX)) as u128;
|
||||
needed.min(u64::MAX as u128) as u64
|
||||
@@ -1372,18 +1496,32 @@ async fn ensure_export_space(pool: &PgPool, event_id: Uuid, export_path: &Path)
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if free < needed {
|
||||
// The archive may not consume the last byte of the volume. `needed` alone authorised an
|
||||
// export sized at exactly `free`: it would pass the check, run for half an hour, and land
|
||||
// the box at zero — at which point Postgres cannot write WAL and the event is over, with
|
||||
// the keepsake still unfinished. `postgres_data`, `media_data` and `exports_data` share one
|
||||
// filesystem, so "enough room for the archive" was never the same question as "enough room
|
||||
// for the archive AND a working database".
|
||||
//
|
||||
// Same reserve the upload path and the host dashboard's low-disk banner use, so all three
|
||||
// agree on what "full" means.
|
||||
let required = needed.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
|
||||
|
||||
if free < required {
|
||||
let gb = |b: u64| b as f64 / 1_000_000_000.0;
|
||||
tracing::error!(
|
||||
needed,
|
||||
required,
|
||||
free,
|
||||
armed,
|
||||
"export preflight: not enough free space to build the keepsake for event {event_id}"
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Nicht genug Speicherplatz für das Keepsake: benötigt ca. {:.1} GB, frei sind {:.1} GB. \
|
||||
Bitte Speicher freigeben und das Keepsake anschließend neu erstellen.",
|
||||
"Nicht genug Speicherplatz für das Keepsake: benötigt ca. {:.1} GB (plus {:.0} GB \
|
||||
Reserve), frei sind {:.1} GB. Bitte Speicher freigeben und das Keepsake \
|
||||
anschließend neu erstellen.",
|
||||
gb(needed),
|
||||
gb(crate::handlers::upload::DISK_RESERVE_BYTES as u64),
|
||||
gb(free)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,8 +39,10 @@ fn decode_limits() -> image::Limits {
|
||||
///
|
||||
/// Deliberately narrow. Only the `ImageError` variants that are a property of the *input*
|
||||
/// count: the file will not shrink, gain codec support, or un-corrupt itself between
|
||||
/// attempts. `IoError` is excluded on purpose — an ENOSPC while writing a derivative, or
|
||||
/// EMFILE under load, is exactly the transient case the retry exists for.
|
||||
/// attempts. `IoError` is excluded on purpose — EMFILE under load, or a momentarily
|
||||
/// unreadable file, is exactly the transient case the retry exists for. A FULL disk is the
|
||||
/// one io error that must not be retried either, but for a different reason and with a
|
||||
/// different remedy; see [`is_storage_full_error`].
|
||||
pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
matches!(
|
||||
@@ -54,6 +56,34 @@ pub fn is_permanent_image_error(err: &anyhow::Error) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the failure is the media filesystem being out of space.
|
||||
///
|
||||
/// Deliberately separate from [`is_permanent_image_error`], which is about the *input*. ENOSPC
|
||||
/// is about the *host*, and it is the one failure the retry loop actively makes worse: a disk
|
||||
/// does not drain during six seconds of backoff, so all three attempts fail identically while
|
||||
/// holding a compression permit that photos are queued behind.
|
||||
///
|
||||
/// The give-up path it fed was worse still. It refunded the guest's quota and soft-deleted the
|
||||
/// row while deliberately RETAINING the original — so the bytes stayed on the full disk, the
|
||||
/// photo vanished from the feed seconds after a `201 Created`, and the guest was handed back
|
||||
/// the quota to upload it again into the same full disk. Each round shrank free space further.
|
||||
pub fn is_storage_full_error(err: &anyhow::Error) -> bool {
|
||||
fn is_full(io: &std::io::Error) -> bool {
|
||||
// `StorageFull` is the portable classification; the raw ENOSPC catches the paths where
|
||||
// the OS error was never mapped to a named kind.
|
||||
io.kind() == std::io::ErrorKind::StorageFull || io.raw_os_error() == Some(28)
|
||||
}
|
||||
err.chain().any(|cause| {
|
||||
// `image` wraps the io error in its own variant rather than exposing it as a source,
|
||||
// so the plain downcast alone would miss every derivative-write failure.
|
||||
cause.downcast_ref::<std::io::Error>().is_some_and(is_full)
|
||||
|| matches!(
|
||||
cause.downcast_ref::<image::ImageError>(),
|
||||
Some(image::ImageError::IoError(io)) if is_full(io)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a decoder for `path` with the budget enforced, WITHOUT reading any pixels.
|
||||
///
|
||||
/// Single source of truth for "may this image be decoded at all": both the upload
|
||||
@@ -88,6 +118,40 @@ fn decoder_within_budget(path: &Path) -> Result<impl image::ImageDecoder> {
|
||||
Ok(decoder)
|
||||
}
|
||||
|
||||
/// Rough peak heap an image will cost to turn into derivatives, read from the HEADER only —
|
||||
/// no pixels are decoded. `None` when the header can't be read or the image is over budget
|
||||
/// (the caller is about to fail on it anyway).
|
||||
///
|
||||
/// Two terms, and the second is the one that surprises:
|
||||
///
|
||||
/// - the decoded buffer, `width * height * channels`; and
|
||||
/// - the resize intermediate. `image`'s Lanczos3 path accumulates in `f32`, so the buffer
|
||||
/// between the horizontal and vertical passes is `new_width * old_height * 4 channels * 4
|
||||
/// bytes` — 16 bytes per pixel-row-slot, not the 4 the output uses. For an 8000x8000
|
||||
/// original that is 262 MiB on top of a 244 MiB decode, measured. It is bigger than the
|
||||
/// decode for any tall image, which is why "the decode is bounded by max_alloc" was never
|
||||
/// the whole story.
|
||||
///
|
||||
/// Used to decide whether an image is heavy enough to need exclusive use of the box's memory
|
||||
/// headroom, NOT to reject anything.
|
||||
pub fn estimated_processing_peak_bytes(path: &Path, display_edge: u32) -> Option<u64> {
|
||||
let decoder = decoder_within_budget(path).ok()?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
let decoded = decoder.total_bytes();
|
||||
|
||||
// Aspect-preserving fit into `display_edge`, matching DynamicImage::resize. No downscale
|
||||
// means no intermediate at all.
|
||||
let intermediate = if width > display_edge || height > display_edge {
|
||||
let ratio = f64::from(display_edge) / f64::from(width.max(height));
|
||||
let new_width = (f64::from(width) * ratio).round().max(1.0) as u64;
|
||||
new_width * u64::from(height) * 16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Some(decoded.saturating_add(intermediate))
|
||||
}
|
||||
|
||||
/// Megapixels an image would decode to, or `None` if its header can't be read. Used only
|
||||
/// to put a concrete number in the message the guest sees.
|
||||
pub fn megapixels(path: &Path) -> Option<f64> {
|
||||
@@ -141,6 +205,32 @@ pub fn decode_oriented(path: &Path) -> Result<DynamicImage> {
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Process-wide serialisation for memory-heavy image work.
|
||||
///
|
||||
/// The `app` container gets 1 GiB. A single 8000x8000 original measures ~516 MiB peak even with
|
||||
/// the decode correctly scoped, so two overlapping giants is an OOM kill — and the kernel kills
|
||||
/// the whole process, dropping every SSE stream and stranding every in-flight upload.
|
||||
///
|
||||
/// GLOBAL rather than a field on `CompressionWorker`, because the constraint is the container's
|
||||
/// memory and there is more than one producer of this work. The export's own image path
|
||||
/// (`services::export`) decodes and resizes every photo in the gallery — a thumbnail for each,
|
||||
/// plus a 2000px re-encode for every original over 5 MB — and it ran in a bare `spawn_blocking`
|
||||
/// with no permit at all. So "host taps Freigeben while the last phone photos are still
|
||||
/// compressing" put an export decode and a heavy compression job in the same cgroup at the same
|
||||
/// time, which is the scenario the permit exists to make impossible. Worse, it is self-repeating:
|
||||
/// the OOM kill marks the export failed, and `recover_exports` re-spawns it on boot into the same
|
||||
/// conditions.
|
||||
///
|
||||
/// Held across the blocking section and released on drop, including on error.
|
||||
pub static HEAVY_IMAGE_PERMITS: std::sync::LazyLock<tokio::sync::Semaphore> =
|
||||
std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(1));
|
||||
|
||||
/// Estimated peak heap above which a job must take [`HEAVY_IMAGE_PERMITS`].
|
||||
///
|
||||
/// 150 MiB sits far above a normal phone photo (a 12 MP JPEG costs ~50 MiB all-in) so the common
|
||||
/// path never serialises, and far below the point where two jobs stop fitting in the container.
|
||||
pub const HEAVY_IMAGE_BYTES: u64 = 150 * 1024 * 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -189,9 +279,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_plain_io_error_is_not_permanent() {
|
||||
// The mirror that keeps the classifier honest. ENOSPC while writing a derivative, or
|
||||
// EMFILE under load, is exactly what the retry exists for — misclassifying those as
|
||||
// The mirror that keeps the classifier honest. EMFILE under load, or a momentary
|
||||
// unreadable file, is exactly what the retry exists for — misclassifying those as
|
||||
// permanent would turn a transient blip back into the data loss round 1 fixed.
|
||||
// (A FULL disk is its own case now; see the storage-full tests below.)
|
||||
let err = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
||||
.map(|img| (img.width(), img.height()))
|
||||
.expect_err("a missing file must error");
|
||||
@@ -201,6 +292,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_disk_is_recognised_through_both_wrappers() {
|
||||
// The two shapes ENOSPC actually arrives in. A bare io::Error is what `tokio::fs` and
|
||||
// `std::fs` produce; the `image` crate wraps its own in `ImageError::IoError`, which is
|
||||
// NOT reachable via `source()` — so a chain walk that only downcast to io::Error would
|
||||
// miss every derivative-write failure, i.e. the exact case this classifier exists for.
|
||||
let bare = anyhow::Error::from(std::io::Error::from(std::io::ErrorKind::StorageFull))
|
||||
.context("failed to write the preview");
|
||||
assert!(is_storage_full_error(&bare), "bare io::Error: {bare:#}");
|
||||
|
||||
let wrapped = anyhow::Error::from(image::ImageError::IoError(std::io::Error::from(
|
||||
std::io::ErrorKind::StorageFull,
|
||||
)))
|
||||
.context("failed to save the display derivative");
|
||||
assert!(
|
||||
is_storage_full_error(&wrapped),
|
||||
"ImageError::IoError: {wrapped:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_io_error_is_not_a_full_disk() {
|
||||
// Keeps the classifier from swallowing the general case: only ENOSPC may skip the retry
|
||||
// and take the keep-the-row branch. Anything else must still be retried and, if it keeps
|
||||
// failing, soft-deleted as before.
|
||||
let missing = decode_oriented(Path::new("/nonexistent/definitely-not-here.jpg"))
|
||||
.map(|img| (img.width(), img.height()))
|
||||
.expect_err("a missing file must error");
|
||||
assert!(
|
||||
!is_storage_full_error(&missing),
|
||||
"a missing file is not a full disk: {missing:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_only_the_over_budget_case() {
|
||||
// Admission and processing must agree about SIZE — a photo accepted at the door and
|
||||
|
||||
@@ -120,6 +120,19 @@ pub async fn startup_recovery(pool: &PgPool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a file in `originals/` may exist without a database row before it is treated as
|
||||
/// abandoned.
|
||||
///
|
||||
/// This window is the ONLY thing making the sweep safe, because the upload handler renames the
|
||||
/// temp file into its final path BEFORE committing the row: for a short moment a perfectly
|
||||
/// healthy upload legitimately looks exactly like an orphan. Six hours is far beyond any live
|
||||
/// request (a 576 MiB body over a bad venue uplink is minutes, and the request itself is bounded
|
||||
/// by the reverse proxy) while still reclaiming the leak inside a single event.
|
||||
///
|
||||
/// DO NOT SHORTEN THIS to make a test faster — a value below the longest possible in-flight
|
||||
/// upload deletes photos out from under the request that is committing them.
|
||||
const ORPHAN_UPLOAD_RETENTION_HOURS: u64 = 6;
|
||||
|
||||
/// Spawns a background task that periodically:
|
||||
/// - deletes session rows whose `expires_at` is more than a day in the past
|
||||
/// - prunes the in-memory rate-limiter HashMap of empty windows
|
||||
@@ -132,20 +145,143 @@ pub fn spawn_periodic_tasks(
|
||||
sse_tickets: SseTicketStore,
|
||||
media_path: PathBuf,
|
||||
) {
|
||||
// Supervised, because this one task carries EVERY piece of recurring hygiene in the app:
|
||||
// session pruning, media reclamation, the orphan-temp sweep, and the rate-limiter and
|
||||
// SSE-ticket maps. As a bare `tokio::spawn` with no retained handle, a single panic anywhere
|
||||
// inside it stopped all five permanently and silently — no log line, no symptom until the
|
||||
// disk or a HashMap grew into one. The supervisor re-spawns and, just as importantly, says
|
||||
// so; it can never spin hot because the inner loop only returns by dying.
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||
// Fire the first tick immediately, then hourly.
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
cleanup_sessions(&pool).await;
|
||||
cleanup_deleted_media(&pool, &media_path).await;
|
||||
rate_limiter.prune();
|
||||
sse_tickets.prune();
|
||||
let inner = tokio::spawn(periodic_loop(
|
||||
pool.clone(),
|
||||
rate_limiter.clone(),
|
||||
sse_tickets.clone(),
|
||||
media_path.clone(),
|
||||
));
|
||||
match inner.await {
|
||||
Ok(()) => tracing::error!("periodic maintenance loop returned; restarting it"),
|
||||
Err(e) => {
|
||||
tracing::error!(error = ?e, "periodic maintenance task died; restarting it")
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The actual hygiene loop. Never returns in normal operation — see the supervisor above.
|
||||
async fn periodic_loop(
|
||||
pool: PgPool,
|
||||
rate_limiter: RateLimiter,
|
||||
sse_tickets: SseTicketStore,
|
||||
media_path: PathBuf,
|
||||
) {
|
||||
// A crash left whatever the previous process was mid-upload behind, and the first periodic
|
||||
// tick is an hour away — sweep once up front so a restart is also a cleanup.
|
||||
sweep_orphan_upload_temps(&media_path).await;
|
||||
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||
// Fire the first tick immediately, then hourly.
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
cleanup_sessions(&pool).await;
|
||||
cleanup_deleted_media(&pool, &media_path).await;
|
||||
sweep_orphan_upload_temps(&media_path).await;
|
||||
// Runs AFTER the .tmp sweep, and covers the class that one structurally cannot see:
|
||||
// an original that was renamed to its final name but whose transaction never
|
||||
// committed. Those have no row, so `cleanup_deleted_media` (row-driven) can never
|
||||
// find them, and `sweep_orphan_upload_temps` skips them because they no longer end
|
||||
// in `.tmp` — they were permanently unowned, silently shrinking the free disk that
|
||||
// `compute_storage_quota` divides among guests.
|
||||
sweep_orphan_originals(&pool, &media_path).await;
|
||||
rate_limiter.prune();
|
||||
sse_tickets.prune();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long an upload's `.tmp` file must have been untouched before it is treated as abandoned.
|
||||
///
|
||||
/// This is an age on the MODIFICATION time, not on creation, and that is what makes an hour
|
||||
/// safe rather than reckless: a live upload is being written to continuously, so its mtime keeps
|
||||
/// advancing and it can never age into the sweep no matter how slow the connection. The clock
|
||||
/// only starts once the writer stops — i.e. once the upload is genuinely dead.
|
||||
const ORPHAN_TEMP_MAX_AGE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Reclaim `.tmp` files left in the media tree by uploads that never finished.
|
||||
///
|
||||
/// `stream_field_to_file` removes its temp file on every error return, which covers everything
|
||||
/// the handler can see. It cannot cover the case that actually happens at a party: the client
|
||||
/// simply goes away — a phone sleeps, a guest walks out of range, the PWA is evicted mid-video —
|
||||
/// and axum DROPS the handler future rather than returning an error, so no cleanup code runs at
|
||||
/// all. The shutdown backstop force-exits in-flight handlers for the same net effect.
|
||||
///
|
||||
/// Nothing else reclaims these. `cleanup_deleted_media` only visits rows with `deleted_at`, and
|
||||
/// an abandoned upload never got a row; `export::sweep_orphan_temps` is only ever pointed at the
|
||||
/// exports volume. So before this, every abandonment stranded up to `max_video_size_mb` of
|
||||
/// unowned bytes permanently — and worse than merely leaking, they were subtracted from what
|
||||
/// everyone else could upload, because the per-user quota is computed from live free disk
|
||||
/// (`compute_storage_quota`). On a 40 GB disk shared with `postgres_data`, an evening of flaky
|
||||
/// venue wifi could take the event down.
|
||||
async fn sweep_orphan_upload_temps(media_path: &std::path::Path) {
|
||||
let originals = media_path.join("originals");
|
||||
let mut event_dirs = match tokio::fs::read_dir(&originals).await {
|
||||
Ok(d) => d,
|
||||
// Absent before the first upload — not a problem worth logging every hour.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, path = %originals.display(), "orphan temp sweep: unreadable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut reclaimed = 0usize;
|
||||
let mut bytes = 0u64;
|
||||
while let Ok(Some(event_dir)) = event_dirs.next_entry().await {
|
||||
let Ok(mut files) = tokio::fs::read_dir(event_dir.path()).await else {
|
||||
continue;
|
||||
};
|
||||
while let Ok(Some(file)) = files.next_entry().await {
|
||||
let path = file.path();
|
||||
if path.extension().is_none_or(|e| e != "tmp") {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = file.metadata().await else {
|
||||
continue;
|
||||
};
|
||||
// No mtime (or a clock that moved backwards) means we cannot show the file is
|
||||
// abandoned, and deleting a live upload is far worse than leaking one temp file.
|
||||
let abandoned = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| m.elapsed().ok())
|
||||
.is_some_and(|age| age >= ORPHAN_TEMP_MAX_AGE);
|
||||
if !abandoned {
|
||||
continue;
|
||||
}
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => {
|
||||
reclaimed += 1;
|
||||
bytes += meta.len();
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, path = %path.display(),
|
||||
"orphan temp sweep: could not reclaim")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reclaimed > 0 {
|
||||
tracing::info!(
|
||||
"orphan temp sweep: reclaimed {reclaimed} abandoned upload temp file(s), {} MiB",
|
||||
bytes / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaim the media of soft-deleted uploads once they are past their retention window.
|
||||
///
|
||||
/// ONLY ever touches rows with `deleted_at IS NOT NULL`, so it can never reach a live upload. Two
|
||||
@@ -262,3 +398,192 @@ async fn cleanup_sessions(pool: &PgPool) {
|
||||
Err(e) => tracing::warn!("session cleanup failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaim files in `originals/` that no upload row references.
|
||||
///
|
||||
/// The backstop behind [`TempFileGuard`](crate::handlers::upload). The guard covers the
|
||||
/// process that is running; this covers the process that was killed — a SIGKILL, an OOM, or a
|
||||
/// power cut leaves whatever bytes had been written with no `Drop` to reclaim them, and those
|
||||
/// files are then permanently invisible: they have no row, so `cleanup_deleted_media` (which is
|
||||
/// row-driven) can never see them, and they are not counted against any quota while still
|
||||
/// consuming the free disk that `compute_storage_quota` divides among guests. On a single box
|
||||
/// where all three volumes share a filesystem, that ends with Postgres unable to write WAL.
|
||||
///
|
||||
/// Two classes:
|
||||
/// - `*.tmp` — an upload that never got as far as being renamed. Always safe past the window.
|
||||
/// - everything else — a final-named original whose commit never happened.
|
||||
async fn sweep_orphan_originals(pool: &PgPool, media_path: &std::path::Path) {
|
||||
let originals = media_path.join("originals");
|
||||
let cutoff = Duration::from_secs(ORPHAN_UPLOAD_RETENTION_HOURS * 3600);
|
||||
|
||||
// originals/{event_slug}/{uuid}.{ext} — one level of per-event directories.
|
||||
let mut event_dirs = match tokio::fs::read_dir(&originals).await {
|
||||
Ok(rd) => rd,
|
||||
// Nothing uploaded yet; the directory is created lazily by the upload handler.
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut candidates: Vec<(String, std::path::PathBuf)> = Vec::new();
|
||||
let mut temps_removed = 0u32;
|
||||
|
||||
while let Ok(Some(event_dir)) = event_dirs.next_entry().await {
|
||||
if !event_dir
|
||||
.file_type()
|
||||
.await
|
||||
.map(|t| t.is_dir())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let slug = event_dir.file_name().to_string_lossy().to_string();
|
||||
let Ok(mut files) = tokio::fs::read_dir(event_dir.path()).await else {
|
||||
continue;
|
||||
};
|
||||
while let Ok(Some(entry)) = files.next_entry().await {
|
||||
let Ok(meta) = entry.metadata().await else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
// Too young to judge: an upload committing RIGHT NOW is indistinguishable from an
|
||||
// orphan, because the rename precedes the commit.
|
||||
let recent = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| m.elapsed().ok())
|
||||
.is_none_or(|age| age < cutoff);
|
||||
if recent {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(".tmp") {
|
||||
// A `.tmp` never has a row by construction — no DB check needed.
|
||||
if tokio::fs::remove_file(entry.path()).await.is_ok() {
|
||||
temps_removed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
candidates.push((format!("originals/{slug}/{name}"), entry.path()));
|
||||
}
|
||||
}
|
||||
|
||||
if temps_removed > 0 {
|
||||
tracing::warn!(
|
||||
"reclaimed {temps_removed} abandoned upload temp file(s) older than \
|
||||
{ORPHAN_UPLOAD_RETENTION_HOURS}h"
|
||||
);
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// One query per batch, not one per file: a backlog of thousands of orphans must not turn
|
||||
// into thousands of round trips on an hourly timer.
|
||||
let mut orphans_removed = 0u32;
|
||||
for chunk in candidates.chunks(500) {
|
||||
let paths: Vec<String> = chunk.iter().map(|(rel, _)| rel.clone()).collect();
|
||||
// NO `deleted_at IS NULL` FILTER HERE. A soft-deleted row still points at its file
|
||||
// during its retention window, and reclaiming that file is `cleanup_deleted_media`'s
|
||||
// job — filtering here would race the two sweeps and destroy the exact files the
|
||||
// recovery window exists to preserve.
|
||||
let unreferenced: Result<Vec<(String,)>, _> = sqlx::query_as(
|
||||
"SELECT p FROM unnest($1::text[]) AS p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM upload u WHERE u.original_path = p)",
|
||||
)
|
||||
.bind(&paths)
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
let unreferenced = match unreferenced {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "orphan-original sweep query failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (rel,) in unreferenced {
|
||||
if let Some((_, abs)) = chunk.iter().find(|(r, _)| *r == rel)
|
||||
&& tokio::fs::remove_file(abs).await.is_ok()
|
||||
{
|
||||
orphans_removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if orphans_removed > 0 {
|
||||
tracing::warn!(
|
||||
"reclaimed {orphans_removed} original(s) with no upload row, older than \
|
||||
{ORPHAN_UPLOAD_RETENTION_HOURS}h"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build `<root>/originals/<event>/<name>` with `len` bytes, optionally back-dating its mtime
|
||||
/// by `age`. Back-dating is the only way to test the sweep without sleeping through an hour.
|
||||
fn temp_file(root: &std::path::Path, name: &str, len: usize, age: Option<Duration>) {
|
||||
let dir = root.join("originals").join("wedding");
|
||||
std::fs::create_dir_all(&dir).expect("create dir");
|
||||
let path = dir.join(name);
|
||||
let f = std::fs::File::create(&path).expect("create file");
|
||||
std::io::Write::write_all(&mut &f, &vec![0u8; len]).expect("write");
|
||||
if let Some(age) = age {
|
||||
let when = std::time::SystemTime::now() - age;
|
||||
f.set_modified(when).expect("set mtime");
|
||||
}
|
||||
}
|
||||
|
||||
fn exists(root: &std::path::Path, name: &str) -> bool {
|
||||
root.join("originals").join("wedding").join(name).exists()
|
||||
}
|
||||
|
||||
/// The two halves of the guarantee in one pass: an abandoned temp is reclaimed, and a temp
|
||||
/// that is still being written to is NOT — the second matters more, because deleting a live
|
||||
/// upload's temp file would corrupt a photo that was about to succeed.
|
||||
#[tokio::test]
|
||||
async fn the_sweep_reclaims_abandoned_temps_and_spares_live_ones() {
|
||||
let root = std::env::temp_dir().join(format!("es-sweep-{}", uuid::Uuid::new_v4()));
|
||||
|
||||
// Abandoned: the writer died over an hour ago and nothing has touched it since.
|
||||
temp_file(
|
||||
&root,
|
||||
"dead.tmp",
|
||||
2048,
|
||||
Some(ORPHAN_TEMP_MAX_AGE + Duration::from_secs(60)),
|
||||
);
|
||||
// Live: an upload in progress keeps advancing its mtime, so it always looks young —
|
||||
// this is why the threshold is on modification time and not on creation time.
|
||||
temp_file(&root, "inflight.tmp", 2048, None);
|
||||
// A committed original. The sweep must only ever consider `.tmp`.
|
||||
temp_file(&root, "keeper.jpg", 2048, Some(Duration::from_secs(86_400)));
|
||||
|
||||
sweep_orphan_upload_temps(&root).await;
|
||||
|
||||
assert!(
|
||||
!exists(&root, "dead.tmp"),
|
||||
"an abandoned temp must be reclaimed"
|
||||
);
|
||||
assert!(
|
||||
exists(&root, "inflight.tmp"),
|
||||
"a temp still being written to must survive — deleting it destroys a live upload"
|
||||
);
|
||||
assert!(
|
||||
exists(&root, "keeper.jpg"),
|
||||
"the sweep must never touch a committed original"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Runs on every boot and every hour, so a media tree that does not exist yet (before the
|
||||
/// first upload) must be a silent no-op rather than an error logged 24 times a day.
|
||||
#[tokio::test]
|
||||
async fn a_missing_media_tree_is_not_an_error() {
|
||||
let root = std::env::temp_dir().join(format!("es-sweep-absent-{}", uuid::Uuid::new_v4()));
|
||||
sweep_orphan_upload_temps(&root).await; // must simply return
|
||||
}
|
||||
}
|
||||
|
||||
104
backend/src/services/media_total.rs
Normal file
104
backend/src/services/media_total.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Cached sum of all media bytes the event is holding.
|
||||
//!
|
||||
//! The upload gate needs to know "how big would the keepsake be if we accept this file", because
|
||||
//! the archive needs room for BOTH halves at once (`export::required_free_bytes` is
|
||||
//! `media × 1.1 × 2` — the ZIP and the HTML viewer are each gallery-sized). Asking that question
|
||||
//! per upload has to be cheap, and it has to be cheap on the busiest write path in the app.
|
||||
//!
|
||||
//! `export::estimate_export_bytes` answers the same question exactly, but it aggregates
|
||||
//! `original_size_bytes` across every upload row joined to `user` — fine once per release,
|
||||
//! wasteful per upload and growing all evening. This sums `user.total_upload_bytes` instead:
|
||||
//! one row per guest (~100), already maintained transactionally by the quota path, already
|
||||
//! refunded on delete.
|
||||
//!
|
||||
//! The two differ slightly — this one counts uploads belonging to banned or hidden users, which
|
||||
//! the export filters out. That skew is in the SAFE direction: it over-estimates the archive, so
|
||||
//! the gate closes marginally early rather than marginally late. Never swap it for a cheaper
|
||||
//! query that could under-estimate; an under-estimate authorises the very upload that makes the
|
||||
//! keepsake unbuildable, which is the failure this exists to prevent.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// How long a reading is trusted. Shorter than [`crate::services::disk`]'s TTL because this
|
||||
/// number only ever grows and does so in the same request path that reads it — a stale value
|
||||
/// under-counts the newest uploads, and under-counting is the direction that matters.
|
||||
const TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Cheap-to-clone cache of the event's total media bytes. Lives in `AppState`.
|
||||
#[derive(Clone)]
|
||||
pub struct MediaTotalCache {
|
||||
inner: Arc<RwLock<Option<(i64, Instant)>>>,
|
||||
}
|
||||
|
||||
impl MediaTotalCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the cached reading so the next `get()` re-queries.
|
||||
///
|
||||
/// Used by the e2e TRUNCATE endpoint for the same reason `DiskCache::invalidate` exists:
|
||||
/// truncation removes every upload, and a surviving reading would make the next test's
|
||||
/// gate compute against the previous test's data.
|
||||
pub fn invalidate(&self) {
|
||||
*self.inner.write().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Total bytes of media the event is holding, cached for [`TTL`].
|
||||
///
|
||||
/// Returns 0 when the query fails. That is a deliberate FAIL-OPEN, consistent with the
|
||||
/// quota path and the export preflight: a database blip must not turn into "every upload
|
||||
/// refused". The disk-space half of the gate still applies, so a failure here degrades the
|
||||
/// check to the old flat-reserve behaviour rather than disabling it.
|
||||
pub async fn get(&self, pool: &PgPool) -> i64 {
|
||||
if let Some((bytes, at)) = *self.inner.read().unwrap()
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
let queried = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT SUM(total_upload_bytes)::bigint FROM \"user\"",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
|
||||
let bytes = match queried {
|
||||
Ok(v) => v.unwrap_or(0).max(0),
|
||||
Err(e) => {
|
||||
// FAIL OPEN, but do NOT cache the failure, and do NOT let it pass silently.
|
||||
//
|
||||
// Storing 0 here pinned the gate's view of the event at "empty" for the whole
|
||||
// TTL. During that window `media_after` is just this upload, `keepsake_needs`
|
||||
// collapses to ~2.2x one file, and the gate degrades to the flat 10 GB reserve —
|
||||
// precisely the behaviour the two-halves design replaced, reappearing with no
|
||||
// trace in the log. And the trigger correlates with the danger: with
|
||||
// `max_connections = 10` and a 5s acquire timeout, this query fails exactly when
|
||||
// a burst is in progress.
|
||||
//
|
||||
// Falling back to the LAST GOOD reading (however stale) is strictly better than
|
||||
// 0: the total only ever grows, so a stale value under-counts slightly, while 0
|
||||
// under-counts by everything.
|
||||
let previous = self.inner.read().unwrap().map(|(b, _)| b);
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
fallback_bytes = previous.unwrap_or(0),
|
||||
"media total query failed; upload gate is running on a stale reading"
|
||||
);
|
||||
return previous.unwrap_or(0);
|
||||
}
|
||||
};
|
||||
*self.inner.write().unwrap() = Some((bytes, Instant::now()));
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaTotalCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,8 @@ pub mod disk;
|
||||
pub mod export;
|
||||
pub mod imaging;
|
||||
pub mod maintenance;
|
||||
pub mod media_total;
|
||||
pub mod rate_limiter;
|
||||
pub mod sse_tickets;
|
||||
pub mod upload_admission;
|
||||
pub mod video;
|
||||
|
||||
@@ -40,8 +40,18 @@ impl RateLimiter {
|
||||
timestamps.push(now);
|
||||
Ok(())
|
||||
} else {
|
||||
// The oldest timestamp expires at oldest + window; compute remaining seconds
|
||||
let oldest = timestamps[0];
|
||||
// The oldest timestamp expires at oldest + window; compute remaining seconds.
|
||||
//
|
||||
// `first()`, not `[0]`: with `max == 0` the length check above is false even on an
|
||||
// empty vec, so indexing would panic — WHILE HOLDING THIS MUTEX. That poisons it
|
||||
// process-wide, so every subsequent `.lock().unwrap()` panics too: upload, feed,
|
||||
// join, recover, social, export and the hourly maintenance task all die, and only
|
||||
// a container restart brings them back. `max == 0` is not reachable through the
|
||||
// admin API (every numeric spec has min = 1) but a direct DB edit would do it, and
|
||||
// the blast radius does not justify the sharper syntax.
|
||||
let Some(&oldest) = timestamps.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
let elapsed = now.duration_since(oldest);
|
||||
let remaining = window.saturating_sub(elapsed);
|
||||
Err(remaining.as_secs().max(1))
|
||||
|
||||
@@ -13,6 +13,36 @@ use rand::Rng;
|
||||
/// stream open. Tickets are consumed on use and expire after `TTL`.
|
||||
const TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Ceiling on outstanding tickets across the whole process.
|
||||
///
|
||||
/// Not really about the bytes (~120 each) — about `issue` having had no bound of any kind.
|
||||
/// Sized well above a real event: ~1000 concurrent clients each holding one live 30 s ticket.
|
||||
const MAX_TICKETS: usize = 4096;
|
||||
|
||||
/// Live tickets one session may hold. Above 1 because two tabs sharing a token open their
|
||||
/// EventSources concurrently; 4 absorbs that without letting a reconnect loop accumulate.
|
||||
const MAX_TICKETS_PER_SESSION: usize = 4;
|
||||
|
||||
/// What a ticket may be redeemed for.
|
||||
///
|
||||
/// The store began life serving only SSE and stayed untyped when the export download started
|
||||
/// reusing it, which silently made the two interchangeable. That is not a theoretical mixing
|
||||
/// concern: `POST /stream/ticket` is rate-limited at 60/min per user and charges nothing, while
|
||||
/// `POST /export/ticket` charges one of three PER-DAY downloads. An untyped ticket let any guest
|
||||
/// mint at the cheap endpoint and redeem at the expensive one, so the daily export limit was
|
||||
/// bypassable ~60×/minute — each redemption streaming the whole multi-GB keepsake, `no-store`,
|
||||
/// off the same filesystem Postgres writes WAL to.
|
||||
///
|
||||
/// `consume` therefore requires the kind to MATCH. A ticket is only ever valid for the thing it
|
||||
/// was minted for.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum TicketKind {
|
||||
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
|
||||
Sse,
|
||||
/// Downloads an export archive (`GET /export/{zip,html}`). Expensive, rate-limited per day.
|
||||
Download,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SseTicketStore {
|
||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||
@@ -22,6 +52,7 @@ pub struct SseTicketStore {
|
||||
struct Entry {
|
||||
token_hash: String,
|
||||
issued_at: Instant,
|
||||
kind: TicketKind,
|
||||
}
|
||||
|
||||
impl SseTicketStore {
|
||||
@@ -39,28 +70,80 @@ impl SseTicketStore {
|
||||
}
|
||||
|
||||
/// Mint a new ticket bound to the caller's session (identified by token hash).
|
||||
pub fn issue(&self, token_hash: String) -> String {
|
||||
///
|
||||
/// `None` when the store is at capacity — the caller should answer 503, not evict.
|
||||
///
|
||||
/// Three bounds, because `issue` had none: no size cap, no per-caller cap, and no rate
|
||||
/// limit on the endpoint, while `prune` ran only hourly against a 30-second TTL. So any
|
||||
/// authenticated session could loop the endpoint and grow the map for an hour.
|
||||
pub fn issue(&self, token_hash: String, kind: TicketKind) -> Option<String> {
|
||||
let ticket = random_ticket();
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
|
||||
// Prune on issue rather than only hourly. This alone changes the bound from "tickets
|
||||
// minted since the last maintenance tick" to "tickets live at once", which is what the
|
||||
// 30 s TTL was always meant to express.
|
||||
map.retain(|_, e| e.issued_at.elapsed() <= TTL);
|
||||
|
||||
// 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.
|
||||
let mut mine: Vec<(String, Instant)> = map
|
||||
.iter()
|
||||
.filter(|(_, e)| e.token_hash == token_hash)
|
||||
.map(|(k, e)| (k.clone(), e.issued_at))
|
||||
.collect();
|
||||
if mine.len() >= MAX_TICKETS_PER_SESSION {
|
||||
mine.sort_by_key(|(_, issued)| *issued);
|
||||
for (key, _) in mine.iter().take(mine.len() - MAX_TICKETS_PER_SESSION + 1) {
|
||||
map.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// At capacity, REFUSE — never evict a stranger's ticket. Evicting would let one
|
||||
// misbehaving client deny SSE to the whole venue, which is worse than failing the
|
||||
// request that hit the ceiling.
|
||||
if map.len() >= MAX_TICKETS {
|
||||
tracing::warn!(
|
||||
outstanding = map.len(),
|
||||
"SSE ticket store at capacity; refusing to mint"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
map.insert(
|
||||
ticket.clone(),
|
||||
Entry {
|
||||
token_hash,
|
||||
issued_at: Instant::now(),
|
||||
kind,
|
||||
},
|
||||
);
|
||||
ticket
|
||||
Some(ticket)
|
||||
}
|
||||
|
||||
/// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is
|
||||
/// not expired. Single-use: the ticket is removed regardless of whether it
|
||||
/// was still fresh, so a replay can't slip through after expiry.
|
||||
pub fn consume(&self, ticket: &str) -> Option<String> {
|
||||
/// Consume a ticket minted for `kind`. Returns `Some(token_hash)` if the ticket exists, is
|
||||
/// not expired, and was minted for this purpose. Single-use: the ticket is removed regardless
|
||||
/// of whether it was still fresh, so a replay can't slip through after expiry.
|
||||
///
|
||||
/// A ticket of the WRONG kind is also removed. It was a valid ticket the caller legitimately
|
||||
/// held, so this is not punitive — but leaving it would let a redemption loop probe the store
|
||||
/// without ever spending anything, and the client has no legitimate reason to present a
|
||||
/// ticket at the wrong endpoint.
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
if entry.kind != kind {
|
||||
tracing::warn!(
|
||||
expected = ?kind,
|
||||
found = ?entry.kind,
|
||||
"ticket presented at the wrong endpoint; rejected"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(entry.token_hash)
|
||||
}
|
||||
|
||||
@@ -84,14 +167,57 @@ fn random_ticket() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `issue` now returns `Option`; in every test below the store is far from capacity, so an
|
||||
/// `expect` here documents that refusing is exceptional rather than routine.
|
||||
fn issue(store: &SseTicketStore, hash: &str) -> String {
|
||||
store
|
||||
.issue(hash.into(), TicketKind::Sse)
|
||||
.expect("store has capacity")
|
||||
}
|
||||
|
||||
/// The store is shared by two endpoints with wildly different costs: `/stream/ticket` is
|
||||
/// 60/min per user and free, `/export/ticket` charges one of three PER-DAY downloads. While
|
||||
/// entries were untyped, a ticket minted at the cheap endpoint opened the expensive one — so
|
||||
/// the daily export limit could be bypassed ~60×/minute, each redemption streaming the whole
|
||||
/// multi-GB keepsake off the disk Postgres writes WAL to.
|
||||
///
|
||||
/// Asserted in BOTH directions so this cannot be "fixed" by a check that only guards one.
|
||||
#[test]
|
||||
fn a_ticket_is_only_valid_for_the_purpose_it_was_minted_for() {
|
||||
let store = SseTicketStore::new();
|
||||
|
||||
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&sse, TicketKind::Download),
|
||||
None,
|
||||
"an SSE ticket must not open the export download"
|
||||
);
|
||||
|
||||
let dl = store.issue("h".into(), TicketKind::Download).unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&dl, TicketKind::Sse),
|
||||
None,
|
||||
"a download ticket must not open the SSE stream"
|
||||
);
|
||||
|
||||
// And the matching cases still work, so the guard is not simply rejecting everything.
|
||||
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
|
||||
assert_eq!(store.consume(&sse, TicketKind::Sse).as_deref(), Some("h"));
|
||||
let dl = store.issue("h".into(), TicketKind::Download).unwrap();
|
||||
assert_eq!(
|
||||
store.consume(&dl, TicketKind::Download).as_deref(),
|
||||
Some("h")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_then_consume_returns_the_hash_exactly_once() {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = store.issue("hash-1".into());
|
||||
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
|
||||
let ticket = issue(&store, "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),
|
||||
store.consume(&ticket, TicketKind::Sse),
|
||||
None,
|
||||
"a consumed ticket must not be reusable"
|
||||
);
|
||||
@@ -100,14 +226,14 @@ mod tests {
|
||||
#[test]
|
||||
fn unknown_ticket_consumes_to_none() {
|
||||
let store = SseTicketStore::new();
|
||||
assert_eq!(store.consume("never-issued"), None);
|
||||
assert_eq!(store.consume("never-issued", TicketKind::Sse), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issued_tickets_are_unique_and_hex() {
|
||||
let store = SseTicketStore::new();
|
||||
let a = store.issue("h".into());
|
||||
let b = store.issue("h".into());
|
||||
let a = issue(&store, "h");
|
||||
let b = issue(&store, "h");
|
||||
assert_ne!(a, b, "each ticket must be unique");
|
||||
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
@@ -116,29 +242,106 @@ mod tests {
|
||||
#[test]
|
||||
fn fresh_ticket_survives_prune() {
|
||||
let store = SseTicketStore::new();
|
||||
let ticket = store.issue("h".into());
|
||||
let ticket = issue(&store, "h");
|
||||
store.prune(); // not expired → kept
|
||||
assert_eq!(store.consume(&ticket).as_deref(), Some("h"));
|
||||
assert_eq!(store.consume(&ticket, TicketKind::Sse).as_deref(), Some("h"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_ticket_consumes_to_none() {
|
||||
// Construct an entry that is already past the TTL and confirm consume() rejects it.
|
||||
let store = SseTicketStore::new();
|
||||
let stale = "stale-ticket".to_string();
|
||||
/// Build an entry that is already past the TTL.
|
||||
fn insert_stale(store: &SseTicketStore, key: &str, token_hash: &str) {
|
||||
store.inner.lock().unwrap().insert(
|
||||
stale.clone(),
|
||||
key.to_string(),
|
||||
Entry {
|
||||
token_hash: "h".into(),
|
||||
kind: TicketKind::Sse,
|
||||
token_hash: token_hash.into(),
|
||||
issued_at: Instant::now()
|
||||
.checked_sub(TTL + Duration::from_secs(1))
|
||||
.expect("host uptime should exceed the ticket TTL"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_ticket_consumes_to_none() {
|
||||
let store = SseTicketStore::new();
|
||||
insert_stale(&store, "stale-ticket", "h");
|
||||
assert_eq!(
|
||||
store.consume(&stale),
|
||||
store.consume("stale-ticket", TicketKind::Sse),
|
||||
None,
|
||||
"an expired ticket must not authenticate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The TTL is 30 s but `prune` only ran hourly, so the map was really bounded by "tickets
|
||||
/// minted in the last hour" — which is unbounded for a client in a loop.
|
||||
#[test]
|
||||
fn issuing_prunes_expired_entries() {
|
||||
let store = SseTicketStore::new();
|
||||
insert_stale(&store, "stale-a", "someone-else");
|
||||
insert_stale(&store, "stale-b", "someone-else");
|
||||
issue(&store, "h");
|
||||
assert_eq!(
|
||||
store.inner.lock().unwrap().len(),
|
||||
1,
|
||||
"issue must reclaim expired slots, not merely add to them"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two tabs sharing a token is normal, so the per-session cap must be above 1 — but a
|
||||
/// reconnect loop must not accumulate. The caller's OWN oldest is what gets evicted.
|
||||
#[test]
|
||||
fn a_session_is_capped_and_evicts_only_its_own_oldest() {
|
||||
let store = SseTicketStore::new();
|
||||
let stranger = issue(&store, "other-session");
|
||||
|
||||
let mut mine: Vec<String> = Vec::new();
|
||||
for _ in 0..MAX_TICKETS_PER_SESSION + 2 {
|
||||
mine.push(issue(&store, "mine"));
|
||||
}
|
||||
|
||||
let live = mine
|
||||
.iter()
|
||||
.filter(|t| store.inner.lock().unwrap().contains_key(*t))
|
||||
.count();
|
||||
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
|
||||
assert!(
|
||||
store.inner.lock().unwrap().contains_key(&mine[mine.len() - 1]),
|
||||
"the newest ticket is the one the caller is about to use"
|
||||
);
|
||||
assert_eq!(
|
||||
store.consume(&stranger, TicketKind::Sse).as_deref(),
|
||||
Some("other-session"),
|
||||
"another session's ticket must survive — evicting it would let one client deny \
|
||||
SSE to the venue"
|
||||
);
|
||||
}
|
||||
|
||||
/// At capacity the store REFUSES rather than evicting a stranger. Refusing fails the one
|
||||
/// request that hit the ceiling; evicting would break an unrelated client's live stream.
|
||||
#[test]
|
||||
fn at_capacity_the_store_refuses_instead_of_evicting() {
|
||||
let store = SseTicketStore::new();
|
||||
{
|
||||
let mut map = store.inner.lock().unwrap();
|
||||
for i in 0..MAX_TICKETS {
|
||||
map.insert(
|
||||
format!("filler-{i}"),
|
||||
Entry {
|
||||
kind: TicketKind::Sse,
|
||||
token_hash: format!("session-{i}"),
|
||||
issued_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
store.issue("newcomer".into(), TicketKind::Sse),
|
||||
None,
|
||||
"a full store must refuse, so the caller can answer 503"
|
||||
);
|
||||
assert!(
|
||||
store.inner.lock().unwrap().contains_key("filler-0"),
|
||||
"no existing ticket may be sacrificed to make room"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
155
backend/src/services/upload_admission.rs
Normal file
155
backend/src/services/upload_admission.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Admission control for upload bodies, budgeted in BYTES rather than requests.
|
||||
//!
|
||||
//! ## Why this has to exist
|
||||
//!
|
||||
//! The keepsake headroom gate in `handlers::upload` cannot bound a burst, and the reason is
|
||||
//! structural rather than a bug in the gate: the request body is streamed to a temp file during
|
||||
//! multipart parsing, so the bytes are already on disk by the time any check runs. The gate can
|
||||
//! only refuse to COMMIT them. Nothing upstream limited how many bodies stream at once — axum has
|
||||
//! no such limit, the tower stack is just `TraceLayer`, and Caddy passes requests straight
|
||||
//! through.
|
||||
//!
|
||||
//! So the failure mode is the ordinary one, not an attack: the ceremony ends, ~100 guests tap
|
||||
//! "upload all", and ~100 bodies stream concurrently. At phone-video sizes that is 10-20 GB of
|
||||
//! `.tmp` files on a 40 GB volume, none of it visible to the gate, and `DISK_RESERVE_BYTES` — the
|
||||
//! 10 GB standing between the party and Postgres losing the volume it writes WAL to — is consumed
|
||||
//! by transient files. The `.tmp` sweeper only reclaims files idle for an hour, correctly, which
|
||||
//! means nothing reclaims a burst on this timescale.
|
||||
//!
|
||||
//! ## Why bytes and not a request count
|
||||
//!
|
||||
//! A flat "N concurrent uploads" limit has to be sized for the worst case (a 500 MB video), which
|
||||
//! makes it absurdly restrictive for the common case (a 3 MB photo). Budgeting bytes lets one
|
||||
//! 500 MB video and two hundred photos coexist under the same ceiling, and it means the ceiling is
|
||||
//! stated in the unit the disk actually cares about.
|
||||
//!
|
||||
//! The reservation is the streaming CAP, not the real size — the real size is unknowable until the
|
||||
//! body has been read, which is far too late. Reserving the cap is deliberately pessimistic; that
|
||||
//! pessimism is the safety margin.
|
||||
//!
|
||||
//! ## Why a permit and not a counter
|
||||
//!
|
||||
//! `OwnedSemaphorePermit` releases on drop. Every path out of the upload handler — success, error,
|
||||
//! a client vanishing mid-body, a panic — therefore returns the reservation without any explicit
|
||||
//! bookkeeping. A hand-rolled `AtomicI64` would need a decrement on each of those paths, and the
|
||||
//! one that gets missed is the one that leaks the budget until restart.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
/// Total transient upload bytes allowed on disk at once, in MiB.
|
||||
///
|
||||
/// Sized against `DISK_RESERVE_BYTES` (10 GB): the reserve must survive a full burst with room to
|
||||
/// spare, since Postgres is writing WAL to the same filesystem throughout. 4 GiB leaves ~6 GB of
|
||||
/// the reserve untouched at the worst moment.
|
||||
///
|
||||
/// It is NOT a throughput limit. On 2 vCPU the box cannot usefully ingest more than this at once
|
||||
/// anyway — compression, ffmpeg, Postgres and TLS all contend for the same two cores — so the
|
||||
/// budget mostly converts "everything is slow and the disk fills" into "a few uploads wait".
|
||||
const BUDGET_MIB: u32 = 4096;
|
||||
|
||||
/// How long an upload waits for room before being told to come back.
|
||||
///
|
||||
/// Long enough to absorb the burst (a photo holds its reservation for well under a second), short
|
||||
/// enough that a guest is not left staring at a spinner. On timeout the handler answers 503 with
|
||||
/// `Retry-After`, which the client queue already treats as transient and retries with backoff.
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UploadAdmission {
|
||||
permits: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl UploadAdmission {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
permits: Arc::new(Semaphore::new(BUDGET_MIB as usize)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve room for a body capped at `cap_bytes`. The returned permit must be held for as long
|
||||
/// as the temp file exists.
|
||||
///
|
||||
/// `None` means the wait timed out and the caller should shed the request.
|
||||
///
|
||||
/// A cap larger than the whole budget is clamped rather than refused. Otherwise an operator
|
||||
/// raising `max_video_size_mb` above the budget would make `acquire_many` unsatisfiable and
|
||||
/// every video upload would hang until timeout — a config change silently disabling video for
|
||||
/// the event. Clamped, such an upload simply gets the whole budget to itself, which is the
|
||||
/// honest interpretation of "one file may fill the machine".
|
||||
pub async fn reserve(&self, cap_bytes: usize) -> Option<OwnedSemaphorePermit> {
|
||||
let mib = cap_bytes.div_ceil(1024 * 1024).max(1);
|
||||
let want = u32::try_from(mib).unwrap_or(BUDGET_MIB).min(BUDGET_MIB);
|
||||
match tokio::time::timeout(
|
||||
WAIT,
|
||||
self.permits.clone().acquire_many_owned(want),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(permit)) => Some(permit),
|
||||
// The semaphore is never closed, so `Err` here is unreachable in practice; treat it
|
||||
// the same as a timeout rather than panicking on the upload path.
|
||||
Ok(Err(_)) => None,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
requested_mib = want,
|
||||
"upload admission timed out; shedding to keep transient temp files bounded"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UploadAdmission {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The budget must actually block once exhausted — otherwise this whole module is decoration.
|
||||
#[tokio::test]
|
||||
async fn a_full_budget_sheds_instead_of_admitting() {
|
||||
let admission = UploadAdmission::new();
|
||||
let whole = admission
|
||||
.reserve(BUDGET_MIB as usize * 1024 * 1024)
|
||||
.await
|
||||
.expect("first reservation takes the whole budget");
|
||||
|
||||
// Nothing left: a second reservation must not be granted. Raced against a short timeout so
|
||||
// the test does not sit for the full WAIT.
|
||||
let blocked = tokio::time::timeout(
|
||||
Duration::from_millis(150),
|
||||
admission.reserve(1024 * 1024),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "budget exhausted, yet a reservation was granted");
|
||||
|
||||
// ...and releasing the permit makes room again, so the budget is not a one-way latch.
|
||||
drop(whole);
|
||||
assert!(
|
||||
admission.reserve(1024 * 1024).await.is_some(),
|
||||
"budget did not recover after the permit was dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cap above the whole budget must be clamped, not left unsatisfiable. Unclamped,
|
||||
/// `acquire_many` for more permits than exist never completes, so raising
|
||||
/// `max_video_size_mb` past the budget would silently hang every video upload for 20s and
|
||||
/// then shed it.
|
||||
#[tokio::test]
|
||||
async fn a_cap_larger_than_the_budget_is_clamped_rather_than_unsatisfiable() {
|
||||
let admission = UploadAdmission::new();
|
||||
let oversized = (BUDGET_MIB as usize + 4096) * 1024 * 1024;
|
||||
assert!(
|
||||
admission.reserve(oversized).await.is_some(),
|
||||
"an over-budget cap must still be admittable on an idle server"
|
||||
);
|
||||
}
|
||||
}
|
||||
263
backend/src/services/video.rs
Normal file
263
backend/src/services/video.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
//! Poster-frame extraction, shared by the compression worker and the HTML export.
|
||||
//!
|
||||
//! Both used to spawn `ffmpeg` themselves with the same broken invocation:
|
||||
//!
|
||||
//! ```text
|
||||
//! ffmpeg -i <src> -vframes 1 -ss 00:00:01 -vf scale=… -y <out>
|
||||
//! ```
|
||||
//!
|
||||
//! `-ss` AFTER `-i` is an output-side seek. Against a clip of a second or less ffmpeg exits **0 and
|
||||
//! writes nothing** — and both call sites gated on the exit status, so neither noticed. The worker
|
||||
//! then wrote `thumbnail_path` for a file that was never created (404 in the live feed) and the
|
||||
//! export listed the entry in `data.json` while the ZIP writer skipped it (a broken image tile in
|
||||
//! the keepsake). Every server-side signal stayed green. Phones produce such clips constantly:
|
||||
//! mis-taps, Live Photos, boomerangs.
|
||||
//!
|
||||
//! This module exists for the same reason `imaging.rs` does — that one was created when compression
|
||||
//! and export duplicated decode logic, and it paid off immediately when the `max_alloc` fix landed
|
||||
//! in both workers at once. Same duplication, same fix.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// A malformed video can hang `ffmpeg` indefinitely. In the compression worker that never releases
|
||||
/// the semaphore permit and the pool eventually deadlocks; in the export worker it strands the job
|
||||
/// at `running` so the keepsake never completes. `export.rs` had NO timeout at all before this
|
||||
/// module — sharing the spawn fixes that too.
|
||||
/// 45s, not the 120s this started at. The timeout is not a budget for honest work — a poster
|
||||
/// frame from a phone clip takes well under a second, and `-ss` before `-i` means even a 500 MB
|
||||
/// file seeks rather than scans. It is purely the ceiling on how long a pathological input may
|
||||
/// hold a compression permit that guests' photos are queued behind, so it should be as tight as
|
||||
/// it can be without ever cutting off real work.
|
||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Seek positions to try, in order.
|
||||
///
|
||||
/// One second first: the opening frame of a real video is often black, a fade-in, or motion-blurred
|
||||
/// as the camera settles, so it makes a poor poster. Zero second as the fallback, which is what
|
||||
/// makes short clips work — and it is genuinely required, not defensive. Moving `-ss` before `-i`
|
||||
/// (an input-side seek) is necessary but NOT sufficient: seeking to 1 s in a 1.000 s clip is still
|
||||
/// past the last frame, and ffmpeg still exits 0 having written nothing. Verified against the real
|
||||
/// production image.
|
||||
const SEEK_POSITIONS: &[&str] = &["00:00:01", "0"];
|
||||
|
||||
/// Extract one poster frame from `src` into `dest`, scaled to `width` px wide.
|
||||
///
|
||||
/// `Ok(false)` means the video yielded no frame — a normal outcome for a very short or unusual
|
||||
/// clip, NOT an error. Callers must degrade (no poster) rather than fail the upload: treating this
|
||||
/// as an error would soft-delete every sub-second video, turning a cosmetic defect into data loss.
|
||||
///
|
||||
/// `Err` is reserved for something genuinely wrong — a hang we had to kill, or a failure to spawn.
|
||||
pub async fn extract_poster_frame(src: &Path, dest: &Path, width: u32) -> Result<bool> {
|
||||
for seek in SEEK_POSITIONS {
|
||||
// A stale file from a previous attempt would be indistinguishable from a fresh success.
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
|
||||
run_ffmpeg(src, dest, width, seek).await?;
|
||||
|
||||
// THE CHECK BOTH CALL SITES WERE MISSING: ask the filesystem, not the exit status.
|
||||
// Non-empty, because a zero-byte file is not a poster either.
|
||||
if tokio::fs::metadata(dest)
|
||||
.await
|
||||
.map(|m| m.is_file() && m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Leave nothing behind for a caller to mistake for a result.
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Run one ffmpeg attempt. A non-zero exit is NOT an error here — the artifact check above is the
|
||||
/// authority, and a corrupt input that fails at 1 s may still yield a frame at 0.
|
||||
async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<()> {
|
||||
let child = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
// BEFORE -i: an input-side seek. See SEEK_POSITIONS.
|
||||
"-ss",
|
||||
seek,
|
||||
"-i",
|
||||
src.to_str().unwrap_or_default(),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
&format!("scale={width}:-1"),
|
||||
"-y",
|
||||
dest.to_str().unwrap_or_default(),
|
||||
])
|
||||
// ffmpeg writes the poster to `dest` itself; nothing here ever reads stdout, so
|
||||
// giving it a pipe only created something that could fill.
|
||||
.stdout(std::process::Stdio::null())
|
||||
// stderr IS piped — it is the only diagnostic when a clip yields no frame — but it
|
||||
// must be DRAINED, which is the whole point of `wait_with_output` below.
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("failed to spawn ffmpeg")?;
|
||||
|
||||
// `wait_with_output`, NOT `wait`. ffmpeg is verbose on stderr (banner, stream info,
|
||||
// per-frame progress) and `wait()` reads neither pipe — so once the ~64 KiB pipe buffer
|
||||
// filled, ffmpeg blocked writing, `wait()` never returned, and the call burned the full
|
||||
// timeout. That is not merely slow: the timeout is an `Err`, so after 2 seek positions x
|
||||
// 3 compression attempts the caller soft-deletes a perfectly playable video for a
|
||||
// poster-frame failure. `wait_with_output` polls the pipe and the exit status together.
|
||||
//
|
||||
// It also CONSUMES the child, so the explicit `child.kill()` that used to sit on the
|
||||
// timeout arm cannot exist here — and is not needed: `kill_on_drop(true)` is set above,
|
||||
// and dropping the future on timeout drops the child with it.
|
||||
let out = match tokio::time::timeout(FFMPEG_TIMEOUT, child.wait_with_output()).await {
|
||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||
Err(_) => anyhow::bail!("ffmpeg timed out after {}s", FFMPEG_TIMEOUT.as_secs()),
|
||||
};
|
||||
|
||||
// A non-zero exit is not an error (see the doc comment) — the artifact check in
|
||||
// `extract_poster_frame` is the authority. Log the tail so a systematically failing
|
||||
// format is diagnosable without turning it into data loss.
|
||||
if !out.status.success() {
|
||||
tracing::debug!(
|
||||
seek,
|
||||
status = ?out.status,
|
||||
stderr = %tail_lines(&out.stderr, 10),
|
||||
"ffmpeg exited non-zero; the artifact check decides"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Last `n` lines of a child's stderr, lossily decoded.
|
||||
///
|
||||
/// Bounded on purpose: ffmpeg's stderr is unbounded, and the reason we now drain it is that
|
||||
/// unbounded output used to be a hazard. Emitting all of it into a log line — into container
|
||||
/// logs that are themselves size-capped — would just move the problem.
|
||||
fn tail_lines(bytes: &[u8], n: usize) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
lines[lines.len().saturating_sub(n)..].join(" | ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Is there a usable `ffmpeg` on PATH?
|
||||
///
|
||||
/// The poster-frame path shells out, and `extract_poster_frame` documents `Err` as meaning
|
||||
/// "a hang or a SPAWN failure" — which is exactly what a missing binary produces. So on a
|
||||
/// machine without ffmpeg the test below stops exercising the case it names (missing INPUT)
|
||||
/// and instead reports a code defect that isn't there. The runtime image installs ffmpeg
|
||||
/// (see backend/Dockerfile), so this only ever skips on a bare developer machine.
|
||||
fn ffmpeg_available() -> bool {
|
||||
std::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// The order is the whole fix. `-ss` must precede `-i`, and 0 must be tried after 1 s.
|
||||
#[test]
|
||||
fn the_fallback_seek_exists_and_comes_last() {
|
||||
assert_eq!(
|
||||
SEEK_POSITIONS,
|
||||
&["00:00:01", "0"],
|
||||
"1s first for a better poster, 0 as the fallback that makes short clips work"
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing input yields no frame rather than an error: the caller must degrade to "no
|
||||
/// poster", never fail the upload. `Err` is reserved for a hang or a spawn failure.
|
||||
#[tokio::test]
|
||||
async fn a_missing_source_yields_no_frame_rather_than_an_error() {
|
||||
if !ffmpeg_available() {
|
||||
eprintln!(
|
||||
"SKIP a_missing_source_yields_no_frame_rather_than_an_error: no ffmpeg on PATH. \
|
||||
A missing binary is a spawn failure, which this function returns Err for by \
|
||||
design, so the missing-INPUT case cannot be exercised here. Install ffmpeg to \
|
||||
run it (the runtime image already has it)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("es-video-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("out.jpg");
|
||||
|
||||
let got = extract_poster_frame(Path::new("/nonexistent/clip.mp4"), &dest, 400).await;
|
||||
|
||||
match got {
|
||||
Ok(false) => {}
|
||||
other => panic!("expected Ok(false) for a missing input, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
!dest.exists(),
|
||||
"a failed extraction must leave nothing a caller could mistake for a poster"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stderr_tail_is_bounded_and_survives_invalid_utf8() {
|
||||
let noisy: Vec<u8> = (0..500)
|
||||
.map(|i| format!("line {i}\n"))
|
||||
.collect::<String>()
|
||||
.into_bytes();
|
||||
let got = tail_lines(&noisy, 3);
|
||||
assert_eq!(got, "line 497 | line 498 | line 499");
|
||||
|
||||
// ffmpeg emits filenames verbatim, so its stderr is not guaranteed to be UTF-8.
|
||||
assert_eq!(tail_lines(&[b'o', b'k', 0xff], 5), "ok\u{fffd}");
|
||||
assert_eq!(tail_lines(b"", 5), "");
|
||||
}
|
||||
|
||||
/// A real extraction must finish in a small fraction of `FFMPEG_TIMEOUT`.
|
||||
///
|
||||
/// Wall-clock is the ONLY observable of the bug this guards: piping stderr and then
|
||||
/// calling `wait()` (which drains nothing) blocks ffmpeg on a full pipe buffer until the
|
||||
/// timeout fires, and the timeout is an `Err`, so the upload is soft-deleted. The
|
||||
/// assertion is deliberately on elapsed time, not on the exit status.
|
||||
///
|
||||
/// Honest limitation: our fixture is quiet enough not to fill a 64 KiB pipe on its own,
|
||||
/// so this catches a regression to `wait()` only in combination with a verbose input. It
|
||||
/// is still worth pinning — a reverted drain plus any chatty clip is data loss.
|
||||
#[tokio::test]
|
||||
async fn a_real_clip_yields_a_poster_well_inside_the_timeout() {
|
||||
if tokio::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("skipping: ffmpeg not on PATH");
|
||||
return;
|
||||
}
|
||||
let src = Path::new("../e2e/fixtures/media/sample.mp4");
|
||||
if !src.exists() {
|
||||
eprintln!("skipping: {} missing", src.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("es-video-ok-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("poster.jpg");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let got = extract_poster_frame(src, &dest, 400).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(matches!(got, Ok(true)), "expected a poster, got {got:?}");
|
||||
assert!(dest.metadata().unwrap().len() > 0);
|
||||
assert!(
|
||||
elapsed < FFMPEG_TIMEOUT / 4,
|
||||
"extraction took {elapsed:?}; a drained stderr finishes in well under \
|
||||
{FFMPEG_TIMEOUT:?} — this is the pipe-deadlock regression guard"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ use crate::config::AppConfig;
|
||||
use crate::services::compression::CompressionWorker;
|
||||
use crate::services::config::ConfigCache;
|
||||
use crate::services::disk::DiskCache;
|
||||
use crate::services::media_total::MediaTotalCache;
|
||||
use crate::services::rate_limiter::RateLimiter;
|
||||
use crate::services::sse_tickets::SseTicketStore;
|
||||
use crate::services::upload_admission::UploadAdmission;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SseEvent {
|
||||
@@ -38,6 +40,12 @@ pub struct AppState {
|
||||
pub config_cache: ConfigCache,
|
||||
/// Cached total/free bytes for the media filesystem (quota + admin stats).
|
||||
pub disk_cache: DiskCache,
|
||||
/// Cached sum of all media bytes, for the upload gate's keepsake-headroom check.
|
||||
pub media_total: MediaTotalCache,
|
||||
/// Byte budget for upload bodies currently streaming to temp files. The headroom gate can
|
||||
/// only refuse to COMMIT bytes that are already on disk; this is what bounds how many get
|
||||
/// there at once.
|
||||
pub upload_admission: UploadAdmission,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -63,6 +71,8 @@ impl AppState {
|
||||
sse_tickets: SseTicketStore::new(),
|
||||
config_cache,
|
||||
disk_cache: DiskCache::new(),
|
||||
media_total: MediaTotalCache::new(),
|
||||
upload_admission: UploadAdmission::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
175
backend/tests/upload_idempotency.rs
Normal file
175
backend/tests/upload_idempotency.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! DB-backed tests for the upload idempotency key (migration 022).
|
||||
//!
|
||||
//! The guarantee under test is the one thing standing between a lost response and a duplicated
|
||||
//! wedding photo: a retry of an upload that already committed must NOT create a second row, and
|
||||
//! must not charge the guest's storage quota twice. The whole mechanism is SQL — a partial unique
|
||||
//! index plus `ON CONFLICT DO NOTHING` — so it is tested against a real database with the real
|
||||
//! migrations applied, using the same statements `src/` runs.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// SRC: `models/upload.rs::Upload::create` — the insert, verbatim.
|
||||
///
|
||||
/// Returns the new row's id, or `None` when the key was already stored. The handler treats
|
||||
/// `None` as "a concurrent retry won" and replays the stored row instead of committing.
|
||||
async fn create_upload(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
user_id: Uuid,
|
||||
original_path: &str,
|
||||
client_upload_id: Option<Uuid>,
|
||||
) -> Option<Uuid> {
|
||||
let row: Option<(Uuid,)> = sqlx::query_as(
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL DO NOTHING
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(user_id)
|
||||
.bind(original_path)
|
||||
.bind("image/jpeg")
|
||||
.bind(1_000i64)
|
||||
.bind(Option::<String>::None)
|
||||
.bind(client_upload_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("create_upload");
|
||||
row.map(|(id,)| id)
|
||||
}
|
||||
|
||||
/// SRC: `models/upload.rs::Upload::find_by_client_upload_id` — the lookup, verbatim.
|
||||
async fn find_by_key(pool: &PgPool, user_id: Uuid, client_upload_id: Uuid) -> Option<Uuid> {
|
||||
let row: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT id FROM upload
|
||||
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(client_upload_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.expect("find_by_key");
|
||||
row.map(|(id,)| id)
|
||||
}
|
||||
|
||||
async fn upload_count(pool: &PgPool) -> i64 {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM upload")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("upload_count")
|
||||
}
|
||||
|
||||
/// The core guarantee. A phone that loses the response and re-sends the same photo gets the
|
||||
/// original row back, not a second copy in the gallery and a second charge against its quota.
|
||||
#[sqlx::test]
|
||||
async fn the_same_key_can_only_ever_store_one_upload(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Wackelige Wanda").await;
|
||||
let key = Uuid::new_v4();
|
||||
|
||||
let first = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
|
||||
assert!(first.is_some(), "the first attempt must store the upload");
|
||||
|
||||
// The retry: same key, and (as after a real re-send) a different file on disk.
|
||||
let second = create_upload(&pool, event_id, user_id, "originals/b.jpg", Some(key)).await;
|
||||
assert!(
|
||||
second.is_none(),
|
||||
"a retry of a committed upload must not insert a second row"
|
||||
);
|
||||
assert_eq!(upload_count(&pool).await, 1);
|
||||
|
||||
// And the handler can find the winner to replay it.
|
||||
assert_eq!(find_by_key(&pool, user_id, key).await, first);
|
||||
}
|
||||
|
||||
/// The index must not over-reach. Two genuinely different photos carry different keys and must
|
||||
/// both land — this is the ordinary case, and breaking it would silently drop uploads.
|
||||
#[sqlx::test]
|
||||
async fn different_keys_are_different_uploads(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Fleißige Frieda").await;
|
||||
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
create_upload(
|
||||
&pool,
|
||||
event_id,
|
||||
user_id,
|
||||
"originals/x.jpg",
|
||||
Some(Uuid::new_v4())
|
||||
)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
assert_eq!(upload_count(&pool).await, 5);
|
||||
}
|
||||
|
||||
/// The index is PARTIAL, and this is why. Every upload that predates migration 022, and any
|
||||
/// client that doesn't send a key, carries NULL — if those collided, the first such upload would
|
||||
/// block every subsequent one and the whole event would fail after one photo.
|
||||
#[sqlx::test]
|
||||
async fn uploads_without_a_key_never_collide(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Alte Anna").await;
|
||||
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
create_upload(&pool, event_id, user_id, "originals/legacy.jpg", None)
|
||||
.await
|
||||
.is_some(),
|
||||
"a NULL key must never be treated as a duplicate"
|
||||
);
|
||||
}
|
||||
assert_eq!(upload_count(&pool).await, 5);
|
||||
}
|
||||
|
||||
/// The lookup is scoped to the owner. The key alone is unique, so this can only matter if a key
|
||||
/// ever repeated across users — but a replay that handed one guest another guest's upload row
|
||||
/// would be a data leak, so the scope is asserted rather than assumed.
|
||||
#[sqlx::test]
|
||||
async fn the_replay_lookup_never_crosses_users(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let owner = seed_user(&pool, event_id, "Besitzerin Bea").await;
|
||||
let other = seed_user(&pool, event_id, "Fremder Franz").await;
|
||||
let key = Uuid::new_v4();
|
||||
|
||||
let id = create_upload(&pool, event_id, owner, "originals/a.jpg", Some(key)).await;
|
||||
|
||||
assert_eq!(find_by_key(&pool, owner, key).await, id);
|
||||
assert_eq!(
|
||||
find_by_key(&pool, other, key).await,
|
||||
None,
|
||||
"another guest's retry must not resolve to this upload"
|
||||
);
|
||||
}
|
||||
|
||||
/// A deleted photo must not be resurrected by a stale queue item. If the guest uploaded, deleted,
|
||||
/// and their queue then retried the original request, replaying the deleted row would put the
|
||||
/// photo they removed back in the gallery — so the lookup excludes soft-deleted rows and the
|
||||
/// retry becomes a fresh upload instead.
|
||||
#[sqlx::test]
|
||||
async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
|
||||
let event_id = seed_event(&pool, "wedding").await;
|
||||
let user_id = seed_user(&pool, event_id, "Reumütige Rita").await;
|
||||
let key = Uuid::new_v4();
|
||||
|
||||
let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
|
||||
.await
|
||||
.expect("first insert");
|
||||
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("soft delete");
|
||||
|
||||
assert_eq!(
|
||||
find_by_key(&pool, user_id, key).await,
|
||||
None,
|
||||
"a soft-deleted upload must not be replayed"
|
||||
);
|
||||
}
|
||||
24
docker-compose.build.yml
Normal file
24
docker-compose.build.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Build overlay. NOT loaded automatically (same convention as docker-compose.dev.yml — this
|
||||
# repo deliberately never uses the auto-loaded `docker-compose.override.yml` name).
|
||||
#
|
||||
# Used ONLY on a workstation that builds and pushes images. Never on the production server,
|
||||
# which pulls: see the note on the `app` service in docker-compose.yml.
|
||||
#
|
||||
# Restores the `build:` keys that production omits, so `docker compose build` still works from
|
||||
# a single source of truth for the build context paths:
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.build.yml build
|
||||
#
|
||||
# For the actual release build use buildx directly instead — it is what produces linux/amd64
|
||||
# images from an arm64 Mac and pushes them in one step (see DEPLOYMENT_RUNBOOK.md §6):
|
||||
#
|
||||
# docker buildx build --platform linux/amd64 -t registry.mc02.dev/eventsnap/app:vX.Y.Z --push ./backend
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
@@ -21,11 +21,15 @@ services:
|
||||
# app user can't create it from `/`, so every upload 500s with EACCES. The media
|
||||
# volume is mounted at /media (see docker-compose.yml) — point the app there.
|
||||
MEDIA_PATH: /media
|
||||
# Recent Docker Compose interpolates env_file values, so the `$` segments of the
|
||||
# bcrypt ADMIN_PASSWORD_HASH in .env get eaten (the salt reads as an unset var) —
|
||||
# every admin login then 401s. Re-supply it here with `$` doubled to `$$` so Compose
|
||||
# passes the literal hash. NOTE: production (docker-compose.yml + .env) has the SAME
|
||||
# bug — escape the hash as `$$` in .env, or set it via `environment:` there too.
|
||||
# Set here purely so local dev needs no `.env` at all. The `$` are doubled because
|
||||
# THIS is a `docker-compose.yml` `environment:` value, where Compose does interpolate.
|
||||
#
|
||||
# DO NOT COPY THE DOUBLING INTO `.env`. An earlier version of this comment claimed
|
||||
# production had "the same bug" and told you to escape the hash as `$$` there — that is
|
||||
# wrong and it breaks a working deployment. Compose uses SINGLE-QUOTED `env_file` values
|
||||
# literally, which is the form `.env.example` ships, so the `$` segments survive intact;
|
||||
# doubling them produces a 74-character string that `looks_bcrypt` rejects and the app
|
||||
# refuses to boot on. Verified with `docker compose exec app printenv`.
|
||||
ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye"
|
||||
# Smoke-testing the comment kill-switch: boot-time flag, so it needs a restart
|
||||
# (not an admin-UI toggle). Backend rejects new comments (403) and the frontend
|
||||
@@ -33,9 +37,10 @@ services:
|
||||
COMMENTS_ENABLED: "false"
|
||||
|
||||
caddy:
|
||||
# The prod caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` expands
|
||||
# to empty and the site block collapses into a malformed global block. Supply it
|
||||
# for local dev (from .env → localhost, which Caddy serves with a local self-signed
|
||||
# cert). NOTE: the prod compose likely needs DOMAIN wired to caddy too.
|
||||
# The caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` would expand to empty
|
||||
# and the site block would collapse into a malformed global block. Supply it for local dev
|
||||
# (from .env → localhost, which Caddy serves with a local self-signed cert). Production
|
||||
# already wires DOMAIN into caddy's `environment:`, guarded with `:?` so an unset value
|
||||
# fails the command instead of silently producing a site with no address.
|
||||
environment:
|
||||
DOMAIN: ${DOMAIN}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
# Docker's default json-file driver has NO rotation at all, and every container writes to the
|
||||
# same filesystem as postgres_data, media_data and exports_data. Filling that filesystem does
|
||||
# not degrade one subsystem — Postgres stops being able to write and the whole event goes down
|
||||
# (see README "Sizing the disk"). This caps logs at 30 MB per service, permanently.
|
||||
#
|
||||
# Paired with RUST_LOG in .env: without it the app falls back to `eventsnap_backend=debug,
|
||||
# tower_http=debug` (main.rs), which is a debug line per HTTP request including every preview.
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
@@ -17,35 +31,74 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
# 1G, not 512M. DATABASE_MAX_CONNECTIONS defaults to 30 for a ~100-guest event
|
||||
# (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's
|
||||
# default shared_buffers leaves very little headroom at 512M. An OOM here does
|
||||
# not degrade one feature — it takes the event down, because every request
|
||||
# path touches the database. Memory is the cheaper knob than shrinking the
|
||||
# pool back and reintroducing the queueing it was raised to fix.
|
||||
# 1G, not 512M. Postgres 16's default shared_buffers plus a pool of backends
|
||||
# leaves very little headroom at 512M, and an OOM here does not degrade one
|
||||
# feature — it takes the event down, because every request path touches the
|
||||
# database.
|
||||
#
|
||||
# Raising DATABASE_MAX_CONNECTIONS further means raising this too.
|
||||
# `.env.example` now sets DATABASE_MAX_CONNECTIONS=15, sized to the 2 vCPU this
|
||||
# box has rather than to the guest count: since migration 024 replaced the feed
|
||||
# view's GROUP BY with scalar subqueries, a feed page costs well under a
|
||||
# millisecond, so connections are no longer spent waiting. Raising it back
|
||||
# toward 30 means raising this limit with it.
|
||||
memory: 1G
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
# Production PULLS a prebuilt image; it never compiles. A release build of this crate is
|
||||
# fat-LTO over 427 dependencies (see backend/Cargo.toml [profile.release]) and peaks well
|
||||
# above the RAM a 4 GB box has spare with the stack running — and a rollback would be a
|
||||
# second build under pressure. Images are built on a workstation and pushed; see
|
||||
# docker-compose.build.yml and DEPLOYMENT_RUNBOOK.md.
|
||||
#
|
||||
# There is deliberately NO `build:` key here: without one, a wrong tag fails instantly with
|
||||
# "manifest unknown" instead of silently starting a 45-minute compile on the event server.
|
||||
# The `:?` form fails loudly on an unset variable rather than resolving to an empty tag.
|
||||
image: registry.mc02.dev/eventsnap/app:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
env_file: .env
|
||||
environment:
|
||||
# Default to info. Without this the code fallback in main.rs applies, which is
|
||||
# `eventsnap_backend=debug,tower_http=debug` — a line per HTTP request AND per
|
||||
# response, including every preview fetch, for a multi-day run. The x-logging cap
|
||||
# above bounds the disk cost but not the CPU/IO one.
|
||||
#
|
||||
# Set here rather than only in `.env` because a stock deploy sets RUST_LOG nowhere,
|
||||
# and this is the layer an operator will actually find when they need to raise it
|
||||
# for a single event (`RUST_LOG=eventsnap_backend=debug docker compose up -d app`).
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
# Activates the production secret guard in config.rs — refuses to boot with
|
||||
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
|
||||
APP_ENV: production
|
||||
# Pinned beside MEDIA_PATH for the same reason, and because nothing validates it:
|
||||
# config.rs defaults it to /exports but never checks that it is a mount or that it
|
||||
# differs from media_path. A stray EXPORT_PATH in .env builds the keepsake into the
|
||||
# container's writable layer, where it passes every health check and disk preflight
|
||||
# and then evaporates on the next `up -d`.
|
||||
EXPORT_PATH: /exports
|
||||
# The media volume is mounted at /media (below), so the app MUST write there.
|
||||
# Pin it here rather than trusting .env: if MEDIA_PATH in .env points elsewhere
|
||||
# (e.g. a host path used for running the backend natively) the container can't
|
||||
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
|
||||
# so this is authoritative for the container.
|
||||
MEDIA_PATH: /media
|
||||
# Pinned for the same reason as MEDIA_PATH: `environment` beats `env_file`, so this cannot
|
||||
# be lost by an operator who copies `.env.example` and edits only the secrets — which is
|
||||
# the likely path, and `.env.example` ships the generic default of `true`.
|
||||
#
|
||||
# This is a product decision for this event, not a technical one: guests should be present
|
||||
# at the party, not in a comment thread. Likes and captions stay on and are unaffected.
|
||||
# Boot-time only, so changing it means `docker compose up -d`, not an admin toggle.
|
||||
# To re-enable comments, delete this line and set COMMENTS_ENABLED in .env.
|
||||
COMMENTS_ENABLED: "false"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Longer than the app's own 10s shutdown backstop (main.rs SHUTDOWN_GRACE), because Docker's
|
||||
# default stop timeout is ALSO 10s — so a redeploy raced the graceful drain and could SIGKILL
|
||||
# the process at the exact moment it was finishing, truncating the in-flight upload the
|
||||
# graceful shutdown exists to protect. The app always exits well before 20s.
|
||||
stop_grace_period: 20s
|
||||
volumes:
|
||||
- media_data:/media
|
||||
# Export archives live OUTSIDE /media so the public media ServeDir can't
|
||||
@@ -71,15 +124,27 @@ services:
|
||||
memory: 1G
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
# Pulled, not built — see the note on `app` above.
|
||||
image: registry.mc02.dev/eventsnap/frontend:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
env_file: .env
|
||||
environment:
|
||||
# adapter-node behind Caddy TLS needs the public origin for CSRF checks on
|
||||
# POST form actions — without it they fail only in production.
|
||||
ORIGIN: "https://${DOMAIN}"
|
||||
# `:?` for the same reason EVENTSNAP_VERSION uses it. A blank DOMAIN doesn't fail — it
|
||||
# produces `https://` here and collapses the Caddyfile's site block below, so the stack
|
||||
# comes up with no TLS and no site and the only symptom is a browser error.
|
||||
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
|
||||
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
|
||||
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
|
||||
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response
|
||||
# buffers, which are external memory V8 doesn't count at all. The practical effect is that
|
||||
# V8 can never reach its own limit and run an emergency GC, so the only backpressure is a
|
||||
# kernel SIGKILL: an arrival burst of ~100 guests SSR-rendering /join and /feed OOM-kills
|
||||
# node, guests get the "Wir sind gleich zurück" page, it restarts, and the burst is still
|
||||
# there. Setting the ceiling below the cgroup limit restores GC as the first line of defence.
|
||||
NODE_OPTIONS: "--max-old-space-size=160"
|
||||
depends_on:
|
||||
- app
|
||||
expose:
|
||||
@@ -100,12 +165,13 @@ services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
# The Caddyfile's site address is `{$DOMAIN}`, read from THIS container's env.
|
||||
# Without it, `{$DOMAIN}` expands to empty, the site block collapses, and Caddy
|
||||
# serves nothing / fails to obtain a TLS cert. `env_file` alone wouldn't help —
|
||||
# Caddy needs it in `environment`, and this keeps the Caddyfile the single source.
|
||||
DOMAIN: ${DOMAIN}
|
||||
DOMAIN: ${DOMAIN:?set DOMAIN in .env}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -14,12 +14,12 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
||||
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
|
||||
| **Onboarding & sessions** | | | | |
|
||||
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
|
||||
| First-visit guided tour (4 steps) | ✓ | ✓ | ✓ | Dismissed once, flag in `localStorage` |
|
||||
| First-visit guided tour (6 steps) | ✓ | ✓ | ✓ | Welcome, upload, hashtags, long-press, hell/dunkel, PIN (+ privacy-note pointer when one is set). Dismissed once, flag in `localStorage` |
|
||||
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
|
||||
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
|
||||
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
|
||||
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
|
||||
| Log out / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
|
||||
| Log out ("Abmelden" / "Auf allen Geräten abmelden") | ✓ | ✓ | ✓ | Confirmation bottom-sheet; `DELETE /session` (this device) or `DELETE /sessions` (all devices). Nothing is deleted — account, PIN and uploads stay |
|
||||
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
|
||||
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
|
||||
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
|
||||
@@ -42,7 +42,7 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
||||
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
|
||||
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
|
||||
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
|
||||
| Fullscreen lightbox with swipe | ✓ | ✓ | ✓ | Swipe navigates the filtered set |
|
||||
| Fullscreen lightbox with prev/next navigation | ✓ | ✓ | ✓ | Navigates the *filtered* set. On-screen prev/next controls, ← / → arrow keys, and swipe left/right on touch |
|
||||
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
|
||||
| Read comments on any post | ✓ | ✓ | ✓ | |
|
||||
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
|
||||
@@ -56,11 +56,11 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
||||
| | | | | |
|
||||
| **Moderation (Host)** | | | | |
|
||||
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
|
||||
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
|
||||
| Ban / unban a user | | ✓ | ✓ | Ban **always hides** their existing uploads — no opt-out, the modal is a plain confirm. Unban restores them. Both invalidate and rebuild a released keepsake. See USER_JOURNEYS §9/§10 |
|
||||
| Delete any upload | | ✓ | ✓ | |
|
||||
| Delete any comment | | ✓ | ✓ | |
|
||||
| Promote guest to Host | | ✓ | ✓ | |
|
||||
| Demote Host to guest | | ✓ | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
|
||||
| Demote Host to guest | | | ✓ | **Admin only** — the backend 403s a Host demoting a peer Host (F1: it would launder past the ban / PIN-reset peer guards). Nobody may change their own role; Admins cannot be demoted at all. The button is hidden for non-admin Hosts |
|
||||
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
|
||||
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
|
||||
| Unlock new uploads | | ✓ | ✓ | |
|
||||
@@ -78,18 +78,18 @@ Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ ou
|
||||
| Edit compression-worker concurrency | | | ✓ | |
|
||||
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
|
||||
| Inspect export job list & progress | | | ✓ | |
|
||||
| Low-disk alert (< 10 GB free) | | | ◯ | Planned |
|
||||
| Low-disk alert | | ✓ | ✓ | Red banner at the top of the Host dashboard when `disk_low`, naming free space and the keepsake's required bytes. Fails closed to "not low" on an unreadable mount |
|
||||
| Event banner / cover image | | | ◯ | DB column exists, no UI |
|
||||
| | | | | |
|
||||
| **Quota visibility (Guest-facing)** | | | | |
|
||||
| Show current per-user quota estimate | ✓ | ✓ | ✓ | "Du hast X MB von Y MB genutzt." in My Account and on the upload screen. Computed from the live formula. Hidden when quota enforcement is toggled off |
|
||||
| **Quota visibility (staff-only)** | | | | |
|
||||
| Show current per-user quota estimate | | ✓ | ✓ | **Deliberately not shown to guests** — the storage widget in My Account and on the upload screen is gated on host/admin. Computed from the live formula; also hidden when quota enforcement is toggled off |
|
||||
| | | | | |
|
||||
| **Export** | | | | |
|
||||
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
|
||||
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
|
||||
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
|
||||
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
|
||||
| Per-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
|
||||
| Per-**user** export download rate limit (3 / day) | ✓ | ✓ | ✓ | Keyed on the user id, not the IP — a venue behind one NAT would otherwise share a 3/day budget across every guest |
|
||||
| | | | | |
|
||||
| **Banned guest** (subset) | | | | |
|
||||
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
|
||||
@@ -143,7 +143,7 @@ email, no password, no account portal.
|
||||
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
|
||||
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
|
||||
link routing to `/recover` for users who already know they want to sign in.
|
||||
- **PIN reset by Host / Admin.** Planned. If a guest loses their PIN and `localStorage` is
|
||||
- **PIN reset by Host / Admin.** If a guest loses their PIN and `localStorage` is
|
||||
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
|
||||
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
|
||||
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
|
||||
@@ -153,9 +153,13 @@ email, no password, no account portal.
|
||||
Admins can.
|
||||
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
|
||||
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
|
||||
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by an admin. **Hosts
|
||||
may also demote other Hosts to guests** (planned) — but never themselves, to avoid
|
||||
locking the event out of moderation. Admins can demote anyone except admins.
|
||||
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by a host or an admin.
|
||||
**Only an Admin may change a Host's role** — a plain Host may promote guests but may not
|
||||
demote a peer Host, because demoting one would then let them ban or PIN-reset (→
|
||||
`/recover` takeover) that ex-peer, the guards keying off the target's *current* role (F1).
|
||||
Nobody may change their own role, so the event can't be locked out of moderation, and
|
||||
Admins are un-demotable and un-bannable. Enforced in `handlers::host::set_role`; the
|
||||
button is hidden for non-admin Hosts.
|
||||
|
||||
### 2.2 Posting pipeline
|
||||
|
||||
@@ -188,8 +192,9 @@ The upload pipeline is built for flaky mobile networks:
|
||||
the loaded uploads, so typing never hits the server.
|
||||
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
|
||||
hashtag + uploader combine with AND. Matches the redesign concept exactly.
|
||||
- **Lightbox** — fullscreen view, swipe navigates the *filtered* set, with embedded
|
||||
like/comment UI.
|
||||
- **Lightbox** — fullscreen view with embedded like/comment UI. Navigation across the
|
||||
*filtered* set is available three ways: on-screen prev/next controls, the ← / → arrow
|
||||
keys, and a left/right swipe on touch.
|
||||
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
|
||||
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
|
||||
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
|
||||
@@ -197,15 +202,18 @@ The upload pipeline is built for flaky mobile networks:
|
||||
### 2.4 Host / Admin tooling
|
||||
|
||||
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
|
||||
Nutzerverwaltung. Ban modal asks explicitly whether to hide the user's existing uploads
|
||||
from the public feed. Promote/demote, lock/unlock, release-gallery are one-tap.
|
||||
- **Admin dashboard** — same dashboard plus three more inner tabs (Stats, Config, Export,
|
||||
Nutzerverwaltung. The ban modal is a plain confirm: banning **always hides** the user's
|
||||
existing uploads, there is no keep-visible option. Promote/demote/unban/release are each
|
||||
behind a confirmation sheet that spells out the consequence, not one-tap.
|
||||
- **Admin dashboard** — same user list plus four inner tabs (Stats, Config, Export,
|
||||
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
|
||||
guest count, and compression concurrency — all stored in the `config` table and read on
|
||||
guest count and the colour theme — all stored in the `config` table and read on
|
||||
each request, so changes take effect without a restart. Disk widget pulls from the
|
||||
`sysinfo` crate live.
|
||||
`sysinfo` crate live. The Export tab mirrors the host dashboard's release controls:
|
||||
the release button is disabled once released, the live keepsake status (progress /
|
||||
ready / failure reason) is shown, and a rebuild is offered as the recovery path.
|
||||
|
||||
### 2.5 Data mode (planned)
|
||||
### 2.5 Data mode
|
||||
|
||||
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
|
||||
guest can be on Saver on their phone and Original on their laptop.
|
||||
@@ -218,7 +226,7 @@ guest can be on Saver on their phone and Original on their laptop.
|
||||
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
|
||||
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
|
||||
|
||||
### 2.6 Rate limits and quotas — toggleable (planned)
|
||||
### 2.6 Rate limits and quotas — toggleable
|
||||
|
||||
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
|
||||
|
||||
@@ -230,13 +238,14 @@ The Admin Config tab gains explicit on/off toggles in addition to the numeric in
|
||||
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
|
||||
independently.
|
||||
|
||||
When a feature is toggled off, the relevant UI in the guest-facing app should adapt: e.g.
|
||||
the "Du hast X von Y MB genutzt" widget hides itself when storage quota is disabled. The
|
||||
quota estimate is computed from the same formula the server uses
|
||||
(`(free_disk × tolerance) / max(active_uploaders, 1)`) — surfaced in My Account *and* on
|
||||
the upload preview screen so guests know before they pick files.
|
||||
When a feature is toggled off, the dependent UI adapts: the "Speicher: X / Y" widget hides
|
||||
itself when the storage quota is disabled. The quota estimate is computed from the same
|
||||
formula the server uses (`(free_disk × tolerance) / max(active_uploaders, 1)`) and is
|
||||
surfaced in My Account *and* on the upload preview screen — but **only to hosts and
|
||||
admins**. Guests never see it: the number moves as other people upload and as disk frees
|
||||
up, which reads as a broken or unfair limit to someone who can't see why.
|
||||
|
||||
### 2.7 Privacy note (Datenschutzhinweis, planned)
|
||||
### 2.7 Privacy note (Datenschutzhinweis)
|
||||
|
||||
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
|
||||
Config). It's stored as a single config key (plain text, whitespace and newlines
|
||||
|
||||
@@ -19,8 +19,9 @@ can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
|
||||
this PIN is the only way to sign in on another device. PIN is also written to
|
||||
`localStorage`.
|
||||
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
|
||||
7. The **first-visit onboarding overlay** appears: dismissible steps (welcome, upload,
|
||||
hashtags, PIN, and a brief pointer to the **Datenschutzhinweis** in My Account).
|
||||
7. The **first-visit onboarding overlay** appears: six dismissible steps (welcome, upload,
|
||||
hashtags, long-press for more actions, hell/dunkel design pick, and PIN — the PIN step
|
||||
also points at the **Datenschutzhinweis** in My Account when the admin has set one).
|
||||
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
|
||||
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
|
||||
|
||||
@@ -41,7 +42,7 @@ can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
|
||||
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
|
||||
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
|
||||
|
||||
## 4. PIN forgotten — Host or Admin resets it (planned)
|
||||
## 4. PIN forgotten — Host or Admin resets it
|
||||
|
||||
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
|
||||
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
|
||||
@@ -126,14 +127,23 @@ the Host can clean up later).
|
||||
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
|
||||
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
|
||||
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
|
||||
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
|
||||
`export-progress` keeps it live; `export-available` notifies all guests when ready.
|
||||
viewer). Progress is visible on the Host dashboard *and* in the Admin dashboard's Export
|
||||
tab; SSE `export-progress` keeps it live; `export-available` notifies all guests when
|
||||
ready. Once released the button is disabled and reads *"Galerie bereits freigegeben"* —
|
||||
a second release is a 409. If a keepsake half fails, both dashboards show the reason and
|
||||
offer **Erneut versuchen** (rebuild); a ready keepsake can also be rebuilt behind a
|
||||
confirm, during which guest downloads are briefly unavailable.
|
||||
5. **Nutzerverwaltung** — search users; per-user controls:
|
||||
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
|
||||
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
||||
calls `POST /host/users/{id}/ban` (no body).
|
||||
- **Entsperren** lifts the ban. Same authority boundary as ban (below): a plain Host may
|
||||
only unban Guests; only an Admin may unban a Host.
|
||||
- **Entsperren** lifts the ban — and does two further things the confirm sheet now spells
|
||||
out, because both are visible to the whole party: it clears `uploads_hidden`, so **all**
|
||||
of that user's previously hidden uploads reappear in the gallery, the diashow and the
|
||||
export; and it invalidates and rebuilds a released keepsake (`invalidate_and_arm`), so
|
||||
every guest's download is unavailable for as long as that takes. A ban does the mirror
|
||||
image of both. Same authority boundary as ban: a plain Host may only unban Guests; only
|
||||
an Admin may unban a Host.
|
||||
- **Host** promotes a guest to host (Hosts and Admins may do this).
|
||||
- **Degradieren** — demote a Host back to guest. **Only an Admin may change a Host's
|
||||
role.** A plain Host may *not* demote a peer Host: doing so would let them then ban or
|
||||
@@ -181,14 +191,18 @@ the Host can clean up later).
|
||||
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
|
||||
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
|
||||
export), quota tolerance, estimated guest count, compression-worker concurrency,
|
||||
plus the **Datenschutzhinweis** free-text editor and **on/off toggles** for the rate
|
||||
limiters and quotas (planned — see §16). Whitelist on the server side rejects
|
||||
unknown keys. Values are read from the `config` table on each request — no restart
|
||||
needed.
|
||||
- **Export**: list of past export jobs with status badges (pending / running / done /
|
||||
failed) and progress bars; refresh button re-polls.
|
||||
- **Nutzer**: same user list as Host, with the additional Demote action and (planned)
|
||||
PIN-reset on host rows.
|
||||
plus the colour-theme picker, the **Datenschutzhinweis** free-text editor and
|
||||
**on/off toggles** for the rate limiters and quotas (see §18). Whitelist on the
|
||||
server side rejects unknown keys. Values are read from the `config` table on each
|
||||
request — no restart needed.
|
||||
- **Export**: the gallery-release control plus the list of export jobs with status
|
||||
badges (pending / running / done / failed) and progress bars; refresh button
|
||||
re-polls. The release control mirrors the Host dashboard exactly — disabled once
|
||||
released (a second tap would only 409), live keepsake status with progress and the
|
||||
failure reason, and a **Neu erstellen / Erneut versuchen** rebuild as the recovery
|
||||
path for a failed or stale keepsake.
|
||||
- **Nutzer**: same user list as Host, with the additional Demote action (admin-only,
|
||||
see §9) and PIN-reset on host rows.
|
||||
|
||||
## 12. Releasing the export and downloading
|
||||
|
||||
@@ -224,11 +238,14 @@ the Host can clean up later).
|
||||
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
|
||||
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
|
||||
browser download.
|
||||
7. Downloads are rate-limited per IP (default 3 / day).
|
||||
7. Downloads are rate-limited **per user** (default 3 / day), keyed on the user id in
|
||||
`enforce_export_rate` — deliberately not per IP. At a venue where a hundred guests share
|
||||
one NAT'd wifi, a per-IP budget would be exhausted by the third person to tap
|
||||
**Herunterladen** and lock everyone else out of their own keepsake.
|
||||
|
||||
## 13. Diashow (planned)
|
||||
## 13. Diashow
|
||||
|
||||
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
||||
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the flow:
|
||||
|
||||
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
|
||||
on mobile).
|
||||
@@ -240,7 +257,7 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
||||
immediately.
|
||||
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
|
||||
|
||||
## 14. Picking a data mode (planned)
|
||||
## 14. Picking a data mode
|
||||
|
||||
1. Guest opens **My Account** → scrolls to **Datennutzung**.
|
||||
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
|
||||
@@ -252,14 +269,24 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
||||
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
|
||||
bundled `_thumb` / `_full` variants.
|
||||
|
||||
## 15. Leaving an event
|
||||
## 15. Signing out
|
||||
|
||||
1. User opens **My Account** → taps **🚪 Event verlassen**.
|
||||
2. Bottom-sheet confirmation: "Event verlassen?" with **Abmelden** and **Bleiben**.
|
||||
3. Confirming calls `DELETE /api/v1/session` (invalidates the session row), clears the JWT
|
||||
and PIN from `localStorage`, and redirects to the join page.
|
||||
The wording matters here: nothing is *left* and nothing is deleted. The account, the PIN
|
||||
and every uploaded photo survive — the user signs back in with name + PIN whenever they
|
||||
like. ("Event verlassen" read as leaving for good, which the confirmation sheet then
|
||||
contradicted by saying you can come back.)
|
||||
|
||||
## 16. Reading the Datenschutzhinweis (planned)
|
||||
1. User opens **My Account** → Konto section. Two separate actions:
|
||||
- **Abmelden** — this device only (`DELETE /api/v1/session`).
|
||||
- **Auf allen Geräten abmelden** — every session of theirs (`DELETE /api/v1/sessions`),
|
||||
for a lost or borrowed phone.
|
||||
2. Bottom-sheet confirmation naming which of the two it is, with **Abmelden** / **Abbrechen**.
|
||||
3. Confirming invalidates the session row(s), wipes the local JWT, PIN and the IndexedDB
|
||||
upload queue (so the next guest on a shared phone doesn't inherit pending uploads), and
|
||||
redirects to the join page. The session-delete call is best-effort: the token is gone
|
||||
locally either way, so a network failure never traps the user on the page.
|
||||
|
||||
## 16. Reading the Datenschutzhinweis
|
||||
|
||||
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
|
||||
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
|
||||
@@ -270,11 +297,15 @@ See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
||||
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
|
||||
tall textarea with a save button. Saved to a single `config` key.
|
||||
|
||||
## 17. Mobile-first gestures (planned)
|
||||
## 17. Mobile-first gestures (partly shipped)
|
||||
|
||||
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
|
||||
ergonomic. Buttons are always present as fallback for desktop and accessibility.
|
||||
|
||||
Shipped today: long-press context sheets on posts and comments, and lightbox navigation
|
||||
(on-screen prev/next controls, ← / → arrow keys, and left/right swipe). The remaining rows
|
||||
below are still planned.
|
||||
|
||||
| Gesture | Action |
|
||||
|-------------------------------------------|-------------------------------------------------------|
|
||||
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
|
||||
@@ -294,7 +325,7 @@ Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press f
|
||||
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
|
||||
land).
|
||||
|
||||
## 18. Admin toggles a rate limit or quota off (planned)
|
||||
## 18. Admin toggles a rate limit or quota off
|
||||
|
||||
1. Admin opens **Admin Dashboard → Config**.
|
||||
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
|
||||
@@ -305,8 +336,9 @@ land).
|
||||
limiter entirely.
|
||||
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
|
||||
bytes / upload count).
|
||||
6. When the storage-quota toggle is off, the **"X von Y MB genutzt"** widget in the
|
||||
guest's My Account and upload screen hides itself (no quota → no number to show).
|
||||
6. When the storage-quota toggle is off, the **"Speicher: X / Y"** widget in My Account and
|
||||
on the upload screen hides itself (no quota → no number to show). That widget is
|
||||
staff-only in any case — guests never see it.
|
||||
|
||||
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
|
||||
Toggling off is the explicit escape hatch for testing or trusted internal events.
|
||||
|
||||
@@ -37,6 +37,52 @@ export const db = {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Is this user's account currently PIN-locked?
|
||||
*
|
||||
* Distinguishes the two ways /recover can answer 429 — the per-(IP, name) throttle, which
|
||||
* costs the attacker, and the account lock, which costs the VICTIM. Only the second one is
|
||||
* weaponizable, so a test asserting "a single IP cannot lock a guest out" has to look at the
|
||||
* row, not at the status code.
|
||||
*/
|
||||
async isPinLocked(userId: string): Promise<boolean> {
|
||||
return withClient(async (c) => {
|
||||
const r = await c.query<{ locked: boolean }>(
|
||||
`SELECT (pin_locked_until IS NOT NULL AND pin_locked_until > NOW()) AS locked
|
||||
FROM "user" WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return r.rows[0]?.locked ?? false;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Preload the wrong-PIN streak, standing in for failures that arrived from other IPs.
|
||||
*
|
||||
* The account lock is deliberately out of reach of any single source, so a test that wants to
|
||||
* exercise it has to simulate the distributed case rather than hammer from one address.
|
||||
* `last_failed_pin_at` is set to now so the 15-minute decay does not immediately reset it.
|
||||
*/
|
||||
async setFailedPinAttempts(userId: string, attempts: number) {
|
||||
await withClient((c) =>
|
||||
c.query(
|
||||
`UPDATE "user" SET failed_pin_attempts = $2, last_failed_pin_at = NOW() WHERE id = $1`,
|
||||
[userId, attempts]
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
/** Current wrong-PIN streak. Decays after 15 minutes — see User::increment_failed_pin. */
|
||||
async failedPinAttempts(userId: string): Promise<number> {
|
||||
return withClient(async (c) => {
|
||||
const r = await c.query<{ failed_pin_attempts: number }>(
|
||||
`SELECT failed_pin_attempts FROM "user" WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return r.rows[0]?.failed_pin_attempts ?? 0;
|
||||
});
|
||||
},
|
||||
|
||||
async expireSession(userId: string) {
|
||||
await withClient((c) =>
|
||||
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
||||
|
||||
BIN
e2e/fixtures/media/huge-99mp.jpg
Normal file
BIN
e2e/fixtures/media/huge-99mp.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 568 KiB |
4
e2e/fixtures/media/not-an-image.jpg
Normal file
4
e2e/fixtures/media/not-an-image.jpg
Normal file
@@ -0,0 +1,4 @@
|
||||
This is plain text, not an image at all.
|
||||
This is plain text, not an image at all.
|
||||
This is plain text, not an image at all.
|
||||
This is plain text, not an image at all.
|
||||
BIN
e2e/fixtures/media/portrait-exif6.jpg
Normal file
BIN
e2e/fixtures/media/portrait-exif6.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 807 B |
BIN
e2e/fixtures/media/sample-5s.mp4
Normal file
BIN
e2e/fixtures/media/sample-5s.mp4
Normal file
Binary file not shown.
BIN
e2e/fixtures/media/sample.jpg
Normal file
BIN
e2e/fixtures/media/sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
e2e/fixtures/media/sample.mp4
Normal file
BIN
e2e/fixtures/media/sample.mp4
Normal file
Binary file not shown.
BIN
e2e/fixtures/media/sample2.jpg
Normal file
BIN
e2e/fixtures/media/sample2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -75,7 +75,11 @@ test.describe('Auth — join flow', () => {
|
||||
expect(storage.pin).toBe(original.pin);
|
||||
});
|
||||
|
||||
test('wrong PIN three times locks the account for 15 minutes', async ({ page, guest, db }) => {
|
||||
test('repeated wrong PINs are throttled without locking the guest out', async ({
|
||||
page,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
const dave = await guest('Dave');
|
||||
await clearAllStorage(page);
|
||||
|
||||
@@ -85,22 +89,33 @@ test.describe('Auth — join flow', () => {
|
||||
await join.submit();
|
||||
await expect(join.recoveryPinInput).toBeVisible();
|
||||
|
||||
// Wrong PIN (real one is dave.pin)
|
||||
// Wrong PIN (real one is dave.pin), four times — one more than the OLD lock threshold of 3.
|
||||
// Typed digit by digit so the 4th character auto-submits (see pin-auto-submit.spec.ts);
|
||||
// clicking as well would double-submit and race the disabled state of the button.
|
||||
const wrong = dave.pin === '0000' ? '1111' : '0000';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await join.recoveryPinInput.fill(wrong);
|
||||
await join.recoverySubmit.click();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await join.recoveryPinInput.fill('');
|
||||
await join.recoveryPinInput.pressSequentially(wrong, { delay: 30 });
|
||||
await expect(join.recoveryError).toBeVisible();
|
||||
await expect(join.recoverySubmit).toBeEnabled();
|
||||
}
|
||||
|
||||
// Fourth attempt should hit the 429 lockout (even with the correct PIN now)
|
||||
await join.recoveryPinInput.fill(dave.pin);
|
||||
await join.recoverySubmit.click();
|
||||
await expect(join.recoveryError).toContainText(/15 Minuten/);
|
||||
// THE PROPERTY THIS TEST EXISTS FOR, stated the way a guest experiences it: Dave can still
|
||||
// get into his own account.
|
||||
//
|
||||
// The lock threshold used to be 3, BELOW the per-(IP, name) ceiling — so these very
|
||||
// keystrokes locked Dave out for 15 minutes, and anyone who can read his name off the feed
|
||||
// could do it to him on repeat. Rate limits are disabled in this environment (see
|
||||
// config `rate_limits_enabled`), so what is exercised here is purely the account-lock tier;
|
||||
// the throttle tier is covered in 07-adversarial/auth-tampering.spec.ts.
|
||||
expect(
|
||||
await db.isPinLocked(dave.userId),
|
||||
'four wrong PINs from one device must not lock a guest out of their own account'
|
||||
).toBe(false);
|
||||
|
||||
// Sanity: DB row reflects the lock
|
||||
// (The handler sets pin_locked_until directly — verify via API "recover" returning 429)
|
||||
void db; // unused for now, documenting that db.lockUserPin exists if we want shortcut path
|
||||
await join.recoveryPinInput.fill('');
|
||||
await join.recoveryPinInput.pressSequentially(dave.pin, { delay: 30 });
|
||||
await page.waitForURL('**/feed');
|
||||
});
|
||||
|
||||
test('"Anderen Namen wählen" returns to the normal join form', async ({ page, guest }) => {
|
||||
|
||||
@@ -199,9 +199,12 @@ test.describe('Upload — client queue under a burst', () => {
|
||||
// a closed tab / killed PWA. The remaining pending items live only in
|
||||
// IndexedDB now.
|
||||
await page.reload();
|
||||
// The queue only resumes where loadQueue() runs — the /upload route's
|
||||
// onMount. Navigating there is the "reopen the composer" recovery path.
|
||||
await page.goto('/upload');
|
||||
// Deliberately NOT navigating to /upload. Rehydration is now module-level and
|
||||
// auth-gated (upload-queue.ts `hydrateQueue`), so the queue resumes wherever the
|
||||
// reload lands. This assertion is the regression guard for the defect it replaced:
|
||||
// `loadQueue()` used to have a single call site in the whole app — the /upload
|
||||
// route's onMount — so a guest who reloaded anywhere else saw a 0 badge and their
|
||||
// staged photos never left the phone, having already been shown a success.
|
||||
|
||||
// (4) RESUME: every file ends up server-side without re-staging anything.
|
||||
// `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload
|
||||
|
||||
@@ -69,6 +69,17 @@ test.describe('Video — the lightbox plays it', () => {
|
||||
// The poster SHOULD still be the thumbnail — that's what it's for.
|
||||
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
|
||||
|
||||
// …and it must actually RESOLVE. Asserting only the attribute is what let a phantom thumbnail
|
||||
// survive nine rounds of green: `thumbnail_path` was written for a file ffmpeg never created,
|
||||
// so this URL 404'd for every clip of a second or less while the attribute looked perfect.
|
||||
// One extra fetch is the whole difference.
|
||||
const poster = await fetch(`${BASE}/api/v1/upload/${id}/thumbnail`, {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
expect(poster.status, 'the poster URL must serve real bytes, not just exist').toBe(200);
|
||||
expect(poster.headers.get('content-type')).toContain('image/');
|
||||
expect((await poster.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
|
||||
// And the browser must accept the bytes as media. preload="none" means nothing is
|
||||
// fetched until we ask, so drive a load explicitly and wait for metadata.
|
||||
const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
|
||||
|
||||
84
e2e/specs/03-feed/video-poster.spec.ts
Normal file
84
e2e/specs/03-feed/video-poster.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Regression guard — a short video gets a real poster frame, and the keepsake never shows a broken
|
||||
* tile.
|
||||
*
|
||||
* Both the compression worker and the HTML export ran the same invocation:
|
||||
*
|
||||
* ffmpeg -i <src> -vframes 1 -ss 00:00:01 -vf scale=… -y <out>
|
||||
*
|
||||
* `-ss` AFTER `-i` is an output-side seek. Against a clip of a second or less ffmpeg exits **0 and
|
||||
* writes nothing**, and both call sites gated on the exit status. So:
|
||||
*
|
||||
* - the worker wrote `thumbnail_path` and logged "thumbnail generated" for a file that was never
|
||||
* created → `GET /upload/{id}/thumbnail` 404s in the live feed;
|
||||
* - the export listed `media/…_thumb.jpg` in `data.json` while the ZIP writer skipped the
|
||||
* unopenable file → the keepsake rendered a broken image tile.
|
||||
*
|
||||
* Any clip at or under a second, which phones produce constantly: mis-taps, Live Photos, boomerangs.
|
||||
* Every server-side signal stayed green throughout.
|
||||
*
|
||||
* Two fixtures on purpose, because they take different paths through the fix:
|
||||
* - `sample.mp4` is exactly 1.000 s. An input-side seek to 1 s is STILL past its last frame, so it
|
||||
* is the 0 s fallback that saves it. Moving `-ss` before `-i` alone does not fix this file.
|
||||
* - `sample-5s.mp4` is 5 s and succeeds on the first seek — the normal path, which no test covered
|
||||
* at all before, because the suite only ever had the boundary fixture.
|
||||
*/
|
||||
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 CLIPS = [
|
||||
{ file: 'sample.mp4', label: '1.000s — needs the 0s fallback' },
|
||||
{ file: 'sample-5s.mp4', label: '5s — succeeds on the first seek' },
|
||||
];
|
||||
|
||||
async function uploadClip(jwt: string, file: string): Promise<string> {
|
||||
const bytes = readFileSync(join(process.cwd(), 'fixtures', 'media', file));
|
||||
const res = await uploadRaw(jwt, bytes, { filename: file, contentType: 'video/mp4' });
|
||||
expect(res.status, `uploading ${file}`).toBe(201);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
}
|
||||
|
||||
test.describe('Video — the poster frame is real', () => {
|
||||
for (const { file, label } of CLIPS) {
|
||||
test(`${file} (${label}) gets a fetchable poster`, async ({ guest, db }) => {
|
||||
test.setTimeout(60_000);
|
||||
const g = await guest(`Poster${file.replace(/\W/g, '')}`);
|
||||
const id = await uploadClip(g.jwt, file);
|
||||
await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done');
|
||||
|
||||
// The DB must not claim a thumbnail that isn't there — that claim IS the defect.
|
||||
const res = await fetch(`${BASE}/api/v1/upload/${id}/thumbnail`, {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
expect(res.status, `${file}: the poster must exist, not just be recorded`).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('image/');
|
||||
expect((await res.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('a video upload still succeeds even if no poster can be extracted', async ({
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
// The mirror that keeps the fix honest. Tightening the check to "the file must exist" without
|
||||
// also making a missing poster non-fatal would have been far worse than the bug: the worker's
|
||||
// call used `?`, so every sub-second clip would fail compression, exhaust its retries and be
|
||||
// soft-deleted. A cosmetic defect turned into data loss.
|
||||
//
|
||||
// `compression_status = 'done'` with the upload still present is exactly that guarantee.
|
||||
const g = await guest('PosterSurvivor');
|
||||
const id = await uploadClip(g.jwt, 'sample.mp4');
|
||||
await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done');
|
||||
expect(await db.countUploadsForUser(g.userId)).toBe(1);
|
||||
|
||||
// And the video itself is playable regardless of the poster.
|
||||
const orig = await fetch(`${BASE}/api/v1/upload/${id}/original`, {
|
||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||
});
|
||||
expect(orig.status).toBe(200);
|
||||
expect(orig.headers.get('content-type')).toContain('video/');
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,40 @@ test.describe('Admin — config API', () => {
|
||||
expect(cfg.privacy_note).toBe(note);
|
||||
await api.patchConfig(adminToken, { privacy_note: '' });
|
||||
});
|
||||
test('quota_tolerance = 0 is rejected, with a pointer to the real off-switch', async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
// Zero is inside the documented 0–1 range and catastrophic: the per-user limit is
|
||||
// `free_disk * tolerance / active_uploaders`, so 0 refuses EVERY upload — mid-event, with
|
||||
// "Du hast dein Upload-Limit für dieses Event erreicht", which names the wrong cause
|
||||
// entirely. `storage_quota_enabled` is what an admin reaching for an off-switch wants.
|
||||
const res = await fetch(
|
||||
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quota_tolerance: '0' }),
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(
|
||||
(await res.text()).toLowerCase(),
|
||||
'the error must name the switch the admin actually wanted'
|
||||
).toContain('speicher-quote');
|
||||
|
||||
// The value is untouched — validation fully precedes any write.
|
||||
expect((await api.getConfig(adminToken)).quota_tolerance).toBe('0.75');
|
||||
});
|
||||
|
||||
test('a very small quota_tolerance is still accepted', async ({ api, adminToken }) => {
|
||||
// The mirror. Rejecting 0 must not become a floor: small tolerances are how a large disk is
|
||||
// throttled to a sensible per-guest ceiling, and how the quota specs steer it (~1e-5 on a
|
||||
// 174 GB volume). A floor of 0.01 would forbid real configurations to prevent one typo.
|
||||
await api.patchConfig(adminToken, { quota_tolerance: '0.00001' });
|
||||
expect((await api.getConfig(adminToken)).quota_tolerance).toBe('0.00001');
|
||||
await api.patchConfig(adminToken, { quota_tolerance: '0.75' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin — stats', () => {
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
* this drives a real video upload → export → and proves the video entry lands in
|
||||
* Memories.zip (i.e. the streamed-from-original path works and the video isn't dropped).
|
||||
*
|
||||
* The fixture clip is <1s, so ffmpeg extracts no thumbnail frame — but exits 0, so the
|
||||
* compression worker keeps the upload (it isn't auto-cleaned). That's the intended
|
||||
* shape here: the full video is exported even when its thumbnail is absent.
|
||||
* NOTE ON A PREVIOUS VERSION OF THIS COMMENT. It used to read: "The fixture clip is <1s, so ffmpeg
|
||||
* extracts no thumbnail frame — but exits 0, so the compression worker keeps the upload. That's the
|
||||
* intended shape here." None of that was intended. `-ss` sat AFTER `-i` (an output-side seek), so
|
||||
* against `sample.mp4` — which is exactly 1.000 s — ffmpeg exited 0 having written nothing, and
|
||||
* both the worker and the export gated on the exit status. The missing thumbnail was observed here
|
||||
* and written down as expected behaviour instead of investigated; every video test in the suite ran
|
||||
* against that one boundary fixture, and none of them ever fetched the poster.
|
||||
*
|
||||
* The seek is now input-side with a 0 s fallback and the artifact is verified rather than the exit
|
||||
* code, so this clip DOES get a thumbnail. The assertion at the bottom pins that.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
@@ -65,5 +72,13 @@ test.describe('Export — video streaming (P4)', () => {
|
||||
const needle = `media/${id}.mp4`;
|
||||
const haystack = new TextDecoder('latin1').decode(bytes);
|
||||
expect(haystack.includes(needle), `Memories.zip must contain ${needle}`).toBe(true);
|
||||
|
||||
// And its poster is really in the archive. This clip is 1.000 s — the exact case the old
|
||||
// output-side seek produced nothing for, silently, while `data.json` still advertised the
|
||||
// entry. See the note at the top of this file.
|
||||
expect(
|
||||
haystack.includes(`media/${id}_thumb.jpg`),
|
||||
`Memories.zip must contain the poster for ${id}, not just reference it`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
113
e2e/specs/06-export/viewer-no-broken-tiles.spec.ts
Normal file
113
e2e/specs/06-export/viewer-no-broken-tiles.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Regression guard — the keepsake never renders a broken image tile.
|
||||
*
|
||||
* The HTML export wrote `thumb: "media/<id>_thumb.jpg"` into `data.json` unconditionally. When
|
||||
* ffmpeg produced no poster frame — which it did, silently and with exit 0, for any clip of a
|
||||
* second or less — the ZIP writer skipped the unopenable file but `data.json` still advertised it.
|
||||
* The viewer then requested an entry the archive did not contain and drew a broken `<img>`.
|
||||
*
|
||||
* The viewer was never the problem: `+page.svelte` already guards `{#if post.media.thumb}` and
|
||||
* falls back to a proper dark video tile with a play glyph. The guard simply never fired, because
|
||||
* the backend always handed it a non-empty string. The fix is the backend telling the truth —
|
||||
* `thumb: ""` when there is no poster — so no viewer change was needed.
|
||||
*
|
||||
* This asserts the property that actually matters to a guest and that no server-side signal can
|
||||
* report: **every image in the opened keepsake resolves**. `naturalWidth > 0` is false for exactly
|
||||
* the broken-tile case, whatever produced it — a missing video poster, a failed image thumbnail, or
|
||||
* some future path nobody has thought of yet. It is deliberately not an assertion about ffmpeg.
|
||||
*
|
||||
* Runs over `file://`, the way a guest opens it.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Export — the keepsake has no broken tiles', () => {
|
||||
test('every image in the opened viewer resolves', async ({ page, host, guest, db }) => {
|
||||
test.setTimeout(150_000);
|
||||
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||||
|
||||
const g = await guest('TileChecker');
|
||||
const ids: string[] = [await seedUpload(g.jwt, { caption: 'ein Foto' })];
|
||||
|
||||
// Both clips: the 1.000 s one is the case that produced the broken tile, the 5 s one is the
|
||||
// ordinary path that had no coverage at all.
|
||||
for (const file of ['sample.mp4', 'sample-5s.mp4']) {
|
||||
const bytes = readFileSync(join(process.cwd(), 'fixtures', 'media', file));
|
||||
const res = await uploadRaw(g.jwt, bytes, { filename: file, contentType: 'video/mp4' });
|
||||
expect(res.status).toBe(201);
|
||||
ids.push(((await res.json()) as { id: string }).id);
|
||||
}
|
||||
for (const id of ids) {
|
||||
await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done');
|
||||
}
|
||||
|
||||
expect(
|
||||
(await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }))
|
||||
.status
|
||||
).toBe(204);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||||
return (await res.json()).html?.status;
|
||||
},
|
||||
{ timeout: 120_000, intervals: [500] }
|
||||
)
|
||||
.toBe('done');
|
||||
|
||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||
method: 'POST',
|
||||
headers: bearer,
|
||||
});
|
||||
const { ticket } = (await ticketRes.json()) as { ticket: string };
|
||||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||
expect(dl.status).toBe(200);
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-tiles-'));
|
||||
try {
|
||||
const zipPath = join(dir, 'Memories.zip');
|
||||
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
|
||||
execFileSync('unzip', ['-qo', zipPath, '-d', dir]);
|
||||
|
||||
await page.goto('file://' + join(dir, 'index.html'));
|
||||
|
||||
// Every post is present, whether or not it has a poster.
|
||||
const posts = await page.evaluate(() => {
|
||||
const d = (
|
||||
window as unknown as {
|
||||
__EXPORT_DATA__?: { posts?: { media?: { thumb?: string; type?: string } }[] };
|
||||
}
|
||||
).__EXPORT_DATA__;
|
||||
return d?.posts?.map((p) => ({ thumb: p.media?.thumb ?? '', type: p.media?.type })) ?? null;
|
||||
});
|
||||
expect(posts, '__EXPORT_DATA__ was never assigned').not.toBeNull();
|
||||
expect(posts!.length).toBe(3);
|
||||
|
||||
// Any thumb data.json DOES advertise must be a file the archive actually contains.
|
||||
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }).split('\n');
|
||||
for (const p of posts!.filter((p) => p.thumb)) {
|
||||
expect(
|
||||
entries.includes(p.thumb),
|
||||
`data.json advertises ${p.thumb} but the archive does not contain it`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// THE assertion: nothing rendered broken. Give the images a moment to settle first.
|
||||
await page.waitForLoadState('networkidle');
|
||||
const broken = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('img'))
|
||||
.filter((i) => i.complete && i.naturalWidth === 0)
|
||||
.map((i) => i.getAttribute('src') ?? '(no src)')
|
||||
);
|
||||
expect(broken, `broken image tiles in the keepsake: ${broken.join(', ')}`).toEqual([]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -89,15 +89,43 @@ test.describe('Adversarial — JWT', () => {
|
||||
});
|
||||
|
||||
test.describe('Adversarial — PIN brute-force', () => {
|
||||
test('sequential wrong-PIN attempts lock the account after 3 attempts', async ({ guest }) => {
|
||||
/**
|
||||
* The PIN defence has two tiers, and telling them apart is the whole point of these tests:
|
||||
*
|
||||
* - the per-(IP, name) throttle, which refuses the ATTACKER; and
|
||||
* - the account lock, which refuses the VICTIM — the only tier that can be weaponised.
|
||||
*
|
||||
* Both answer 429, so the status code alone proves nothing. The regression these guard is that
|
||||
* the lock threshold used to sit BELOW the throttle ceiling (3 vs 5), so three requests from a
|
||||
* single IP locked any guest whose display name is readable off the feed, every 15 minutes,
|
||||
* indefinitely. The tier meant to protect a guest was the cheapest way to attack them.
|
||||
*/
|
||||
// Restore the default. The first test turns the limiter on for the whole instance, and leaving
|
||||
// it on would throttle unrelated specs sharing this stack. Runs even on failure.
|
||||
test.afterEach(async ({ api, adminToken }) => {
|
||||
await api.patchConfig(adminToken, { rate_limits_enabled: 'false' });
|
||||
});
|
||||
|
||||
test('a single IP is throttled without ever locking the victim out', async ({
|
||||
api,
|
||||
adminToken,
|
||||
guest,
|
||||
db,
|
||||
}) => {
|
||||
// Rate limits are off by default in this environment, and the throttle IS the tier under
|
||||
// test — without it the run would silently assert only half the property.
|
||||
await api.patchConfig(adminToken, {
|
||||
rate_limits_enabled: 'true',
|
||||
recover_rate_enabled: 'true',
|
||||
});
|
||||
|
||||
const g = await guest('Brute');
|
||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||
|
||||
// Do them serially so the failed_pin_attempts counter increments
|
||||
// monotonically. Parallel attempts race and may never accumulate to 3 in
|
||||
// the current handler implementation — that's a separate finding.
|
||||
// Serially, so the failed-PIN counter increments monotonically. Well past the per-(IP, name)
|
||||
// ceiling of 4, and past the OLD lock threshold of 3.
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const r = await fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -105,26 +133,52 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
});
|
||||
statuses.push(r.status);
|
||||
}
|
||||
// First three are 401, fourth (or later) is 429.
|
||||
expect(statuses.filter((s) => s === 200)).toHaveLength(0);
|
||||
expect(statuses.some((s) => s === 429)).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);
|
||||
|
||||
// Now even the correct PIN fails until lockout expires.
|
||||
expect(
|
||||
await db.isPinLocked(g.userId),
|
||||
'one IP must not be able to lock a guest out of their own account'
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('the account still locks once the failure count is reached', async ({ guest, db }) => {
|
||||
const g = await guest('BruteDistributed');
|
||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||
|
||||
// The per-IP throttle is what a single source hits first, so drive the counter the way a
|
||||
// DISTRIBUTED attacker would — the tier this test covers is the last line against exactly
|
||||
// that, and it must not have been removed while fixing the weaponisation above.
|
||||
await db.setFailedPinAttempts(g.userId, 11);
|
||||
|
||||
const r = await fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.77' },
|
||||
body: JSON.stringify({ display_name: g.displayName, pin: wrong }),
|
||||
});
|
||||
expect(r.status).toBe(401);
|
||||
|
||||
expect(
|
||||
await db.isPinLocked(g.userId),
|
||||
'a distributed guesser must still trip the account lock'
|
||||
).toBe(true);
|
||||
|
||||
// And the lock holds even against the correct PIN, which is what makes it a real control.
|
||||
const correct = await fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.78' },
|
||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||
});
|
||||
expect(correct.status).toBe(429);
|
||||
});
|
||||
|
||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
test('the wrong-PIN streak is atomic under concurrency', async ({ guest, db }) => {
|
||||
const g = await guest('BruteParallel');
|
||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||
|
||||
const attempts = await Promise.all(
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, () =>
|
||||
fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
@@ -133,29 +187,14 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
const statuses = attempts.map((r) => r.status);
|
||||
expect(
|
||||
statuses.filter((s) => s === 200),
|
||||
'a wrong PIN must never authenticate'
|
||||
).toHaveLength(0);
|
||||
|
||||
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
||||
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
||||
// racy — and is the property this test exists to guard — is the state left behind:
|
||||
// `failed_pin_attempts` is incremented with an atomic `SET x = x + 1 ... RETURNING`, so
|
||||
// 10 wrong PINs must push it past the 3-strike threshold and leave the account locked.
|
||||
//
|
||||
// We prove that with a follow-up request using the CORRECT pin: it must be refused with
|
||||
// 429 (locked), not 200. Delete the lockout counter and this line goes 200 → red.
|
||||
const correct = await fetch(`${BASE}/api/v1/recover`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||
});
|
||||
// How many of the 10 get past the throttle is genuinely racy and cannot be asserted. What is
|
||||
// NOT racy is that every one that DID reach the handler incremented the counter — it is a
|
||||
// single `SET x = x + 1 ... RETURNING`, so none of them can be lost to the race.
|
||||
expect(
|
||||
correct.status,
|
||||
'after 10 wrong PINs the account must be locked, even for the right PIN'
|
||||
).toBe(429);
|
||||
await db.failedPinAttempts(g.userId),
|
||||
'concurrent wrong PINs must all be counted'
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
18
frontend/.dockerignore
Normal file
18
frontend/.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
# Build context exclusions.
|
||||
#
|
||||
# This one is a correctness fix, not just a speed one. The Dockerfile runs `npm ci` and THEN
|
||||
# `COPY . .`, so a macOS `node_modules/` in the build context is copied OVER the Linux one the
|
||||
# container just installed. npm's platform-specific binaries (@rollup/rollup-linux-x64-musl,
|
||||
# @esbuild/linux-x64, @tailwindcss/oxide) live in separate optional packages, so the result is
|
||||
# a two-platform node_modules with a host-authored .package-lock.json — sometimes it works,
|
||||
# sometimes the build fails with a missing native binding, and which one you get depends on
|
||||
# whether someone ran `npm install` locally.
|
||||
node_modules/
|
||||
|
||||
# Build outputs — regenerated inside the image; stale copies only confuse the layer cache.
|
||||
.svelte-kit/
|
||||
build/
|
||||
|
||||
# Never let a real .env reach an image layer.
|
||||
.env
|
||||
.env.*
|
||||
@@ -13,8 +13,12 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN npm install --omit=dev
|
||||
# The lockfile comes along so the runtime deps are the ones that were tested. With only
|
||||
# package.json here, `npm install` re-resolved the `^`-ranged dependencies at build time, so an
|
||||
# image rebuilt days later could ship different code than the one that passed the checks — the
|
||||
# kind of difference that only shows up on the event server.
|
||||
COPY --from=builder /app/package.json /app/package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Run as the image's built-in non-root `node` user.
|
||||
RUN chown -R node:node /app
|
||||
|
||||
@@ -58,10 +58,16 @@ export default ts.config(
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'.svelte-kit/',
|
||||
'build/',
|
||||
'dist/',
|
||||
'node_modules/',
|
||||
// `**/` matters: these patterns are relative to this config's directory, so a bare
|
||||
// `.svelte-kit/` matched only the top-level one and left `export-viewer/.svelte-kit/`
|
||||
// — SvelteKit's GENERATED types, gitignored but present after a viewer build — being
|
||||
// linted, for 10 errors in code nobody wrote. Rebuilding the viewer is a required
|
||||
// step whenever its source changes (it is embedded in the binary via include_dir!),
|
||||
// so that directory exists in any normal working tree.
|
||||
'**/.svelte-kit/',
|
||||
'**/build/',
|
||||
'**/dist/',
|
||||
'**/node_modules/',
|
||||
'static/export-viewer/',
|
||||
'*.config.js',
|
||||
'*.config.ts'
|
||||
|
||||
7
frontend/export-viewer/package-lock.json
generated
7
frontend/export-viewer/package-lock.json
generated
@@ -901,7 +901,6 @@
|
||||
"integrity": "sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@@ -944,7 +943,6 @@
|
||||
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
@@ -1291,7 +1289,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1904,7 +1901,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2024,7 +2020,6 @@
|
||||
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -2114,7 +2109,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2129,7 +2123,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -57,22 +57,25 @@
|
||||
...allTags.slice(0, 3).map(([t]) => ({ type: 'tag' as const, value: t }))
|
||||
];
|
||||
}
|
||||
// Once the user has TYPED, every match is offered — no `.slice()`. Capping matters
|
||||
// more here than in the live app, for two reasons: this viewer holds the WHOLE event
|
||||
// (the export queries are uncapped), so the tag set is far larger than any one feed
|
||||
// page; and the keepsake is a frozen artifact — a guest opening it years later has no
|
||||
// server to re-query and no "scroll to load more" escape hatch. A silently dropped
|
||||
// match would simply be unreachable forever. The dropdown scrolls instead.
|
||||
if (q.startsWith('#')) {
|
||||
const prefix = q.slice(1).toLowerCase();
|
||||
return allTags
|
||||
.filter(([t]) => t.startsWith(prefix))
|
||||
.slice(0, 8)
|
||||
.map(([t]) => ({ type: 'tag' as const, value: t }));
|
||||
}
|
||||
const lower = q.toLowerCase();
|
||||
return [
|
||||
...allUploaders
|
||||
.filter((u) => u.toLowerCase().includes(lower))
|
||||
.slice(0, 4)
|
||||
.map((u) => ({ type: 'user' as const, value: u })),
|
||||
...allTags
|
||||
.filter(([t]) => t.includes(lower))
|
||||
.slice(0, 4)
|
||||
.map(([t]) => ({ type: 'tag' as const, value: t }))
|
||||
];
|
||||
});
|
||||
@@ -381,13 +384,24 @@
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
{#if showAutocomplete && suggestions.length > 0}
|
||||
<!-- `preventDefault` on mousedown suppresses the input's blur, which would
|
||||
otherwise close this dropdown (see the input's `onblur`) before a click
|
||||
could land. That is what lets the commit live on `onclick` — the LAST
|
||||
event — instead of `onmousedown`. Committing on press meant sliding off
|
||||
a suggestion you didn't mean to hit still applied the filter, and
|
||||
`selectSuggestion` sets `showAutocomplete = false`, so the button
|
||||
destroyed itself on the first event of the click sequence. Ported from
|
||||
the live feed page, which fixed this; the viewer never got it. -->
|
||||
<div
|
||||
class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg"
|
||||
class="absolute left-0 right-0 top-full z-50 mt-1 max-h-72 overflow-y-auto overscroll-contain rounded-xl border border-gray-200 bg-white shadow-lg"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#each suggestions as item (item.type + ':' + item.value)}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
onmousedown={() => selectSuggestion(item)}
|
||||
onclick={() => selectSuggestion(item)}
|
||||
>
|
||||
{#if item.type === 'user'}
|
||||
<svg
|
||||
|
||||
@@ -46,6 +46,40 @@
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Boot backstop. With `ssr = false` the page is EMPTY until the bundle mounts, so
|
||||
// anything that stops it mounting leaves the guest on the spinner below forever:
|
||||
// a chunk 404 after a redeploy, a dead uplink, or untranspiled syntax on an old
|
||||
// phone. There is no message, no reload control, and in a standalone PWA no URL
|
||||
// bar to escape from — the app is simply bricked for that guest, all evening.
|
||||
//
|
||||
// A TIMEOUT, deliberately, not feature detection: a SyntaxError in the bundle is
|
||||
// invisible to any capability check, whereas "still not painted" catches every
|
||||
// cause at once. The root layout removes #app-boot on mount, so its continued
|
||||
// presence IS the failure signal and nothing needs to cancel this.
|
||||
//
|
||||
// 15s is well beyond a slow-but-healthy load on venue wifi (the bundle is ~130 kB
|
||||
// gzipped); erring long matters more than erring short, because a false positive
|
||||
// here would tell a guest something is broken while it is quietly working.
|
||||
setTimeout(function () {
|
||||
var boot = document.getElementById('app-boot');
|
||||
if (!boot) return; // app mounted — nothing to do
|
||||
// Classes, not inline styles. The panel must stay legible in dark mode, and
|
||||
// the only stylesheet guaranteed to be present here is the inline <style>
|
||||
// below — a boot failure includes the case where the app's own CSS 404'd
|
||||
// too, so nothing may set a text colour. Inline styles cannot express the
|
||||
// `html.dark` variant, and the first version's un-coloured heading rendered
|
||||
// as the UA default black on the #100f0f dark background: invisible, on the
|
||||
// one screen whose entire job is to be readable.
|
||||
boot.innerHTML =
|
||||
'<div class="app-boot__fail">' +
|
||||
'<p class="app-boot__fail-title">Die App konnte nicht geladen werden</p>' +
|
||||
'<p class="app-boot__fail-text">Bitte prüf deine Verbindung und lade die Seite neu.</p>' +
|
||||
'<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(); });
|
||||
}, 15000);
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
@@ -65,6 +99,14 @@
|
||||
<span class="app-boot__spinner"></span>
|
||||
<span class="app-boot__label">EventSnap</span>
|
||||
</div>
|
||||
<!-- The app is client-rendered end to end, so with JS off there is nothing at all to
|
||||
show. Say so, rather than leaving a permanent spinner over a blank page. -->
|
||||
<noscript>
|
||||
<div class="app-boot__noscript">
|
||||
<p><strong>EventSnap braucht JavaScript</strong></p>
|
||||
<p>Bitte aktiviere JavaScript im Browser und lade die Seite neu.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<style>
|
||||
#app-boot {
|
||||
position: fixed;
|
||||
@@ -103,6 +145,66 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* Boot-failure panel, swapped in by the backstop timer above. Every colour is
|
||||
stated explicitly in both themes: when this renders, the app's own stylesheet
|
||||
may well be one of the things that failed to load. */
|
||||
.app-boot__fail {
|
||||
max-width: 20rem;
|
||||
text-align: center;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.app-boot__fail-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1c1b1a;
|
||||
}
|
||||
.app-boot__fail-text {
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
color: #545350;
|
||||
}
|
||||
html.dark .app-boot__fail-title {
|
||||
color: #f2f0ed;
|
||||
}
|
||||
html.dark .app-boot__fail-text {
|
||||
color: #a6a4a1;
|
||||
}
|
||||
.app-boot__fail-btn {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #8a6a2b;
|
||||
color: #fff;
|
||||
}
|
||||
html.dark .app-boot__fail-btn {
|
||||
background: #c6a24a;
|
||||
color: #100f0f;
|
||||
}
|
||||
/* Sits above #app-boot, which is `position: fixed` and would otherwise cover it. */
|
||||
.app-boot__noscript {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #faf9f7;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #545350;
|
||||
}
|
||||
html.dark .app-boot__noscript {
|
||||
background: #100f0f;
|
||||
color: #a6a4a1;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,25 @@ 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'];
|
||||
|
||||
/**
|
||||
* Send a guest whose session died back to the join screen.
|
||||
*
|
||||
* 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
|
||||
* create an import cycle. A full document load is also the more correct behaviour after a
|
||||
* session loss — it resets every module-level store, which is exactly what we want, and the
|
||||
* queued upload blobs live in IndexedDB so they survive it.
|
||||
*/
|
||||
function redirectToJoin(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const path = window.location.pathname;
|
||||
if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return;
|
||||
window.location.assign('/join');
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getToken();
|
||||
@@ -27,6 +46,13 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
|
||||
// Abort hung requests so a dead connection surfaces as a friendly error
|
||||
// instead of a spinner that never resolves.
|
||||
//
|
||||
// The timer must stay armed until the BODY has been read, not just the headers.
|
||||
// `fetch` resolves as soon as the response head arrives, so clearing it in a `finally`
|
||||
// around the fetch left `res.text()` below completely uncovered — and no longer
|
||||
// abortable, since the controller had already been disarmed. An upstream that sends
|
||||
// headers and then stalls the body (the shape of a half-dead proxy, or of the pool
|
||||
// saturation this same release adds shedding for) hung that call forever.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
@@ -38,6 +64,26 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
|
||||
}
|
||||
throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.');
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
// Must clear on this path too, or every no-content request (logout, delete, like)
|
||||
// leaks a live 20 s timer.
|
||||
clearTimeout(timer);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
|
||||
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await res.text();
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
|
||||
@@ -47,13 +93,6 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
|
||||
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
|
||||
const raw = await res.text();
|
||||
let data: { error?: string; message?: string } | unknown = null;
|
||||
if (raw) {
|
||||
try {
|
||||
@@ -69,6 +108,17 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
// simply get a 403 "gesperrt" toast on writes.
|
||||
if (res.status === 401) {
|
||||
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
|
||||
// onMount (which does not re-run), and a standalone PWA has no URL bar — so
|
||||
// there is no way back to /join. Real triggers mid-event are a host PIN reset
|
||||
// (which revokes that guest's sessions) and a redeployed JWT_SECRET.
|
||||
// Queued upload blobs survive in IndexedDB and are picked up again after
|
||||
// re-joining — via the `onSetAuth` hook in upload-queue.ts, NOT the boot-time
|
||||
// call in +layout.svelte: this redirect lands on /join with no token, so the
|
||||
// layout's `if (getToken())` skips it, and the subsequent recover navigates
|
||||
// with `goto()`, which never re-runs `onMount`.
|
||||
redirectToJoin();
|
||||
}
|
||||
const d = (data ?? {}) as { error?: string; message?: string };
|
||||
throw new ApiError(
|
||||
|
||||
18
frontend/src/lib/ban-store.ts
Normal file
18
frontend/src/lib/ban-store.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Whether the CURRENT guest is banned.
|
||||
//
|
||||
// The ban is deliberately read-only (see backend `handlers/host.rs`): the guest keeps the feed
|
||||
// and the released keepsake, but every write — upload, like, comment, delete — is refused with
|
||||
// 403 "Du bist gesperrt.". Until this store existed the client had no idea, so all of those
|
||||
// controls rendered fully enabled and the guest discovered the ban one error toast at a time,
|
||||
// with nobody at the party to ask. The event-lock case has always been surfaced live for
|
||||
// exactly this reason (`event-state-store`); a ban simply never was.
|
||||
//
|
||||
// Seeded from `/me/context` (root layout on boot, and `refreshEventState` afterwards). Reset on
|
||||
// identity change so a shared device does not carry one guest's ban to the next.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { onClearAuth } from './auth';
|
||||
|
||||
export const isBanned = writable(false);
|
||||
|
||||
onClearAuth(() => isBanned.set(false));
|
||||
@@ -5,9 +5,37 @@
|
||||
interface Props {
|
||||
oncapture: (blob: Blob, type: 'photo' | 'video') => void;
|
||||
onclose: () => void;
|
||||
/// Fired once, when the live preview is actually on screen — not when the camera was
|
||||
/// merely requested. The caller uses it to dismiss whatever launched the camera.
|
||||
///
|
||||
/// Gated on the preview rather than on `getUserMedia` resolving, because the two differ
|
||||
/// exactly where it matters: if permission is denied, or the page is not a secure context,
|
||||
/// this never fires and the caller's UI stays put behind the error panel — so "Schließen"
|
||||
/// returns the guest to where they were instead of dropping them somewhere they never
|
||||
/// asked to be.
|
||||
onready?: () => void;
|
||||
/// Fired when the guest gives up on the camera and wants the file picker instead.
|
||||
///
|
||||
/// The error panel used to offer only "Erneut versuchen" and "Schließen", which dead-ends
|
||||
/// the whole upload journey for anyone whose `getUserMedia` cannot succeed: an in-app
|
||||
/// browser (the WhatsApp/Instagram webview a shared QR link opens in) may never grant it,
|
||||
/// and the panel's advice to change "Browsereinstellungen" refers to settings that do not
|
||||
/// exist there. Retrying cannot help those guests; picking a photo can.
|
||||
onpickfile?: () => void;
|
||||
}
|
||||
|
||||
let { oncapture, onclose }: Props = $props();
|
||||
let { oncapture, onclose, onready, onpickfile }: Props = $props();
|
||||
|
||||
/// `onready` is a one-shot. `startCamera` re-runs on every lens flip and photo/video switch,
|
||||
/// and re-announcing "the camera is ready" mid-session would ask the caller to redo a
|
||||
/// dismissal it has already done.
|
||||
let announcedReady = false;
|
||||
|
||||
function handlePreviewLive() {
|
||||
if (announcedReady) return;
|
||||
announcedReady = true;
|
||||
onready?.();
|
||||
}
|
||||
|
||||
let videoEl: HTMLVideoElement = $state()!;
|
||||
let canvasEl: HTMLCanvasElement = $state()!;
|
||||
@@ -157,7 +185,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-black">
|
||||
<!-- z-[60], above the z-50 upload sheet that launches this. The sheet stays mounted for its
|
||||
translate-y animation and is rendered AFTER this element, so at an equal z-index it painted
|
||||
on top: the shutter button sat underneath the Galerie/Kamera panel and the only way to reach
|
||||
it was the phone's back button. The sheet also dismisses itself once the preview is live
|
||||
(see `onready`), but that is a behaviour; this is the guarantee — the capture controls are
|
||||
reachable during the permission prompt and on the error panel too, when nothing has been
|
||||
dismissed yet. -->
|
||||
<div class="fixed inset-0 z-[60] flex flex-col bg-black">
|
||||
<!-- Camera preview -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
{#if error}
|
||||
@@ -177,13 +212,21 @@
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-white">{error}</p>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
onclick={startCamera}
|
||||
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
{#if onpickfile}
|
||||
<button
|
||||
onclick={onpickfile}
|
||||
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Aus Galerie wählen
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white">
|
||||
Schließen
|
||||
</button>
|
||||
@@ -197,6 +240,7 @@
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
onloadedmetadata={handlePreviewLive}
|
||||
class="h-full w-full object-cover {facingMode === 'user' ? 'scale-x-[-1]' : ''}"
|
||||
></video>
|
||||
|
||||
|
||||
@@ -54,8 +54,12 @@
|
||||
disabled={busy}
|
||||
tabindex="-1"
|
||||
></button>
|
||||
<!-- Bottom-anchored, so a sheet taller than the viewport loses its TOP first: the title
|
||||
(the only thing naming what you are confirming) slides off-screen while the danger
|
||||
button stays. A long message plus a landscape phone is enough. Cap the height and
|
||||
scroll instead; `overscroll-contain` keeps the page behind from scrolling with it. -->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
|
||||
class="fixed inset-x-0 bottom-0 z-50 max-h-[85dvh] overflow-y-auto overscroll-contain rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
|
||||
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -27,6 +27,53 @@
|
||||
|
||||
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
||||
|
||||
// Video cards show a poster if one exists; `pickMediaUrl` is not used here because it is
|
||||
// mime-agnostic and would hand back the raw MP4 for an <img>.
|
||||
const posterSrc = $derived(upload.thumbnail_url ?? upload.preview_url ?? '');
|
||||
|
||||
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A failed <img> rendered as an empty grey box with `alt=""` — no icon, no message,
|
||||
// nothing to tap. And the failure is routine rather than exotic: `pickMediaUrl` falls
|
||||
// back to `/original` whenever preview AND thumbnail are still null, i.e. for everything
|
||||
// still compressing, which in a newest-first feed is the card at the top during every
|
||||
// burst. That also makes the cause usually TRANSIENT, so one delayed retry of the same
|
||||
// URL recovers most of them; the nonce exists because the browser would otherwise replay
|
||||
// its cached failure rather than re-request. Two strikes and we fall through to the
|
||||
// placeholder this card already draws for "no derivative yet".
|
||||
const MEDIA_RETRY_MS = 4000;
|
||||
let mediaRetryNonce = $state(0);
|
||||
let mediaFailed = $state(false);
|
||||
let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function withNonce(url: string): string {
|
||||
if (!url || !mediaRetryNonce) return url;
|
||||
return `${url}${url.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`;
|
||||
}
|
||||
|
||||
const displaySrc = $derived(withNonce(isVideo(upload.mime_type) ? posterSrc : mediaSrc));
|
||||
|
||||
// A url change — the SSE `upload-processed` swapping the original for a real preview is
|
||||
// the common one — deserves a clean attempt rather than inheriting the old verdict.
|
||||
$effect(() => {
|
||||
void mediaSrc;
|
||||
void posterSrc;
|
||||
mediaRetryNonce = 0;
|
||||
mediaFailed = false;
|
||||
});
|
||||
|
||||
function handleMediaError() {
|
||||
if (mediaRetryNonce) {
|
||||
mediaFailed = true;
|
||||
return;
|
||||
}
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
mediaRetryTimer = setTimeout(() => {
|
||||
mediaRetryTimer = null;
|
||||
mediaRetryNonce = Date.now();
|
||||
}, MEDIA_RETRY_MS);
|
||||
}
|
||||
|
||||
function relativeTime(iso: string, nowMs: number): string {
|
||||
const diff = nowMs - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -61,6 +108,7 @@
|
||||
// card mid-animation) so it can't fire against a stale component.
|
||||
onDestroy(() => {
|
||||
if (burstTimer) clearTimeout(burstTimer);
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -124,13 +172,14 @@
|
||||
<HeartBurst active={heartBurst} />
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="relative aspect-video w-full bg-gray-900">
|
||||
{#if upload.thumbnail_url || upload.preview_url}
|
||||
{#if posterSrc && !mediaFailed}
|
||||
<img
|
||||
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="h-full w-full object-cover opacity-80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
@@ -143,17 +192,18 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mediaSrc}
|
||||
{:else if mediaSrc && !mediaFailed}
|
||||
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
|
||||
to height 0 and reflow as images stream in. The uncropped original is one
|
||||
tap away in the lightbox. -->
|
||||
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
|
||||
<img
|
||||
src={mediaSrc}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -209,6 +259,7 @@
|
||||
{#if $commentsEnabled}
|
||||
<button
|
||||
onclick={() => oncomment(upload.id)}
|
||||
aria-label="Kommentare anzeigen"
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
import { api } from '$lib/api';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
@@ -14,6 +15,7 @@
|
||||
import { vibrate } from '$lib/haptics';
|
||||
import { commentsEnabled } from '$lib/event-config-store';
|
||||
import HeartBurst from './HeartBurst.svelte';
|
||||
import ConfirmSheet from './ConfirmSheet.svelte';
|
||||
|
||||
const COMMENT_MAX = 500;
|
||||
|
||||
@@ -30,9 +32,41 @@
|
||||
upload: FeedUpload;
|
||||
onclose: () => void;
|
||||
onlike: (id: string) => void;
|
||||
/** Whether a previous/next item exists in the caller's list. Controls the chevrons. */
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
onprev?: () => void;
|
||||
onnext?: () => void;
|
||||
}
|
||||
|
||||
let { upload, onclose, onlike }: Props = $props();
|
||||
let {
|
||||
upload,
|
||||
onclose,
|
||||
onlike,
|
||||
hasPrev = false,
|
||||
hasNext = false,
|
||||
onprev,
|
||||
onnext
|
||||
}: Props = $props();
|
||||
|
||||
// The system back gesture must close the photo, not leave the app.
|
||||
//
|
||||
// On a phone, back is how you dismiss a full-screen view — but this modal is component
|
||||
// state, not a route, so back navigated away from /feed entirely and the guest lost their
|
||||
// scroll position. Since the modal is only mounted while open, this subscription is
|
||||
// active exactly while it is open (Svelte tears it down on destroy).
|
||||
//
|
||||
// Cancelling a popstate navigation is explicitly supported: SvelteKit counteracts it with
|
||||
// `history.go`, restoring the entry we just left (see its client runtime, "if a
|
||||
// popstate-driven navigation is cancelled"). So the URL stays put and the photo closes.
|
||||
// Only `popstate` is intercepted — an in-app link or a programmatic `goto` should still
|
||||
// navigate, closing the modal with the page.
|
||||
beforeNavigate((nav) => {
|
||||
if (nav.type === 'popstate') {
|
||||
nav.cancel();
|
||||
onclose();
|
||||
}
|
||||
});
|
||||
|
||||
let comments = $state<CommentDto[]>([]);
|
||||
let newComment = $state('');
|
||||
@@ -56,6 +90,115 @@
|
||||
: pickMediaUrl($dataMode, upload)
|
||||
);
|
||||
|
||||
// ── Prev / next ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Until now the only way out of a photo was to close the modal, so browsing hundreds
|
||||
// of party photos meant open-close-open-close all evening — while FEATURES.md and
|
||||
// USER_JOURNEYS §8.6/§17 both promised swipe navigation. Three affordances, because
|
||||
// each covers a different user: on-screen chevrons are the discoverable baseline (a
|
||||
// swipe nobody knows about is not a feature), arrow keys for the projector laptop,
|
||||
// swipe for the phone in one hand.
|
||||
let prevBtn = $state<HTMLButtonElement | undefined>();
|
||||
let nextBtn = $state<HTMLButtonElement | undefined>();
|
||||
let closeBtn = $state<HTMLButtonElement | undefined>();
|
||||
|
||||
async function step(dir: -1 | 1) {
|
||||
if (dir === -1) {
|
||||
if (!hasPrev) return;
|
||||
onprev?.();
|
||||
} else {
|
||||
if (!hasNext) return;
|
||||
onnext?.();
|
||||
}
|
||||
// Stepping onto the first/last item removes the chevron that was just used. A
|
||||
// focused node disappearing drops focus to <body> — outside `focusTrap`'s node — so
|
||||
// the next Tab would leave for the browser chrome instead of cycling the modal.
|
||||
// Hand focus to whichever control survived.
|
||||
await tick();
|
||||
if (document.activeElement === document.body) {
|
||||
const fallback = dir === -1 ? nextBtn : prevBtn;
|
||||
(fallback ?? closeBtn)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
// Never hijack caret movement inside the comment composer.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
void step(e.key === 'ArrowLeft' ? -1 : 1);
|
||||
}
|
||||
|
||||
// Swipe. Bound to the media wrapper rather than the whole modal so it cannot fight the
|
||||
// comment list's vertical scroll, and read on touchend rather than continuously so the
|
||||
// browser keeps ownership of the gesture until we know it was horizontal.
|
||||
const SWIPE_MIN_PX = 50;
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchTracking = false;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
// A second finger means pinch-zoom, which must not turn into a page change.
|
||||
touchTracking = e.touches.length === 1;
|
||||
if (!touchTracking) return;
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
if (!touchTracking) return;
|
||||
touchTracking = false;
|
||||
const t = e.changedTouches[0];
|
||||
if (!t) return;
|
||||
const dx = t.clientX - touchStartX;
|
||||
const dy = t.clientY - touchStartY;
|
||||
// Require a decisively HORIZONTAL gesture: the modal body scrolls vertically, so a
|
||||
// diagonal drag must not steal a photo change from someone who meant to scroll.
|
||||
if (Math.abs(dx) < SWIPE_MIN_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
void step(dx < 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A failed <img> used to render as an empty black box with `alt=""` — no icon, no
|
||||
// message, nothing to tap. And the failure is common rather than exotic: `pickMediaUrl`
|
||||
// falls back to `/original` whenever preview AND thumbnail are still null, i.e. for
|
||||
// everything still compressing, which in a newest-first feed is the top of the list
|
||||
// during every burst. That also makes the cause usually TRANSIENT, so one delayed retry
|
||||
// of the same URL recovers most of them; the nonce is there because the browser would
|
||||
// otherwise replay its cached failure rather than re-request.
|
||||
const MEDIA_RETRY_MS = 4000;
|
||||
let mediaRetryNonce = $state(0);
|
||||
let mediaFailed = $state(false);
|
||||
let mediaRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const displaySrc = $derived(
|
||||
mediaRetryNonce
|
||||
? `${mediaSrc}${mediaSrc.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`
|
||||
: mediaSrc
|
||||
);
|
||||
|
||||
// A different photo — or the same one after `upload-processed` swapped the original for
|
||||
// a real preview — deserves a clean attempt rather than inheriting the old verdict.
|
||||
$effect(() => {
|
||||
void mediaSrc;
|
||||
mediaRetryNonce = 0;
|
||||
mediaFailed = false;
|
||||
});
|
||||
|
||||
function handleMediaError() {
|
||||
if (mediaRetryNonce) {
|
||||
mediaFailed = true;
|
||||
return;
|
||||
}
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
mediaRetryTimer = setTimeout(() => {
|
||||
mediaRetryTimer = null;
|
||||
mediaRetryNonce = Date.now();
|
||||
}, MEDIA_RETRY_MS);
|
||||
}
|
||||
|
||||
function triggerHeartBurst() {
|
||||
heartBurst = true;
|
||||
vibrate(10);
|
||||
@@ -90,6 +233,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (burstTimer) clearTimeout(burstTimer);
|
||||
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
|
||||
unsubCommentDeleted();
|
||||
unsubNewComment();
|
||||
});
|
||||
@@ -128,16 +272,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `asHost` routes to the moderation endpoint. The guest route only ever deletes the
|
||||
* caller's OWN comment, and it refuses a banned author outright — so without this a
|
||||
* host who banned an abusive guest was left with the abuse still on screen and no way
|
||||
* to remove it, since the ban itself blocks the author's own delete.
|
||||
*/
|
||||
async function deleteComment(id: string, asHost: boolean) {
|
||||
// Comment deletion is permanent and had NO confirmation at all — from a ~14px ✕ glyph
|
||||
// sitting a few pixels from the comment text, which a host could also use on someone
|
||||
// else's words. Deleting a POST two components away has always gone through ConfirmSheet;
|
||||
// this is the same irreversible act and now takes the same route.
|
||||
// `asHost` routes to the moderation endpoint: the guest route only ever deletes the
|
||||
// caller's OWN comment, and it refuses a banned author outright — so without it a host
|
||||
// who banned an abusive guest was left with the abuse on screen and no way to remove it,
|
||||
// since the ban itself blocks the author's own delete.
|
||||
let pendingCommentDelete = $state<{ id: string; asHost: boolean } | null>(null);
|
||||
|
||||
async function confirmDeleteComment() {
|
||||
const pending = pendingCommentDelete;
|
||||
if (!pending) return;
|
||||
pendingCommentDelete = null;
|
||||
try {
|
||||
await api.delete(asHost ? `/host/comment/${id}` : `/comment/${id}`);
|
||||
comments = comments.filter((c) => c.id !== id);
|
||||
await api.delete(pending.asHost ? `/host/comment/${pending.id}` : `/comment/${pending.id}`);
|
||||
comments = comments.filter((c) => c.id !== pending.id);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
@@ -157,6 +308,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
role="dialog"
|
||||
@@ -166,12 +319,26 @@
|
||||
use:scrollLock
|
||||
use:modalInert
|
||||
>
|
||||
<!-- Backdrop — real <button> so keyboard / switch-control users get parity, matching
|
||||
ContextSheet / ConfirmSheet / UploadSheet. The lightbox was the one overlay in the app
|
||||
that did NOT close on an outside tap, which is why it felt broken: every other sheet
|
||||
does. `tabindex="-1"` keeps it out of the tab order, so it adds no stop in front of the
|
||||
photo; Escape still closes via `focusTrap` and the labelled ✕ stays the keyboard route.
|
||||
It sits behind the content, which is `relative` for exactly that reason. -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 cursor-default"
|
||||
onclick={onclose}
|
||||
tabindex="-1"
|
||||
aria-label="Schließen"
|
||||
></button>
|
||||
<div
|
||||
class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||
class="relative flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||
>
|
||||
<!-- Media -->
|
||||
<div class="relative bg-black">
|
||||
<button
|
||||
bind:this={closeBtn}
|
||||
onclick={onclose}
|
||||
aria-label="Schließen"
|
||||
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
@@ -185,7 +352,59 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
||||
|
||||
<!-- Chevrons sit OUTSIDE the doubletap wrapper below, so tapping one can never be
|
||||
read as half of a double-tap and fire a like on the photo you are leaving. -->
|
||||
{#if hasPrev}
|
||||
<button
|
||||
bind:this={prevBtn}
|
||||
type="button"
|
||||
onclick={() => void step(-1)}
|
||||
aria-label="Vorheriges Foto"
|
||||
class="absolute left-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasNext}
|
||||
<button
|
||||
bind:this={nextBtn}
|
||||
type="button"
|
||||
onclick={() => void step(1)}
|
||||
aria-label="Nächstes Foto"
|
||||
class="absolute right-2 top-1/2 z-10 inline-flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- `role="group"` is here for the touch handlers, not for semantics: swipe is a
|
||||
redundant shortcut for the labelled chevrons above and the arrow keys, so
|
||||
assistive tech loses nothing by treating this as a plain grouping. -->
|
||||
<div
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label="Foto – wischen zum Blättern"
|
||||
use:doubletap
|
||||
ondoubletap={triggerHeartBurst}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
ontouchcancel={() => (touchTracking = false)}
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<video
|
||||
src={mediaSrc}
|
||||
@@ -195,12 +414,29 @@
|
||||
class="max-h-[60vh] w-full object-contain"
|
||||
poster={upload.thumbnail_url ?? undefined}
|
||||
></video>
|
||||
{:else if mediaFailed}
|
||||
<!-- Both attempts failed. Anything is better than the empty black box this used
|
||||
to leave behind, which read as a broken app rather than a missing file. -->
|
||||
<div
|
||||
class="flex h-[40vh] flex-col items-center justify-center gap-2 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">Bild konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={mediaSrc}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="max-h-[60vh] w-full object-contain select-none"
|
||||
class="max-h-[60vh] w-full select-none object-contain"
|
||||
draggable="false"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -211,18 +447,20 @@
|
||||
<!-- Info + Comments -->
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- `min-w-0` + `truncate`: without them a long display name (guests type their
|
||||
own) is unshrinkable and pushes the like button off the right edge. -->
|
||||
<div class="flex min-w-0 items-baseline gap-2">
|
||||
<span id="lightbox-title" class="truncate font-medium text-gray-900 dark:text-gray-100"
|
||||
>{upload.uploader_name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
||||
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||
>{formatTime(upload.created_at)}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => onlike(upload.id)}
|
||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||
class="flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
@@ -269,22 +507,32 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if comment.user_id === userId || $isStaff}
|
||||
<!-- A trash glyph, not a ✕ (which reads as "dismiss"), and a real 44px tap
|
||||
target: the old 14px icon sat ~4px from the comment text, so a thumb
|
||||
aiming at the text destroyed the comment instead. -->
|
||||
<button
|
||||
onclick={() => deleteComment(comment.id, comment.user_id !== userId)}
|
||||
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
aria-label={comment.user_id === userId ? 'Löschen' : 'Kommentar entfernen'}
|
||||
type="button"
|
||||
onclick={() =>
|
||||
(pendingCommentDelete = {
|
||||
id: comment.id,
|
||||
asHost: comment.user_id !== userId
|
||||
})}
|
||||
class="-m-1.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||
aria-label={comment.user_id === userId
|
||||
? 'Kommentar löschen'
|
||||
: 'Kommentar entfernen'}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
d="M14.74 9l-.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>
|
||||
</button>
|
||||
@@ -335,3 +583,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Same branded confirmation the post-delete flow uses. -->
|
||||
<ConfirmSheet
|
||||
open={pendingCommentDelete !== null}
|
||||
title={pendingCommentDelete?.asHost ? 'Kommentar entfernen?' : 'Kommentar löschen?'}
|
||||
message={pendingCommentDelete?.asHost
|
||||
? 'Der Kommentar verschwindet für alle Gäste. Diese Aktion kann nicht rückgängig gemacht werden.'
|
||||
: 'Diese Aktion kann nicht rückgängig gemacht werden.'}
|
||||
confirmLabel={pendingCommentDelete?.asHost ? 'Entfernen' : 'Löschen'}
|
||||
tone="danger"
|
||||
onConfirm={confirmDeleteComment}
|
||||
onCancel={() => (pendingCommentDelete = null)}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,17 @@
|
||||
use:focusTrap={{ onclose: onClose }}
|
||||
use:scrollLock
|
||||
>
|
||||
<div class="card pointer-events-auto w-full max-w-sm p-6 shadow-xl">
|
||||
<!-- The card must never grow past the viewport. `items-center` centres it, so an
|
||||
over-tall card is clipped EQUALLY top and bottom — the confirm buttons at the
|
||||
bottom disappear at the same moment the heading does, and there is no scrollbar
|
||||
to hint that anything is missing. That is the worst possible failure for a
|
||||
dialog whose only exits are its own buttons (the ban confirm, the one-time PIN).
|
||||
`max-h-full` resolves against the padded fixed parent, so the 1rem inset is
|
||||
preserved; `overscroll-contain` keeps a scroll gesture inside the dialog instead
|
||||
of scrolling the page behind it. -->
|
||||
<div
|
||||
class="card pointer-events-auto max-h-full w-full max-w-sm overflow-y-auto overscroll-contain p-6 shadow-xl"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||
import { vibrate } from '$lib/haptics';
|
||||
|
||||
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
|
||||
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
||||
|
||||
type Step =
|
||||
| { kind: 'text'; icon: string; title: string; body: string }
|
||||
@@ -31,7 +30,7 @@
|
||||
kind: 'text',
|
||||
icon: '⬆️',
|
||||
title: 'Fotos & Videos hochladen',
|
||||
body: 'Tippe auf den Plus-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
||||
body: 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
@@ -55,8 +54,8 @@
|
||||
icon: '🔑',
|
||||
title: 'Deinen PIN merken!',
|
||||
body:
|
||||
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.' +
|
||||
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto".' : '')
|
||||
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto“ zu finden.' +
|
||||
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -74,7 +73,7 @@
|
||||
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||
];
|
||||
|
||||
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
||||
if (browser && !hasSeenGuide()) {
|
||||
visible = true;
|
||||
}
|
||||
|
||||
@@ -87,7 +86,7 @@
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (browser) localStorage.setItem(GUIDE_SEEN_KEY, '1');
|
||||
markGuideSeen();
|
||||
vibrate([0, 8, 60, 8]);
|
||||
visible = false;
|
||||
}
|
||||
@@ -100,8 +99,12 @@
|
||||
{#if visible}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<!-- Same viewport guard as Modal/ConfirmSheet: the theme step (three option rows) is the
|
||||
tallest thing a first-time guest sees, and this dialog's only exits are its own
|
||||
"Weiter"/"Überspringen" buttons at the very bottom. Clipping them strands the guest
|
||||
on step one of a modal they cannot dismiss. -->
|
||||
<div
|
||||
class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl"
|
||||
class="max-h-[90dvh] w-full max-w-sm overflow-y-auto overscroll-contain rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
@@ -150,7 +153,7 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Du kannst die Wahl jederzeit unter „Mein Konto" ändern.
|
||||
Du kannst die Wahl jederzeit unter „Mein Konto“ ändern.
|
||||
</p>
|
||||
<div class="space-y-2 text-left" role="radiogroup" aria-label="Design">
|
||||
{#each THEME_OPTIONS as opt (opt.value)}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
isProcessing,
|
||||
retryItem,
|
||||
removeItem,
|
||||
cancelItem,
|
||||
clearCompleted,
|
||||
rateLimitRetryAt
|
||||
} from '$lib/upload-queue';
|
||||
@@ -121,6 +122,27 @@
|
||||
Erneut
|
||||
</button>
|
||||
{/if}
|
||||
<!-- An in-flight item gets a cancel, not a remove. Until this existed an
|
||||
upload that stalled on a half-open connection had NO control at all:
|
||||
it holds the queue's only slot, so nothing else drains either, and
|
||||
the guest's only escape was force-quitting the app. Cancelling parks
|
||||
the item as retryable with its file intact. -->
|
||||
{#if item.status === 'uploading'}
|
||||
<button
|
||||
onclick={() => cancelItem(item.id)}
|
||||
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
aria-label="Upload abbrechen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.status === 'done' || item.status === 'error' || item.status === 'blocked'}
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { uploadSheetOpen } from '$lib/ui-store';
|
||||
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
|
||||
import { pendingFiles } from '$lib/pending-upload-store';
|
||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
||||
import { commentsEnabled } from '$lib/event-config-store';
|
||||
import { isBanned } from '$lib/ban-store';
|
||||
|
||||
// A ban closes uploads just as hard as an event lock does — the backend refuses every
|
||||
// write from a banned user. Without this the read-only banner said uploading was off while
|
||||
// the FAB still opened the sheet, the camera still opened, files still staged, and only the
|
||||
// final POST 403'd: the guest burns their photo and their upload allowance on a rejection
|
||||
// nobody is around to explain.
|
||||
let banned = $derived($isBanned);
|
||||
// Uploads closed (event locked or gallery released) — show a lock notice instead of
|
||||
// the capture options, so a guest can't stage a photo that would just be rejected.
|
||||
let closed = $derived(uploadsClosed($eventState));
|
||||
let closed = $derived(banned || uploadsClosed($eventState));
|
||||
|
||||
let showCamera = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
@@ -49,6 +57,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFocus() {
|
||||
if (!returnFocus) return;
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
|
||||
@@ -59,13 +77,14 @@
|
||||
});
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
} else if (returnFocus) {
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
} else if (untrack(() => showCamera)) {
|
||||
// The camera closed the sheet from under us. Restoring focus now would put it on the
|
||||
// FAB *behind* a full-screen overlay, where a Tab could then walk the page underneath;
|
||||
// `handleCameraClose` does it once the overlay is gone. `untrack` because this branch
|
||||
// must not resubscribe the effect to `showCamera` — that would re-run the open branch
|
||||
// when the camera mounts and re-capture `returnFocus` as the sheet's own Kamera button.
|
||||
} else {
|
||||
restoreFocus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,6 +96,13 @@
|
||||
showCamera = true;
|
||||
}
|
||||
|
||||
// Close the sheet before navigating: it stays mounted for its translate-y animation,
|
||||
// so leaving it open would keep the backdrop and scroll lock over /upload.
|
||||
function openQueue() {
|
||||
close();
|
||||
void goto('/upload');
|
||||
}
|
||||
|
||||
async function handleFiles() {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -102,14 +128,41 @@
|
||||
await goto('/upload');
|
||||
}
|
||||
|
||||
// The camera preview is up, so the sheet has done its job — dismiss it. Leaving it open put
|
||||
// the Galerie/Kamera panel over the shutter button, and since it is `fixed` it could not be
|
||||
// scrolled away: the only escape was the phone's back button.
|
||||
//
|
||||
// Deliberately driven by the camera's own ready signal rather than by the tap on "Kamera".
|
||||
// Closing on the tap would dismiss the sheet before we know the camera will work at all, so a
|
||||
// denied permission — or an insecure context, where `getUserMedia` does not exist — would
|
||||
// leave the guest looking at an error panel with nothing behind it. This way the sheet is
|
||||
// still there if the camera never opens, and `handleCameraClose` returns them to it.
|
||||
function handleCameraReady() {
|
||||
close();
|
||||
}
|
||||
|
||||
function handleCameraClose() {
|
||||
showCamera = false;
|
||||
// `close()` normally restores focus to whatever opened the sheet, but when the camera
|
||||
// dismissed it that element was behind a full-screen overlay and the restore was skipped.
|
||||
// Do it now that the overlay is gone, so focus never ends up on <body>.
|
||||
restoreFocus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Camera (rendered outside sheet so it gets full viewport) -->
|
||||
{#if showCamera}
|
||||
<CameraCapture oncapture={handleCapture} onclose={handleCameraClose} />
|
||||
<CameraCapture
|
||||
oncapture={handleCapture}
|
||||
onclose={handleCameraClose}
|
||||
onready={handleCameraReady}
|
||||
onpickfile={() => {
|
||||
// Close the camera first, then open the OS picker — the sheet is still mounted
|
||||
// behind it, so this returns the guest to a working path instead of a dead end.
|
||||
showCamera = false;
|
||||
openGallery();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden file input -->
|
||||
@@ -160,16 +213,26 @@
|
||||
|
||||
<div class="space-y-3 px-4 pb-4 pt-2">
|
||||
{#if closed}
|
||||
<!-- Uploads closed: no capture options, just an explanation + dismiss. -->
|
||||
<!-- Uploads closed: no capture options, just an explanation + dismiss. A ban and an
|
||||
event lock both land here, but they need different copy — a banned guest keeps
|
||||
read access only, so promising them likes and comments would just set up the
|
||||
next 403. -->
|
||||
<div class="rounded-xl bg-amber-50 px-5 py-4 text-center dark:bg-amber-950/30">
|
||||
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen{$commentsEnabled
|
||||
? ', liken und kommentieren'
|
||||
: ' und liken'}.
|
||||
</p>
|
||||
{#if banned}
|
||||
<p class="font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||
Hochladen ist für dich deaktiviert. Du kannst weiterhin alle Fotos ansehen und die
|
||||
Galerie später herunterladen.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen{$commentsEnabled
|
||||
? ', liken und kommentieren'
|
||||
: ' und liken'}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={close} class="btn btn-secondary btn-block"> Schließen </button>
|
||||
{:else}
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
@@ -232,8 +295,62 @@
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cancel -->
|
||||
<button onclick={close} class="btn btn-secondary btn-block"> Abbrechen </button>
|
||||
<!-- The in-app-browser escape hatch.
|
||||
The join link travels through WhatsApp groups, and a link tapped inside one opens
|
||||
in that app's own browser rather than Safari or Chrome. There, the file picker and
|
||||
`getUserMedia` both depend on the host app having wired them up, and when they are
|
||||
not wired up the buttons above simply do nothing — no error, nothing to act on,
|
||||
with no operator to ask. Deliberately NOT user-agent sniffing: a sniff list is
|
||||
wrong for browsers it has never heard of, whereas a quiet standing hint costs a
|
||||
line of text and is never wrong. It lives here rather than in the root layout
|
||||
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
|
||||
off — a banner there would never render on the composer. -->
|
||||
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Queue access. The FAB badge is the ONLY signal a guest gets that an upload is
|
||||
pending or failed, and tapping it opens this sheet — not the queue. Without this
|
||||
entry the queue view (and with it the only retry button in the app) is reachable
|
||||
only by staging a NEW file, so a guest with a failed upload sees a red badge,
|
||||
three capture options, and no way to act on it. Shown in the closed/banned state
|
||||
too: that guest is the MOST likely to have items parked in the queue, and it is
|
||||
the only place they can clear the badge. -->
|
||||
{#if $uploadBadgeCount > 0}
|
||||
<button
|
||||
onclick={openQueue}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
<span
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Warteschlange</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$uploadBadgeCount}
|
||||
{$uploadBadgeCount === 1 ? 'Foto wartet' : 'Fotos warten'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button onclick={close} class="btn btn-secondary btn-block">
|
||||
{closed ? 'Schließen' : 'Abbrechen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
import { dataMode } from '$lib/data-mode-store';
|
||||
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||
import { commentsEnabled } from '$lib/event-config-store';
|
||||
import { longpress } from '$lib/actions/longpress';
|
||||
import FeedListCard from './FeedListCard.svelte';
|
||||
@@ -62,12 +62,74 @@
|
||||
return mime.startsWith('video/');
|
||||
}
|
||||
|
||||
// Grid tiles always use the small thumbnail — full media is one tap away in the
|
||||
// lightbox where the data-mode picker decides for real.
|
||||
// Grid tiles prefer the SMALLEST derivative — three across, so a thumbnail is plenty and
|
||||
// the full file is one tap away in the lightbox.
|
||||
//
|
||||
// Everything past that preference defers to `pickMediaUrl`, the centralised rule the card
|
||||
// and the lightbox already follow (saver → preview → thumbnail → original). This used to
|
||||
// return '' when no derivative existed yet, which rendered the grey "broken image" tile
|
||||
// for every upload still in the compression queue. Because the feed is newest-first, that
|
||||
// was precisely the top of the grid during a burst — the photos people had just taken —
|
||||
// while the same items opened fine in the lightbox, which has always had the fallback.
|
||||
// An upload with no derivatives is a TRANSIENT state (SSE `upload-processed` swaps in the
|
||||
// preview as soon as the worker finishes), so the original is only ever fetched for the
|
||||
// short window before compression catches up.
|
||||
function tileUrl(upload: FeedUpload): string {
|
||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
||||
if (upload.preview_url) return upload.preview_url;
|
||||
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
||||
// VIDEOS: a poster image or nothing. `pickMediaUrl` is deliberately mime-agnostic and
|
||||
// bottoms out at `/original` — for a video that is the raw MP4, and this is an <img>.
|
||||
// Videos also never get a `preview_url` (compression.rs gives them a thumbnail only,
|
||||
// and only when a poster frame was actually extracted), so falling through would put a
|
||||
// multi-hundred-MB file behind three broken-image tiles per row. Returning '' keeps the
|
||||
// `{#if tileUrl(...)}` guard meaningful and leaves the play icon on its own. This is the
|
||||
// same carve-out LightboxModal documents for the same reason.
|
||||
if (isVideo(upload.mime_type)) return upload.thumbnail_url ?? '';
|
||||
if ($dataMode === 'saver' && upload.thumbnail_url) return upload.thumbnail_url;
|
||||
return pickMediaUrl($dataMode, upload);
|
||||
}
|
||||
|
||||
// ── Tile media fallback ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// A tile whose <img> 404s used to render as an empty grey box with `alt=""` — no icon,
|
||||
// no message, nothing to tap. And it is not an exotic case: `tileUrl` bottoms out at
|
||||
// `/original` while an upload is still compressing, which in a newest-first grid is the
|
||||
// top-left corner during every burst. That also makes the cause usually TRANSIENT, so
|
||||
// one delayed retry recovers most of them; the nonce is required because the browser
|
||||
// would otherwise replay its cached failure instead of re-requesting.
|
||||
//
|
||||
// Keyed by upload id rather than per-<img>, because the virtualizer recycles rows: a
|
||||
// tile that scrolls out and back must not restart the whole dance, and must not lose a
|
||||
// verdict we already reached. The recorded `url` is what makes that safe to keep — an
|
||||
// `upload-processed` that swaps the original for a real preview is a DIFFERENT url and
|
||||
// gets a clean attempt.
|
||||
const MEDIA_RETRY_MS = 4000;
|
||||
type TileError = { url: string; phase: 'retrying' | 'failed'; nonce: number };
|
||||
let tileErrors = $state<Record<string, TileError>>({});
|
||||
|
||||
function tileSrc(upload: FeedUpload): string {
|
||||
const base = tileUrl(upload);
|
||||
if (!base) return '';
|
||||
const e = tileErrors[upload.id];
|
||||
if (!e || e.url !== base) return base;
|
||||
if (e.phase === 'failed') return '';
|
||||
return e.nonce ? `${base}${base.includes('?') ? '&' : '?'}r=${e.nonce}` : base;
|
||||
}
|
||||
|
||||
function handleTileError(upload: FeedUpload) {
|
||||
const base = tileUrl(upload);
|
||||
if (!base) return;
|
||||
const e = tileErrors[upload.id];
|
||||
if (e && e.url === base) {
|
||||
// The retry failed too — show the placeholder rather than a blank tile.
|
||||
tileErrors[upload.id] = { ...e, phase: 'failed' };
|
||||
return;
|
||||
}
|
||||
tileErrors[upload.id] = { url: base, phase: 'retrying', nonce: 0 };
|
||||
setTimeout(() => {
|
||||
const cur = tileErrors[upload.id];
|
||||
if (cur && cur.url === base && cur.phase === 'retrying') {
|
||||
tileErrors[upload.id] = { ...cur, nonce: Date.now() };
|
||||
}
|
||||
}, MEDIA_RETRY_MS);
|
||||
}
|
||||
|
||||
// STABLE option callbacks — created once, never swapped. They read the live
|
||||
@@ -208,13 +270,14 @@
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="flex h-full items-center justify-center bg-gray-800">
|
||||
{#if tileUrl(upload)}
|
||||
{#if tileSrc(upload)}
|
||||
<img
|
||||
src={tileUrl(upload)}
|
||||
src={tileSrc(upload)}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => handleTileError(upload)}
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
@@ -227,13 +290,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{:else if tileUrl(upload)}
|
||||
{:else if tileSrc(upload)}
|
||||
<img
|
||||
src={tileUrl(upload)}
|
||||
src={tileSrc(upload)}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => handleTileError(upload)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-gray-400">
|
||||
|
||||
@@ -2,16 +2,20 @@ import { describe, it, expect } from 'vitest';
|
||||
import { SlideQueue } from './queue';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
|
||||
// Minimal FeedUpload factory — the queue only reads id, user_id, preview_url, thumbnail_url.
|
||||
// Minimal FeedUpload factory — the queue reads id, user_id, preview_url, thumbnail_url and
|
||||
// mime_type. Note the `as unknown as FeedUpload` cast below: it silences the missing fields,
|
||||
// so adding a field the queue reads does NOT fail typechecking here — it fails at runtime.
|
||||
// Keep this factory in step with what `merge` actually touches.
|
||||
const up = (
|
||||
id: string,
|
||||
opts: { user?: string; preview?: boolean; thumb?: boolean } = {}
|
||||
opts: { user?: string; preview?: boolean; thumb?: boolean; video?: boolean } = {}
|
||||
): FeedUpload =>
|
||||
({
|
||||
id,
|
||||
user_id: opts.user ?? 'u1',
|
||||
preview_url: opts.preview === false ? null : `/p/${id}`,
|
||||
thumbnail_url: opts.thumb ? `/t/${id}` : null
|
||||
thumbnail_url: opts.thumb ? `/t/${id}` : null,
|
||||
mime_type: opts.video ? 'video/mp4' : 'image/jpeg'
|
||||
}) as unknown as FeedUpload;
|
||||
|
||||
const drain = (q: SlideQueue, n: number): string[] => {
|
||||
@@ -32,6 +36,19 @@ describe('SlideQueue.merge', () => {
|
||||
expect(q.stats().known).toBe(3);
|
||||
});
|
||||
|
||||
it('never queues a video, even when it has a usable poster frame', () => {
|
||||
const q = new SlideQueue();
|
||||
// The projector shows stills only. Before this was keyed on the mime type it depended
|
||||
// on whether ffmpeg happened to extract a poster: a video WITH a thumbnail was shown,
|
||||
// one without was dropped — the same clip in or out by luck.
|
||||
const added = q.merge([up('photo'), up('clip', { video: true, thumb: true })], {
|
||||
live: false
|
||||
});
|
||||
expect(added).toBe(1);
|
||||
expect(q.has('clip')).toBe(false);
|
||||
expect(q.has('photo')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips uploads with no preview or thumbnail (still compressing)', () => {
|
||||
const q = new SlideQueue();
|
||||
const added = q.merge([up('a'), up('pending', { preview: false }), up('t', { thumb: true })], {
|
||||
|
||||
@@ -26,14 +26,25 @@ export class SlideQueue {
|
||||
private recentlyShown: string[] = [];
|
||||
|
||||
/**
|
||||
* Add uploads, de-duplicated by id. Only *displayable* items (preview- or
|
||||
* thumbnail-ready) are kept — a still-compressing upload has no image yet, so it's
|
||||
* skipped until a later sync brings it with a preview. `live` items jump onto the live
|
||||
* queue (shown next); otherwise they join the shuffle pool. Returns the count newly added.
|
||||
* Add uploads, de-duplicated by id. `live` items jump onto the live queue (shown next);
|
||||
* otherwise they join the shuffle pool. Returns the count newly added.
|
||||
*
|
||||
* Two things are skipped:
|
||||
*
|
||||
* 1. **Videos, deliberately and by mime type.** The projector shows stills only — a clip
|
||||
* would either hold the slide for its full duration or be cut off mid-way, and it has
|
||||
* no audio path on a room's screen. This used to happen only ACCIDENTALLY, as a side
|
||||
* effect of the derivative check below: a video whose poster frame extracted fine has
|
||||
* a `thumbnail_url` and so was shown as a still frame, while one whose extraction
|
||||
* failed was dropped — the same upload included or excluded depending on whether
|
||||
* ffmpeg happened to find a frame. Now the rule is the mime type, so it is consistent.
|
||||
* 2. **Images with no derivative yet.** A still-compressing photo has no image to show;
|
||||
* it is picked up by a later sync once its preview exists.
|
||||
*/
|
||||
merge(uploads: FeedUpload[], opts: { live: boolean }): number {
|
||||
let added = 0;
|
||||
for (const u of uploads) {
|
||||
if (u.mime_type.startsWith('video/')) continue;
|
||||
if (!u.preview_url && !u.thumbnail_url) continue;
|
||||
if (this.allKnown.has(u.id)) continue;
|
||||
this.allKnown.set(u.id, u);
|
||||
|
||||
@@ -16,6 +16,19 @@ type WakeLock = { request: (t: string) => Promise<SentinelLike> };
|
||||
|
||||
let sentinel: SentinelLike | null = null;
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
let retryTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* How often to retry while visible and lock-less.
|
||||
*
|
||||
* `visibilitychange` was the ONLY retry trigger, and a kiosk never changes visibility: the
|
||||
* projector tab is opened once and left alone for eight hours. So a single refusal at startup
|
||||
* was permanent. Refusal is not exotic either — iOS declines Screen Wake Lock outright in Low
|
||||
* Power Mode, which is exactly the state a tablet that has been sitting on a table all
|
||||
* afternoon is in. The symptom is the screen sleeping mid-party and someone having to walk
|
||||
* over and tap it, repeatedly.
|
||||
*/
|
||||
const RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
async function request(wakeLock: WakeLock): Promise<void> {
|
||||
try {
|
||||
@@ -33,9 +46,17 @@ async function request(wakeLock: WakeLock): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireWakeLock(): Promise<void> {
|
||||
/**
|
||||
* Acquire the screen wake lock, and keep trying.
|
||||
*
|
||||
* Returns whether the API exists at all, so the caller can tell "the browser cannot do this,
|
||||
* warn the operator" (Firefox, Safari < 16.4, most TV browsers) apart from "asked for, may
|
||||
* still arrive". It deliberately does NOT report whether the first request succeeded — that
|
||||
* answer goes stale immediately, and the retry loop below is what actually matters.
|
||||
*/
|
||||
export async function acquireWakeLock(): Promise<boolean> {
|
||||
const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock;
|
||||
if (!wakeLock) return;
|
||||
if (!wakeLock) return false;
|
||||
await request(wakeLock);
|
||||
|
||||
// Re-acquire when the page becomes visible again (the OS releases the lock
|
||||
@@ -48,6 +69,17 @@ export async function acquireWakeLock(): Promise<void> {
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
|
||||
// The kiosk case: visible, no lock, and no visibility change ever coming. Cheap enough to
|
||||
// run all evening — it does nothing at all once a lock is held.
|
||||
if (!retryTimer) {
|
||||
retryTimer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||
void request(wakeLock);
|
||||
}
|
||||
}, RETRY_INTERVAL_MS);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function releaseWakeLock(): Promise<void> {
|
||||
@@ -63,4 +95,8 @@ export async function releaseWakeLock(): Promise<void> {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
visibilityHandler = null;
|
||||
}
|
||||
if (retryTimer) {
|
||||
clearInterval(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* paint the right colours before the JS bundle loads (no flash of the default palette).
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette';
|
||||
|
||||
export const PALETTE_CACHE_KEY = 'eventsnap_palette_css';
|
||||
@@ -20,15 +21,41 @@ export type EventConfig = {
|
||||
};
|
||||
|
||||
export const eventConfig = writable<EventConfig | null>(null);
|
||||
/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */
|
||||
export const commentsEnabled = writable<boolean>(true);
|
||||
/**
|
||||
* Convenience flag for the comment UI. `null` until `/event` answers — deliberately NOT an
|
||||
* optimistic `true`.
|
||||
*
|
||||
* Optimistic-open was wrong in the direction that costs a guest their words. Production pins
|
||||
* `COMMENTS_ENABLED=false`, so on every cold open the comment button, the grid tile button and
|
||||
* the whole lightbox composer rendered for as long as `/event` took — up to the 20s api timeout
|
||||
* on congested venue wifi, and PERMANENTLY if that request failed, because the catch below
|
||||
* leaves the last value in place. A guest would type a comment, tap Senden, get a red error,
|
||||
* and watch the text be discarded.
|
||||
*
|
||||
* `null` is falsy, so every `{#if $commentsEnabled}` hides and every `$commentsEnabled ? a : b`
|
||||
* picks the comments-off copy until the server has actually said otherwise. The failure mode
|
||||
* flips from "offered something that doesn't work" to "revealed a moment late".
|
||||
*/
|
||||
export const commentsEnabled = writable<boolean | null>(null);
|
||||
|
||||
type PublicEventDto = {
|
||||
name: string;
|
||||
slug: string;
|
||||
comments_enabled?: boolean;
|
||||
theme_preset?: string;
|
||||
theme_primary?: string;
|
||||
theme_accent?: string;
|
||||
};
|
||||
|
||||
/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */
|
||||
export async function loadEventConfig(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/event');
|
||||
if (!res.ok) return;
|
||||
const b = await res.json();
|
||||
// Through `api` rather than a bare `fetch`: this was the ONE request in the app with
|
||||
// no AbortController deadline. On a congested venue WiFi a socket that never answers
|
||||
// left the promise pending indefinitely, so the palette never reconciled with the
|
||||
// server — and since `event-updated` re-calls this, those zombie requests stack up.
|
||||
// `api` aborts at 20s and turns it into an ApiError the catch below already handles.
|
||||
const b = await api.get<PublicEventDto>('/event');
|
||||
const theme: ThemeConfig = {
|
||||
preset: b.theme_preset ?? 'champagne-gold',
|
||||
primary: b.theme_primary ?? '#8a6a2b',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
import { setRole } from './role-store';
|
||||
import { privacyNote } from './privacy-note-store';
|
||||
import { isBanned } from './ban-store';
|
||||
import type { MeContextDto } from './types';
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,13 @@ export async function refreshEventState(): Promise<void> {
|
||||
// The role is unaffected by the close/reopen race guarded below, so apply it
|
||||
// unconditionally — this is one of the refreshes that used to drop it.
|
||||
setRole(ctx.role);
|
||||
// Same reasoning, same bug shape: these two fields come back in the SAME payload and
|
||||
// were simply dropped on the floor here. `privacy_note` mattered most on the join
|
||||
// → feed path, where the layout's token-gated boot fetch never runs, so a first-time
|
||||
// guest's onboarding never learned the note existed. `is_banned` drives the read-only
|
||||
// banner, which has to appear the moment a ban lands, not on the next cold start.
|
||||
privacyNote.set(ctx.privacy_note);
|
||||
isBanned.set(ctx.is_banned);
|
||||
// A close/reopen landed while this was in flight — its result is now authoritative;
|
||||
// don't overwrite it with our possibly-stale snapshot.
|
||||
if (seq !== stateSeq) return;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterUploads, type FeedFilter } from './feed-filter';
|
||||
|
||||
// filterUploads only reads `caption` and `uploader_name`, so a partial shape suffices.
|
||||
const u = (id: string, uploader_name: string, caption: string | null) =>
|
||||
({ id, uploader_name, caption }) as any;
|
||||
|
||||
const uploads = [
|
||||
u('1', 'Alice', 'pic #wedding'),
|
||||
u('2', 'Bob', 'party #party'),
|
||||
u('3', 'Alice', 'more #wedding #party'),
|
||||
u('4', 'Carol', 'no tags here'),
|
||||
u('5', 'Bob', null) // no caption
|
||||
];
|
||||
|
||||
const ids = (r: any[]) => r.map((x) => x.id);
|
||||
const tag = (value: string): FeedFilter => ({ type: 'tag', value });
|
||||
const user = (value: string): FeedFilter => ({ type: 'user', value });
|
||||
|
||||
describe('filterUploads', () => {
|
||||
it('no filters → returns all uploads unchanged', () => {
|
||||
expect(filterUploads(uploads, [])).toBe(uploads);
|
||||
});
|
||||
|
||||
it('a single tag matches uploads whose caption contains it', () => {
|
||||
expect(ids(filterUploads(uploads, [tag('wedding')]))).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('two tags combine with OR', () => {
|
||||
expect(ids(filterUploads(uploads, [tag('wedding'), tag('party')]))).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('a single user matches only that uploader', () => {
|
||||
expect(ids(filterUploads(uploads, [user('Alice')]))).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('two users combine with OR', () => {
|
||||
expect(ids(filterUploads(uploads, [user('Alice'), user('Bob')]))).toEqual(['1', '2', '3', '5']);
|
||||
});
|
||||
|
||||
it('a user chip and a tag chip combine with AND', () => {
|
||||
// Alice AND #wedding → only Alice's wedding uploads (not Bob's #wedding, not Alice's #party-only)
|
||||
expect(ids(filterUploads(uploads, [user('Alice'), tag('wedding')]))).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('AND excludes an uploader-match that lacks the tag', () => {
|
||||
// Bob AND #wedding → none (Bob has #party and a null caption, no #wedding)
|
||||
expect(filterUploads(uploads, [user('Bob'), tag('wedding')])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tag matching is case-insensitive against the caption', () => {
|
||||
expect(filterUploads([u('9', 'X', 'PIC #WeDDing')], [tag('wedding')])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a null caption never matches a tag but can match a user', () => {
|
||||
expect(filterUploads([u('5', 'Bob', null)], [tag('party')])).toHaveLength(0);
|
||||
expect(filterUploads([u('5', 'Bob', null)], [user('Bob')])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
// Grid-view feed filtering. Extracted from feed/+page.svelte so the OR/AND
|
||||
// combination rules are unit-testable without mounting the page.
|
||||
|
||||
import type { FeedUpload } from './types';
|
||||
|
||||
export interface FeedFilter {
|
||||
type: 'tag' | 'user';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active grid filters to a list of uploads.
|
||||
*
|
||||
* Combination rules (mirroring the chip UI):
|
||||
* - Tags combine with **OR**: a card passes the tag group if its caption contains
|
||||
* ANY selected `#tag`.
|
||||
* - Users combine with **OR** within the user group (uploader is one of the
|
||||
* selected names).
|
||||
* - The tag group and the user group combine with **AND**: a card must satisfy
|
||||
* both groups. An empty group is a pass-through.
|
||||
*
|
||||
* Tags are matched against the caption text (the autocomplete source), so `value`
|
||||
* is expected lowercase (as produced by the tag suggestions).
|
||||
*/
|
||||
export function filterUploads(uploads: FeedUpload[], filters: FeedFilter[]): FeedUpload[] {
|
||||
if (filters.length === 0) return uploads;
|
||||
const tags = filters.filter((f) => f.type === 'tag').map((f) => f.value);
|
||||
const users = filters.filter((f) => f.type === 'user').map((f) => f.value);
|
||||
return uploads.filter((u) => {
|
||||
const cap = (u.caption ?? '').toLowerCase();
|
||||
const passTag = !tags.length || tags.some((t) => cap.includes('#' + t));
|
||||
const passUser = !users.length || users.includes(u.uploader_name);
|
||||
return passTag && passUser;
|
||||
});
|
||||
}
|
||||
45
frontend/src/lib/onboarding.ts
Normal file
45
frontend/src/lib/onboarding.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Onboarding-guide "already seen" state.
|
||||
//
|
||||
// Extracted from OnboardingGuide.svelte so the AUTH FLOWS can mark it too. The guide is a
|
||||
// first-run explainer, and the only thing that used to suppress it was this localStorage key
|
||||
// being present on the device — which meant a returning guest who recovered their account
|
||||
// (new phone, cleared site data, second browser) was shown the whole guide again as if they
|
||||
// had never used the app.
|
||||
//
|
||||
// `/join` cannot distinguish them: its `is_new` field is hardcoded `true` in the backend,
|
||||
// because a name that already exists 409s before that response is built. So `/recover` — both
|
||||
// the dedicated page and the inline "name taken, enter your PIN" path on /join — is the
|
||||
// authoritative "this person already has an account" signal, and it marks the guide seen.
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
|
||||
|
||||
/** Has this device already been shown (and dismissed) the onboarding guide? */
|
||||
export function hasSeenGuide(): boolean {
|
||||
if (!browser) return false;
|
||||
try {
|
||||
return localStorage.getItem(GUIDE_SEEN_KEY) !== null;
|
||||
} catch {
|
||||
// localStorage can throw in Safari private mode. Treating that as "not seen" would
|
||||
// re-show the guide on every navigation, so fail the quiet way instead.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the guide from now on.
|
||||
*
|
||||
* Called when the user dismisses it, and when they authenticate as an EXISTING user via
|
||||
* recover — someone who already has an account has already been through onboarding, and
|
||||
* re-running it on a replacement device is the bug this exists to prevent.
|
||||
*/
|
||||
export function markGuideSeen(): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(GUIDE_SEEN_KEY, '1');
|
||||
} catch {
|
||||
// Private mode / quota. Nothing to do: the guide is cosmetic, and the worst case is
|
||||
// that it shows again on this device.
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from './api';
|
||||
import { onClearAuth } from './auth';
|
||||
|
||||
export interface QuotaSnapshot {
|
||||
enabled: boolean;
|
||||
@@ -26,6 +27,12 @@ const empty: QuotaSnapshot = {
|
||||
|
||||
export const quotaStore = writable<QuotaSnapshot>(empty);
|
||||
|
||||
// Reset on logout, the same way role-store / ban-store / upload-queue do. A phone handed
|
||||
// round a party is one browser profile: `goto()` is a client-side navigation, so no module
|
||||
// re-imports and no `onMount` re-runs, and without this the next guest saw the PREVIOUS
|
||||
// guest's storage usage — their data, on their screen — until the first refresh landed.
|
||||
onClearAuth(() => quotaStore.set(empty));
|
||||
|
||||
/** Refresh from the server. Swallows errors so a transient network blip doesn't
|
||||
* break the account page; the previous snapshot just stays in place. */
|
||||
export async function refreshQuota(): Promise<void> {
|
||||
|
||||
@@ -27,6 +27,27 @@ const handlers: Map<string, EventHandler[]> = new Map();
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* True once a stream has opened at least once this session. Distinguishes "first
|
||||
* connect" from "reconnect" now that `lastEventTime` is seeded at ticket-mint rather
|
||||
* than in `onopen` (see `connectSse`) — without it every boot would fire a pointless
|
||||
* zero-width delta.
|
||||
*/
|
||||
let streamEverOpened = false;
|
||||
|
||||
/**
|
||||
* `Date.now()` of the last thing we actually received on the live stream.
|
||||
*
|
||||
* Keep-alives are deliberately NOT observable here: the backend sends them as SSE
|
||||
* COMMENTS (`KeepAlive::new().text("ping")` emits `:ping`), and the EventSource parser
|
||||
* discards comments without dispatching anything at all. There is no browser API that
|
||||
* exposes them. So liveness cannot be decided by a plain silence timer — it could not
|
||||
* tell a dead socket from a genuinely quiet half hour, and would churn reconnects for
|
||||
* every guest through every lull. The backstop poll below decides it on evidence
|
||||
* instead: the server had news that this stream never delivered.
|
||||
*/
|
||||
let lastStreamActivity = 0;
|
||||
|
||||
/**
|
||||
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
|
||||
* call sites grow — every entry becomes a relay registration below.
|
||||
@@ -91,29 +112,40 @@ export function connectSse(): void {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
// Auth flow may have torn things down while we were awaiting the ticket.
|
||||
if (!getToken() || eventSource) return;
|
||||
// Seed the reconnect cursor the moment we have a server clock — NOT in `onopen`,
|
||||
// which is where it used to live. `onopen` never fires behind a captive portal or
|
||||
// any proxy that buffers `text/event-stream`, so on exactly the networks where the
|
||||
// stream fails the cursor stayed `null` forever and the backstop poll below had no
|
||||
// `since` to fetch from. Still the server clock and never `new Date()`: a skewed
|
||||
// browser clock would shift the window and silently drop uploads.
|
||||
if (!lastEventTime) lastEventTime = serverTime;
|
||||
|
||||
// Auth flow may have torn things down while we were awaiting the ticket — and the
|
||||
// phone may have gone to sleep during that round-trip. Opening a stream while
|
||||
// hidden is worse than not opening one: iOS reaps a backgrounded socket without
|
||||
// ever firing `onerror`, so `eventSource` stays non-null and the guard at the top
|
||||
// of this function then treats the corpse as a live connection for the rest of the
|
||||
// evening. The visibility handler reconnects us when the screen comes back.
|
||||
if (!getToken() || eventSource || (typeof document !== 'undefined' && document.hidden)) return;
|
||||
|
||||
eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
// Successful connection — reset the backoff counter.
|
||||
reconnectAttempt = 0;
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap. The
|
||||
// delta advances `lastEventTime` from the SERVER clock it returns.
|
||||
const since = lastEventTime;
|
||||
if (since) {
|
||||
void deltaFetchAndFan(since);
|
||||
} else {
|
||||
// First connect: seed the cursor from the server clock at ticket-mint time,
|
||||
// never `new Date()` — a skewed browser clock would otherwise shift the very
|
||||
// first reconnect window and could drop uploads.
|
||||
lastEventTime = serverTime;
|
||||
}
|
||||
noteStreamActivity();
|
||||
// A reconnect has a gap to close; the very first open of a session does not
|
||||
// (the cursor was just seeded from this ticket's server clock). The delta
|
||||
// advances `lastEventTime` from the SERVER clock it returns.
|
||||
if (streamEverOpened && lastEventTime) void deltaFetchAndFan(lastEventTime);
|
||||
streamEverOpened = true;
|
||||
};
|
||||
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data));
|
||||
eventSource.addEventListener(eventName, (e) => {
|
||||
noteStreamActivity();
|
||||
dispatch(eventName, (e as MessageEvent).data);
|
||||
});
|
||||
}
|
||||
|
||||
// `resync` is emitted by the server when our broadcast subscription fell
|
||||
@@ -123,6 +155,7 @@ export function connectSse(): void {
|
||||
// own listener — not via `dispatch` — so reading `lastEventTime` as the gap
|
||||
// start isn't clobbered by dispatch bumping it to "now".
|
||||
eventSource.addEventListener('resync', () => {
|
||||
noteStreamActivity();
|
||||
const since = lastEventTime;
|
||||
if (since) void deltaFetchAndFan(since);
|
||||
});
|
||||
@@ -141,7 +174,14 @@ export function connectSse(): void {
|
||||
function scheduleReconnect(): void {
|
||||
reconnectAttempt++;
|
||||
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
|
||||
const jitter = Math.random() * 500;
|
||||
// Jitter must SCALE WITH the backoff, not be a flat 500ms. Every client that dropped
|
||||
// together shares the same `reconnectAttempt`, so they compute an identical `delay` —
|
||||
// a fixed 500ms window spreads 100 phones over half a second no matter how long the
|
||||
// backoff grew, which is the thundering herd the backoff exists to prevent. Each
|
||||
// reconnect costs a ticket POST + stream GET + feed-delta fetch, so the herd lands on
|
||||
// the DB pool three times over. Scaling the jitter to the delay (floored at 1s so the
|
||||
// first, most synchronised retry is spread too) turns that into a smooth ramp.
|
||||
const jitter = Math.random() * Math.max(delay, 1_000);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connectSse, delay + jitter);
|
||||
}
|
||||
@@ -157,6 +197,91 @@ export function disconnectSse(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function noteStreamActivity(): void {
|
||||
lastStreamActivity = Date.now();
|
||||
}
|
||||
|
||||
// ── Stream backstop ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVERY feed update is triggered by an SSE event — there is no periodic refetch — so a
|
||||
// stream that stops delivering freezes a guest's gallery for the rest of the evening.
|
||||
// And a venue produces exactly the two failures that the reconnect path cannot see:
|
||||
//
|
||||
// • a captive portal or any proxy that buffers `text/event-stream` never forwards a
|
||||
// byte, so `onopen` may never fire and neither does `onerror`;
|
||||
// • a phone that roams between APs leaves a HALF-OPEN socket — the connection is gone
|
||||
// but `readyState` still reads OPEN, no error is raised, and `connectSse` early-
|
||||
// returns on its non-null `eventSource`, so nothing ever reconnects.
|
||||
//
|
||||
// Neither surfaces anything to react to, which is why the only honest backstop is to
|
||||
// ask the server. `/feed/delta` is idempotent and answers with an empty payload when
|
||||
// nothing changed, and one request per 60–120s is ~1/60th of the per-user feed limit —
|
||||
// cheap insurance against a guest staring at a frozen feed all night. The diashow uses
|
||||
// the same reconcile-on-a-timer for the same reason (`RECONCILE_INTERVAL_MS` there).
|
||||
const BACKSTOP_MIN_MS = 60_000;
|
||||
const BACKSTOP_MAX_MS = 120_000;
|
||||
let backstopTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backstopEnabled = false;
|
||||
|
||||
/**
|
||||
* Start the poll-based liveness/completeness backstop. Opt-in per page (the feed is the
|
||||
* consumer that needs it; the export page's SSE use is a status ping and the diashow
|
||||
* runs its own full reconcile), and idempotent.
|
||||
*/
|
||||
export function startStreamBackstop(): void {
|
||||
if (typeof document === 'undefined' || backstopEnabled) return;
|
||||
backstopEnabled = true;
|
||||
scheduleBackstop();
|
||||
}
|
||||
|
||||
export function stopStreamBackstop(): void {
|
||||
backstopEnabled = false;
|
||||
if (backstopTimer) {
|
||||
clearTimeout(backstopTimer);
|
||||
backstopTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackstop(): void {
|
||||
if (backstopTimer) clearTimeout(backstopTimer);
|
||||
// Jittered for the same reason the reconnect backoff is: ~100 phones that joined
|
||||
// within the same few minutes would otherwise poll in permanent lockstep.
|
||||
const delay = BACKSTOP_MIN_MS + Math.random() * (BACKSTOP_MAX_MS - BACKSTOP_MIN_MS);
|
||||
backstopTimer = setTimeout(() => void runBackstop(), delay);
|
||||
}
|
||||
|
||||
async function runBackstop(): Promise<void> {
|
||||
backstopTimer = null;
|
||||
try {
|
||||
// A hidden tab has no stream (the visibility handler closed it) and cannot show a
|
||||
// result anyway; the reopen path already fetches the gap.
|
||||
if (document.hidden || !getToken()) return;
|
||||
// No stream, or one the browser has admitted is closed, while we are visible: there
|
||||
// is no other way back, because `connectSse`'s `eventSource` guard cannot tell a
|
||||
// corpse from a live connection.
|
||||
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||
disconnectSse();
|
||||
reconnectAttempt = 0;
|
||||
connectSse();
|
||||
return;
|
||||
}
|
||||
const since = lastEventTime;
|
||||
if (!since) return;
|
||||
const activityBefore = lastStreamActivity;
|
||||
const carried = await deltaFetchAndFan(since);
|
||||
// The server had news that this stream never delivered. That is the evidence a
|
||||
// half-open socket cannot otherwise give us — reconnect, or every remaining update
|
||||
// tonight arrives at poll latency instead of instantly.
|
||||
if (carried && lastStreamActivity === activityBefore) {
|
||||
disconnectSse();
|
||||
reconnectAttempt = 0;
|
||||
connectSse();
|
||||
}
|
||||
} finally {
|
||||
if (backstopEnabled) scheduleBackstop();
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastEventTime(): string | null {
|
||||
return lastEventTime;
|
||||
}
|
||||
@@ -196,14 +321,23 @@ function extractCreatedAt(data: string): string | undefined {
|
||||
* event. Subscribers (typically the feed page) merge the result into their
|
||||
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
||||
* SSE event will keep the feed moving.
|
||||
*
|
||||
* Resolves to whether the delta actually CARRIED something. `runBackstop` uses that as
|
||||
* its liveness signal: content the poll found but the stream never pushed means the
|
||||
* stream is dead in the way the browser will not report.
|
||||
*/
|
||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.get<DeltaResponse>(`/feed/delta?since=${encodeURIComponent(since)}`);
|
||||
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
return (
|
||||
response.uploads.length > 0 ||
|
||||
response.deleted_ids.length > 0 ||
|
||||
response.hidden_user_ids.length > 0
|
||||
);
|
||||
} catch (e) {
|
||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
|
||||
@@ -211,10 +345,15 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
||||
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
|
||||
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
|
||||
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
|
||||
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
||||
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
|
||||
// Jittered for the same reason `scheduleReconnect` is: the clients that hit this
|
||||
// 429 are the ones that just reconnected together after a venue-wide wifi blip,
|
||||
// so they share `attempt` and would otherwise retry in lockstep at exactly 2s,
|
||||
// 4s, 8s — re-tripping the same per-user limit in a synchronised wave.
|
||||
const base = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
||||
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), base + Math.random() * base);
|
||||
}
|
||||
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +367,12 @@ function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
disconnectSse();
|
||||
} else {
|
||||
// Tear down UNCONDITIONALLY before reconnecting rather than leaning on
|
||||
// `connectSse`'s `eventSource` guard. iOS can reap a backgrounded socket without
|
||||
// ever firing `onerror`, which leaves a non-null but permanently dead EventSource —
|
||||
// and the guard would then read that as "already connected" and never reconnect,
|
||||
// for the rest of the evening. Closing an already-closed EventSource is a no-op.
|
||||
disconnectSse();
|
||||
// User-initiated reconnect — clear backoff so we don't wait out a long
|
||||
// retry delay that was scheduled from a prior background error.
|
||||
reconnectAttempt = 0;
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface MeContextDto {
|
||||
storage_quota_enabled: boolean;
|
||||
uploads_locked: boolean;
|
||||
gallery_released: boolean;
|
||||
/** Read-only ban: the feed and the keepsake stay available, every write is refused. */
|
||||
is_banned: boolean;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyUploadStatus, isReversibleLock, entryToQueueItem } from './upload-queue';
|
||||
import {
|
||||
classifyUploadStatus,
|
||||
isReversibleLock,
|
||||
entryToQueueItem,
|
||||
shouldAbortForStall,
|
||||
suspendedSinceLastTick,
|
||||
MAX_SUSPEND_CREDIT_MS
|
||||
} from './upload-queue';
|
||||
|
||||
/**
|
||||
* Regression guard for the upload-queue retry policy (H2 + M1). The bug being locked out:
|
||||
@@ -107,3 +114,144 @@ describe('entryToQueueItem', () => {
|
||||
expect(item.hashtags).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The upload XHR had no timeout of any kind while `processQueue` held the `isProcessing`
|
||||
* latch across it. On a half-open socket neither `error` nor `abort` ever fires, so the
|
||||
* latch was pinned forever and the whole queue wedged with no recovery but a reload.
|
||||
*
|
||||
* The policy that matters: bound SILENCE, not total duration. A 500 MB video over a venue
|
||||
* uplink legitimately runs 30+ minutes while making steady progress, and a flat total cap
|
||||
* would kill exactly the uploads worth keeping.
|
||||
*/
|
||||
describe('shouldAbortForStall', () => {
|
||||
const now = 1_000_000;
|
||||
|
||||
it('lets a long upload run as long as progress keeps arriving', () => {
|
||||
// Two hours in, but progress landed a second ago.
|
||||
expect(shouldAbortForStall(now - 1_000, now, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts once the body stalls past the no-progress ceiling', () => {
|
||||
expect(shouldAbortForStall(now - 89_000, now, false)).toBe(false);
|
||||
expect(shouldAbortForStall(now - 91_000, now, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the wider ceiling once the body is sent and progress goes quiet', () => {
|
||||
// Silence that would abort mid-body is normal while waiting for the response.
|
||||
expect(shouldAbortForStall(now - 91_000, now, true)).toBe(false);
|
||||
expect(shouldAbortForStall(now - 121_000, now, true)).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* The watchdog measures SILENCE via `Date.now()`, but a backgrounded phone freezes the
|
||||
* interval while the clock keeps running. Without crediting the un-run window back, the first
|
||||
* tick after a screen lock reads the whole sleep as a stall and aborts a healthy upload —
|
||||
* re-sending from byte zero and spending one of five permanent auto-attempts. That is what
|
||||
* every phone does between shots at a party.
|
||||
*
|
||||
* Detecting the freeze from the tick gap (rather than from `visibilitychange`) also covers the
|
||||
* causes that fire no visibility event at all: a throttled-but-visible tab, a closed lid, an
|
||||
* occluded window.
|
||||
*/
|
||||
describe('suspendedSinceLastTick', () => {
|
||||
const now = 1_000_000;
|
||||
|
||||
it('credits nothing for a tick that arrived on schedule', () => {
|
||||
expect(suspendedSinceLastTick(now - 5_000, now, 5_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('credits nothing for ordinary timer jitter or throttling', () => {
|
||||
expect(suspendedSinceLastTick(now - 6_900, now, 5_000)).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The bound that matters: however the page is throttled or frozen, a genuinely dead socket
|
||||
* must be detected in a time a guest would tolerate — not left to `xhr.timeout` (5-60 min).
|
||||
*/
|
||||
it('detects a dead socket in bounded time even under 1-tick-per-minute throttling', () => {
|
||||
// Background tabs are throttled to ~1 tick/min WITHOUT the network stack pausing, so a
|
||||
// socket can be dead while ticks keep arriving. Unbounded crediting made this take ~18
|
||||
// minutes, holding the queue's latch the whole time.
|
||||
const CAP = MAX_SUSPEND_CREDIT_MS;
|
||||
let lastProgressAt = 0;
|
||||
let lastTickAt = 0;
|
||||
let creditSpent = 0;
|
||||
let clock = 0;
|
||||
let ticks = 0;
|
||||
while (!shouldAbortForStall(lastProgressAt, clock, false) && ticks < 100) {
|
||||
clock += 60_000; // throttled tick
|
||||
const credit = Math.min(
|
||||
suspendedSinceLastTick(lastTickAt, clock, 5_000),
|
||||
Math.max(0, CAP - creditSpent)
|
||||
);
|
||||
creditSpent += credit;
|
||||
lastProgressAt = Math.min(clock, lastProgressAt + credit);
|
||||
lastTickAt = clock;
|
||||
ticks += 1;
|
||||
}
|
||||
expect(clock).toBeLessThanOrEqual(240_000);
|
||||
});
|
||||
|
||||
it('credits the whole frozen window when the interval did not run', () => {
|
||||
// Screen locked ~2 minutes: a 5s interval arriving 130s late.
|
||||
expect(suspendedSinceLastTick(now - 130_000, now, 5_000)).toBe(125_000);
|
||||
});
|
||||
|
||||
it('credits nothing when the clock jumps backwards (NTP correction)', () => {
|
||||
expect(suspendedSinceLastTick(now + 60_000, now, 5_000)).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Replays the production watchdog tick faithfully — including the MAX_SUSPEND_CREDIT_MS clamp.
|
||||
*
|
||||
* The previous version of the test below omitted that clamp, so it asserted a property the
|
||||
* shipped code does not have and could not fail. Anything checking the suspension behaviour
|
||||
* must go through here.
|
||||
*/
|
||||
function runTicks(lockMs: number, tickMs = 5_000, ticks = 3): boolean {
|
||||
const CAP = MAX_SUSPEND_CREDIT_MS;
|
||||
let lastProgressAt = 0;
|
||||
let lastTickAt = 0;
|
||||
let creditSpent = 0;
|
||||
let clock = lockMs; // first tick lands when the page resumes
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const credit = Math.min(
|
||||
suspendedSinceLastTick(lastTickAt, clock, tickMs),
|
||||
Math.max(0, CAP - creditSpent)
|
||||
);
|
||||
creditSpent += credit;
|
||||
lastProgressAt = Math.min(clock, lastProgressAt + credit);
|
||||
lastTickAt = clock;
|
||||
if (shouldAbortForStall(lastProgressAt, clock, false)) return true;
|
||||
clock += tickMs;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
it('a pocket-length screen lock does not abort a healthy upload', () => {
|
||||
// 60s locked, then the interval resumes: fully credited, nothing aborted.
|
||||
expect(runTicks(60_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('a suspension beyond the credit cap DOES abort — the cap is the deliberate bound', () => {
|
||||
// 3 minutes locked. The cap forgives 90s, so the first tick after resume survives and the
|
||||
// next one aborts. This is intended: after a lock that long the socket is almost certainly
|
||||
// reaped (iOS does so without firing `error`), and re-sending beats hanging on `xhr.timeout`
|
||||
// for 5-60 minutes while the queue's latch is held.
|
||||
//
|
||||
// It is asserted rather than merely tolerated because the cost lands on the retry budget —
|
||||
// see RETRY_BUDGET_WINDOW_MS, which is what keeps this from parking the photo for good.
|
||||
expect(runTicks(180_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('but a socket still silent 91s AFTER resume is aborted, never left to xhr.timeout', () => {
|
||||
// iOS reaps backgrounded sockets without firing `error`. The credit buys one fresh
|
||||
// window, not immunity — otherwise a dead upload would hang for 5-60 minutes holding
|
||||
// the queue's `processing` latch.
|
||||
const resumedAt = now - 91_000;
|
||||
expect(shouldAbortForStall(resumedAt, now, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
40
frontend/src/routes/+error.svelte
Normal file
40
frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
// Without this file SvelteKit renders its built-in fallback: an unstyled English
|
||||
// "500 / Internal Error" with no reload control. That is reachable from any uncaught
|
||||
// render or load error, and `ssr = false` means there is nothing else on the page to
|
||||
// fall back to. In a `display: standalone` PWA there is also no URL bar, so a guest who
|
||||
// hit it had no way back to the app at all for the rest of the evening.
|
||||
//
|
||||
// Deliberately dependency-free: no stores, no api client, no fetch. Whatever broke may
|
||||
// be one of those, and an error page that can itself throw is worse than none.
|
||||
import { page } from '$app/state';
|
||||
|
||||
function reload() {
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="font-mono text-xs tracking-widest text-gray-500 uppercase">
|
||||
Fehler {page.status}
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{page.status === 404 ? 'Diese Seite gibt es nicht' : 'Da ist etwas schiefgelaufen'}
|
||||
</h1>
|
||||
<p class="mx-auto max-w-sm text-gray-600 dark:text-gray-400">
|
||||
{page.status === 404
|
||||
? 'Der Link stimmt nicht ganz. Geh zurück zur Galerie, dort sind alle Fotos.'
|
||||
: 'Die Seite konnte nicht geladen werden. Meistens hilft es, sie neu zu laden.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
{#if page.status !== 404}
|
||||
<button type="button" class="btn btn-primary" onclick={reload}> Neu laden </button>
|
||||
{/if}
|
||||
<!-- A full document load, not `goto`: if the client-side router is what broke, a
|
||||
client-side navigation would fail exactly the same way. -->
|
||||
<a href="/feed" data-sveltekit-reload class="btn btn-secondary"> Zur Galerie </a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
import { queueItems, isProcessing } from '$lib/upload-queue';
|
||||
import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue';
|
||||
import { privacyNote } from '$lib/privacy-note-store';
|
||||
import { refreshQuota } from '$lib/quota-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
@@ -17,7 +17,8 @@
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
|
||||
import { setRole } from '$lib/role-store';
|
||||
import { loadEventConfig } from '$lib/event-config-store';
|
||||
import { loadEventConfig, commentsEnabled } from '$lib/event-config-store';
|
||||
import { isBanned } from '$lib/ban-store';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -31,6 +32,27 @@
|
||||
return Math.round((done / total) * 100);
|
||||
});
|
||||
|
||||
// Rate-limit countdown, mirrored from UploadQueue.svelte. That component is mounted ONLY on
|
||||
// /upload, but the composer sends the guest straight to /feed after staging — which is where
|
||||
// they invariably are when the 429 lands. The documented "Wird in Xs automatisch fortgesetzt"
|
||||
// reassurance was therefore unreachable in the exact situation it exists for: all the guest
|
||||
// saw was a stuck badge and a queue that appeared to have died.
|
||||
let rateLimitCountdown = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const retryAt = $rateLimitRetryAt;
|
||||
if (!retryAt) {
|
||||
rateLimitCountdown = 0;
|
||||
return;
|
||||
}
|
||||
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
|
||||
const interval = setInterval(() => {
|
||||
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
|
||||
if (rateLimitCountdown <= 0) clearInterval(interval);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// With `ssr = false` the server ships an empty shell; `app.html` paints a boot
|
||||
// spinner to cover the JS-load gap. The app has now mounted and painted, so drop it.
|
||||
@@ -46,6 +68,15 @@
|
||||
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
|
||||
// Page-level mounts will refresh again as needed.
|
||||
if (getToken()) {
|
||||
// Rehydrate the persisted upload queue from IndexedDB on EVERY boot, not just
|
||||
// when /upload happens to mount. `queueItems` is a module-level store that starts
|
||||
// empty, and the drain loop reads only that store — so without this, a guest whose
|
||||
// PWA is evicted mid-upload (iOS does this aggressively) and who reopens onto /feed
|
||||
// has pending blobs sitting in IndexedDB that nothing ever reads. The badge shows
|
||||
// 0, the photos never upload, and there is no symptom to act on. This is also what
|
||||
// arms the other resume paths below (`online`, `event-opened`, `feed-delta`), all
|
||||
// of which call processQueue() against the same store.
|
||||
void loadQueue();
|
||||
try {
|
||||
const ctx = await api.get<MeContextDto>('/me/context');
|
||||
privacyNote.set(ctx.privacy_note);
|
||||
@@ -56,6 +87,7 @@
|
||||
uploadsLocked: ctx.uploads_locked,
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
isBanned.set(ctx.is_banned);
|
||||
} catch {
|
||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||
// a session land on /join anyway, and the per-page mount will retry.
|
||||
@@ -79,6 +111,20 @@
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// A host banned someone. Same contract as `pin-reset`: `data` is a JSON string of
|
||||
// `{ 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.
|
||||
onSseEvent('user-hidden', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
if (payload.user_id === getUserId()) isBanned.set(true);
|
||||
} 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),
|
||||
@@ -117,6 +163,55 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rate-limit countdown, rendered next to the progress bar for the same reason: the queue's
|
||||
own banner lives on /upload, which is not where the guest is standing when the 429 hits.
|
||||
Suppressed while the bottom nav is (i.e. on /upload), where UploadQueue already shows it
|
||||
in place and this would duplicate it over the sticky submit bar — and for a banned guest,
|
||||
whose read-only banner occupies the same slot and is the more relevant message. -->
|
||||
{#if $rateLimitRetryAt && rateLimitCountdown > 0 && $isAuthenticated && $showBottomNav && !$isBanned}
|
||||
<div
|
||||
role="status"
|
||||
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl bg-amber-50 px-4 py-2 text-center text-sm text-amber-800 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:text-amber-300 dark:ring-amber-900"
|
||||
>
|
||||
Upload-Limit erreicht. Wird in {rateLimitCountdown} Sek. automatisch fortgesetzt.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Read-only notice for a banned guest. Rendered in the layout so it follows them across
|
||||
every route, and above the bottom nav so it is not hidden behind it. A ban blocks every
|
||||
write server-side but leaves the feed and the keepsake readable, so the guest needs to be
|
||||
told once — otherwise the upload, like and delete controls all look available and answer
|
||||
with a 403 toast each time, which reads as the app being broken.
|
||||
|
||||
The offset must be computed, not the fixed `bottom-16` it used to be: BottomNav is h-14
|
||||
(3.5rem) PLUS env(safe-area-inset-bottom), so on a notched iPhone the nav is ~90px tall and
|
||||
paints over the lower part of this banner (same z-40, later in the DOM). And it only renders
|
||||
alongside the nav — on /upload the nav is suppressed and this would float over the sticky
|
||||
submit bar; a banned guest can no longer open the composer anyway (see UploadSheet). -->
|
||||
{#if $isBanned && $isAuthenticated && $showBottomNav}
|
||||
<div
|
||||
role="status"
|
||||
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl bg-amber-50 px-4 py-3 text-center shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:ring-amber-900"
|
||||
>
|
||||
<p class="text-sm font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
|
||||
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
|
||||
Du kannst alle Fotos ansehen und die Galerie später herunterladen. Hochladen, Liken{$commentsEnabled
|
||||
? ' und Kommentieren'
|
||||
: ''} sind für dich deaktiviert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- UploadSheet is always mounted for smooth enter/exit animation -->
|
||||
<UploadSheet />
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user