Two smaller operational items.
POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 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 doesn't degrade one
feature -- every request path touches the database, so it takes the event down.
Memory is the cheaper knob than shrinking the pool back and reintroducing the
queueing it was raised to fix. .env.example now names the pairing explicitly, the way
it already does for COMPRESSION_WORKER_CONCURRENCY.
SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the
only mutating endpoints in the app with no limit at all -- upload, join, recover,
export and admin login all carry one. Asymmetric coverage rather than a deliberate
decision.
Low severity, and honestly so: a like fans an SSE broadcast to every client, but the
export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s,
workers born with their epoch, superseded ones inert). So the ceiling is 120/min --
far above anything a real guest produces. This bounds a script, not an enthusiastic
double-tapper.
ONE bucket across all three actions: separate buckets would let a caller triple the
aggregate write rate by alternating between them. Keyed per USER, matching the feed
and upload limits -- at a venue every guest is behind one NAT, and an IP key is what
made the /join and /feed limits turn guests away in the first place.
Migration 020 seeds both keys, and both are wired into the admin allowlist, the
config UI and the e2e reseed -- the step two earlier per-area toggles missed, which
left switches that existed in code and could never be flipped.
Tests: 4 e2e, including that the shared bucket really is shared (the part most likely
to be lost in a refactor) and that one guest hitting the ceiling doesn't block
another behind the same IP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things, all found by the same question: what does an operator standing at the
venue actually need?
A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.
Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:
- The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
"_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
so the documented dump could only ever be restored into an empty database. Fixed
at the source (the dump is now self-cleaning) and verified end to end: 16 tables
back, exit 0.
- `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
the extract aborted before unpacking anything. `--numeric-owner` plus the
explicit chown, verified to land 100:101.
BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.
quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.
SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.
Also ticks the low-disk alert off the roadmap, since it now exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two issues would each stop a clean production `docker compose up` for the event:
1. Healthchecks probed http://localhost:{3000,3001}, but the app and frontend
bind IPv4 (0.0.0.0) while `localhost` resolves to ::1 (IPv6) first inside the
container — so the probe got "connection refused" and neither container ever
turned healthy. Caddy is gated on `condition: service_healthy` for both, so on
a fresh boot it would block forever and nothing gets served. Switch both probes
to 127.0.0.1. (Verified: both containers now report healthy.)
2. The prod caddy service never received DOMAIN, so the Caddyfile's `{$DOMAIN}`
site address expanded to empty — malformed site block, no TLS, no serving. Add
`environment: { DOMAIN: ${DOMAIN} }` to the caddy service.
Also make .env.example honest and event-ready:
- Add DATABASE_MAX_CONNECTIONS (real env lever; recommend 30 for ~100 guests).
- The DEFAULT_* upload/rate/capacity vars are NOT read from env — they are seeded
into the DB config table and managed at runtime via the admin dashboard. Replace
the misleading entries (e.g. upload rate showed 10; live value is 100 via
migration 015) with a note pointing to the admin UI and the real seeded defaults.
- Document that raising COMPRESSION_WORKER_CONCURRENCY also needs the app memory
limit raised (ffmpeg), so a video burst can't OOM the box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two latent production pitfalls, both proven by probing Compose's env_file resolution:
- A bcrypt ADMIN_PASSWORD_HASH is full of $; Compose env_file interpolation AND
dotenvy variable substitution both eat the $… segments (as unset vars), corrupting
the hash so every admin login 401s. Single-quote it so both read it literally —
verified correct for env_file (container) and dotenvy 0.15.7 strong-quote (native).
Updated .env.example with the quotes + a warning comment.
- MEDIA_PATH: pin it to the /media mount in the app 'environment:' block so a stray
host path in .env can't leak in and make every upload 500 with EACCES. environment
overrides env_file, so the container is always correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CR1: the lightbox comment button POSTed to /upload/{id}/comment (singular);
no such route exists, so every UI-submitted comment 404'd and was lost.
Fixed to /comments (plural). The prior e2e passed because its seed helper
POSTs the API directly — added comment-ui.spec.ts which drives the real
component so this can't regress silently again.
CR2: export archives (Gallery.zip / Memories.zip / HTML viewer) were written
under media_path, which is a public ServeDir — so GET /media/exports/
Gallery.zip served the entire event (every photo, caption, comment,
uploader name) to any anonymous visitor at a guessable URL, bypassing the
ticket + release gate. Moved exports to a separate EXPORT_PATH (=/exports)
on its own volume (Dockerfile chown, compose volume, gated handler reads
the new path). export-leak.spec.ts asserts /media/exports/Gallery.zip → 404.
Riders (git can't split hunks): LightboxModal also gains H3 live-eviction on
comment-deleted/upload-deleted; config.rs also wires compression_concurrency
from boot config (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C1: reset_user_pin wrote to a non-existent column (pin_failed_attempts);
the real column is failed_pin_attempts, so every PIN reset 500'd. Fixed
the column name; new e2e (pin-reset.spec.ts) proves a reset returns a
usable PIN and the target can recover with it.
C2: config.rs::validate_secrets now rejects placeholder-ish secrets
(change_me/dev_secret/placeholder), enforces len>=32 in prod, and
requires a real ADMIN_PASSWORD_HASH. docker-compose.yml sets
APP_ENV=production so the guard actually runs. Corrected the false
"fixed" claim in SECURITY-BACKLOG.md. .env.example documents the rule.
Riders in these files (documented here since git can't split hunks):
- host.rs also carries the event-scoped ban_user fix and the
close_event/open_event no-op broadcast guard (medium).
- docker-compose.yml also adds ORIGIN (H6), app/frontend healthchecks with
Caddy waiting on health, and per-service memory limits (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the seven findings from the security & deployment review.
High:
- upload: make infer authoritative — reject files it can't identify
(SVG/HTML/JS) and require the detected MIME to be on an ALLOWED_MEDIA
allowlist; derive the stored MIME and on-disk extension from the
detected type, ignoring client filename/Content-Type. Closes the
stored-XSS vector via media served on-origin.
- deploy: rename docker-compose.override.yml -> docker-compose.dev.yml
so the default `docker compose up -d` no longer publishes Postgres
5432 to the host; the port map is now opt-in via -f. README updated.
Medium:
- upload: DefaultBodyLimit::disable() -> max(576 MiB) as an HTTP-level
OOM backstop; handler still enforces precise per-class size limits.
- docker: run backend and frontend as non-root users.
Low:
- social/upload: event-scope toggle_like, list_comments, add_comment,
delete_comment, edit_upload, delete_upload via find_by_id_and_event /
soft_delete_in_event — cross-event IDs now resolve to 404.
- Caddy: site-wide HSTS / nosniff / X-Frame-Options / Referrer-Policy,
plus Content-Disposition: attachment on /media/originals/*.
- .env.example: replace default Postgres password with a CHANGE_ME hint.
Out of scope: localStorage JWT (root cause fixed; httpOnly cookies are a
larger change tracked separately).
Verified: cargo build (no new warnings), cargo test (3 passed),
caddy validate, docker compose config (no 5432 published by default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>