At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.
`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.
- feed:{ip} -> feed:{user_id} (auth was already in scope)
- export:{ip} -> export:{user_id} (resolved from the download ticket's
session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
per (ip, name), mirroring the existing `recover:{ip}:{name}`.
admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.
Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.
Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.
Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.
Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/media/%70reviews/{id}.jpg` served a taken-down photo to anyone,
unauthenticated. Verified against the running stack: the literal path 404s,
the escaped one returned 200 with the full image. Same for displays,
thumbnails and originals, and any escaped byte in any position works.
Cause: the block was four `nest_service("/media/previews", 404)` route
matches sitting above a `ServeDir` on `/media`. axum matches on the RAW path
(matchit does no percent-decoding), while `ServeDir` percent-decodes when it
resolves the file. So `%70reviews` missed every blocker, fell through to the
ServeDir, and was decoded back to `previews/` on disk — reaching the bytes
with no soft-delete and no ban-hide check. That defeats a host takedown,
which is the entire point of the gate.
Remove the `/media` route tree outright instead of racing the decoder.
Nothing needs it: every media URL the backend emits is already a gated
`/api/v1/upload/{id}/{original,preview,display,thumbnail}` alias
(handlers::feed), the frontend contains zero `/media/` references, and the
`/media` in config.rs/disk.rs is the filesystem path while `media/` in
export.rs is a path inside the zip. `/media/**` now 404s regardless of
encoding. The route's own comment already said it "serves nothing" — it
wasn't a backstop, it was the vector.
Caddy keeps proxying /media/* deliberately: the app 404s it, and forwarding
means the e2e gating specs exercise the app's refusal exactly as production
would rather than being masked by the SvelteKit 404 page.
Extend the gating spec with the encoded variants — asserting only the literal
spelling is what let this sit undetected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The keepsake download navigates a hidden, same-origin iframe (deliberately:
a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a
site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`.
Blink hands a `Content-Disposition: attachment` response to the download
manager at the network layer, so Chromium never noticed. WebKit enforces XFO
on the frame navigation first and aborts the load — so on iOS Safari, the
app's primary platform, tapping Download did nothing at all, silently.
Carve the two export endpoints out to SAMEORIGIN, which still blocks
cross-origin framing. Implemented as two disjoint matchers rather than an
override: Caddy applies the FIRST `header` directive outermost, so it wins on
write and a later, more specific `header` is silently ignored (verified
against the running test stack).
Also close the test gap that let this ship:
- `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the
only engine that enforces XFO on the download frame.
- No test in the suite ever clicked a download button — every archive
assertion used Node `fetch`, which has no frame and no XFO enforcement.
Add a spec that clicks it and awaits a real `download` event. Verified
falsifiable: with the blanket DENY reinstated it fails and reports the
WebKit refusal as the cause.
- Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards
carry `class="card p-5"` (a Tailwind `@apply` component class), never the
`rounded-xl` the page object looked for. This had left the "shows enabled
download buttons" test red on main.
Co-Authored-By: Claude Opus 5 (1M context) <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>
The kill-switch previously left comment UI/text visible in several places.
Sweep the whole surface so a comments-off instance shows no trace:
Frontend (main app):
- VirtualFeed grid tile: gate the comment button/count on $commentsEnabled
(was ungated — the only feed surface still showing it).
- admin stats: hide the "Kommentare" count card.
- UploadSheet "Uploads geschlossen" notice, host ban-modal description, and
host/admin unban confirmations: drop the "…und kommentieren" wording.
- export page: drop "Kommentaren" from the keepsake description.
Keepsake export (had no concept of the flag):
- export.rs: thread comments_enabled into the exported data (ViewerEvent),
wired through spawn_export_jobs/recover_exports and their call sites
(host.rs, main.rs).
- export-viewer: gate comment counts (list + grid) and the lightbox comments
section; older exports without the field default to enabled (?? true).
Backend still 403s comment writes when disabled (unchanged) — this is the UI
half so stale clients and archives match.
Verified on the running stack (COMMENTS_ENABLED=false): /event reports
comments_enabled=false, a regenerated keepsake embeds "comments_enabled": false
with no comment UI, and the uploads-closed notice renders "…ansehen und liken."
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diashow completeness rewrite so every eligible upload is shown regardless of
bursts, disconnects, or library size:
- queue.ts: SlideQueue with live/shuffle queues, allKnown map, recentlyShown
ring; merge(dedup, live-first), remove/removeByUser (prunes recentlyShown),
knownIds for reconcile-eviction. Adds queue.test.ts (burst/completeness/race).
- diashow/+page.svelte: reconcile (full paginate + evict, pre-scan snapshot to
spare concurrent uploads) on mount/reconnect/periodic; catchUpNew paginate-
until-known for bursts with debounced maxWait; hard-cut removals; decode
timeout + candidate fallback + bounded skip so a broken image never stalls.
New ~2048px "display" derivative for big-screen sharpness, decoupled from the
data-saver preview (800px) used on phones:
- migration 016: upload.display_path + v_feed rebuilt (DROP+CREATE, not REPLACE,
to slot the column beside preview/thumbnail).
- compression: generate_image_derivatives emits preview+display (downscale-only
guard, no upscaling); backfill_missing_display regenerates on startup (safe:
logs on error, never soft-deletes).
- upload.rs/main.rs: GET /upload/{id}/display (mirrors preview auth/cache),
/media/displays direct-serve blocked.
- feed.rs + types.ts: display_url in feed/delta DTOs.
- diashow candidate chain: display -> original -> preview.
Verified on the running stack: migration applied, 10/10 existing images
backfilled (2048px cap honoured, small images not upscaled), /display serves
200, /feed returns display_url, diashow cycles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Harden production env handling: single-quote the bcrypt ADMIN_PASSWORD_HASH so
Compose/dotenvy don't corrupt it, and pin MEDIA_PATH to the /media mount.
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>
Transitions never actually animated: Svelte scopes @keyframes names but does NOT
rewrite animation references in inline style attributes, so the inline
'animation: crossfade-in ...' never matched its scoped keyframe — a hard cut, no fade
(Ken Burns' zoom was dead too). Move the animation into scoped classes and pass dynamic
values via CSS custom properties.
- Real two-layer crossfade: hold the outgoing frame opaque beneath the incoming one
(which is decode-gated), so there's no black flash and no decode pop.
- New slide transitions (from below/left/right) as one reusable component; the previous
frame is pushed off the opposite edge in step (a conveyor/push), easeInOutSine 1s.
- Fullscreen toggle button (Fullscreen API) + 'f' shortcut; Esc in fullscreen no longer
also navigates away.
- Controls now reveal on pointer/keyboard activity and fade when idle (cursor hides too)
instead of being always visible.
- wakelock: null the sentinel on the OS 'release' event so re-acquire-on-visible
actually fires (previously the screen could sleep after the tab was first hidden).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-user quota widget was shown to everyone and the /me/quota payload returned
free_disk_bytes (raw server free space) and active_uploaders to any authenticated
guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero
the server-wide telemetry fields for non-staff in the handler. Guests still get their
own used/limit so enforcement stays transparent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Colour theme is configurable at runtime from two seed colours (brand + accent);
neutrals stay fixed for contrast safety. Tailwind v4 var()-based tokens let a
:root:root override recolour everything with no rebuild; the 50->950 ramps are
derived via a color-mix ladder. Config lives in the DB config table (admin UI:
Config > Farbschema, presets + custom pickers + live preview), served on the public
/event endpoint with env defaults (THEME_PRESET/PRIMARY/ACCENT), propagated live via
event-updated SSE, and cached in localStorage for a no-flash boot. The keepsake export
mirrors the same ladder in Rust so offline archives match the event theme.
COMMENTS_ENABLED (env, default true) is a boot-time kill-switch: the backend rejects
new comments with 403 and the frontend hides the comment button (feed card) and
panel/composer (lightbox). Existing comments stay in the DB, hidden, and return when
re-enabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Running in a container picked up host-oriented values from .env via env_file:
- MEDIA_PATH pointed at a host path that doesn't exist in the container, so every
upload 500'd with EACCES; point it at the /media volume mount.
- ADMIN_PASSWORD_HASH lost its $-delimited bcrypt segments to Compose interpolation
(admin login 401'd); re-supply it with $$ escaping.
- COMMENTS_ENABLED=false to smoke-test the comment kill-switch.
NOTE: production compose + .env carry the same MEDIA_PATH / admin-hash pitfalls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the scenario the server-side load test could not — a guest multi-selecting
10-20 photos at once — by driving the real client path (UploadSheet → /upload →
addToQueue → processQueue → XHR) rather than hitting POST /upload directly.
Two tests assert the properties that keep a guest's photos from being lost:
serial per-device draining (one upload in flight at a time), IndexedDB
persistence (blobs kept until upload, dropped after), every file landing
server-side, and a hard reload mid-burst resuming the rest via loadQueue()
with no photo lost. Injects small per-upload latency via route interception so
the serial drain is observable and the mid-burst reload window is reliable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HTTP-level load driver simulating ~100 guests uploading ~1000 images in bursts
over a window, plus SSE viewers and one real browser on /diashow. Correlates
upload→upload-processed (pipeline latency), waits for the compression backlog
to drain against DB ground truth, and emits per-status/latency metrics with
pass/fail flags. Includes a realistic-JPEG generator and a diashow-SSE
regression check (confirm-diashow-fix.mjs). Run artifacts are gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diashow only subscribed to SSE events but never called connectSse(), so a
kiosk/projector opening /diashow directly (not via /feed) never opened the
EventSource — the showcase display got a one-time /feed snapshot and no live
updates, showing "Noch keine Beiträge" forever when turned on before any
photos. Open the stream in onMount (idempotent) and close it in onDestroy,
mirroring the feed page.
Raise the default upload_rate_per_hour from 10 to 100 (migration 015, scoped
to installs still on the old default so admin overrides are preserved). Guests
routinely upload bursts of 10-20 photos; the old default throttled the first
burst. Also update the code fallback and the test-mode reseed.
Both verified end-to-end against the docker test stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild the guest "keepsake" (offline HTML gallery) so it renders when the
extracted index.html is opened over file://. Browsers block external ES-module
scripts and fetch() at origin null, so a normal multi-file SvelteKit build shows
a blank window. Build the viewer as one self-contained index.html (inlined
JS/CSS via vite-plugin-singlefile, standalone non-SvelteKit entry) and inject
the data as window.__EXPORT_DATA__; the backend embeds the built viewer via
include_dir! and writes the data global into index.html when zipping.
Also surface the guest-facing download: a feed banner and the BottomNav Export
tab, both shown once the host releases the export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reskin the whole app via design tokens in tailwind-theme.css (remapped
color ramps) plus a define-once component layer in lib/styles/components.css
(.btn, .card, .input, .chip, .badge, .sheet, …). Buttons are muted/outlined
gold rather than flat fills. Self-host Inter + Fraunces (woff2) under the
existing font-src 'self' CSP. Restyle the shared components and the account,
admin, host, join, recover and upload screens against the new tokens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three distinct load-induced flakes in the mobile suite, all root-caused from real traces:
1. gestures-longpress "quick tap": longPress() drove page.mouse.down + Node-side
waitForTimeout(200) + page.mouse.up — three CDP round-trips. Under load the browser saw
the pointerup >500ms after pointerdown, firing the app's real long-press so the
ContextSheet opened on a quick tap. New quickTap() helper dispatches pointerdown/up
entirely in-browser on one clock, so the earlier-due pointerup always cancels the 500ms
timer first — immune to CDP latency.
2/3. upload-cancel-confirm and sheet-escape interacted with server-rendered controls before
Svelte hydrated them (caption fill lost -> cancel() navigates to /feed; custom role=radio
/ leave-button click no-ops). Wait for a post-hydration readiness signal (composer
auto-focus / real profile name) before the first interaction — the same barrier the
passing feed-based specs already have. (These are now belt-and-suspenders given ssr=false,
but keep the specs robust regardless of render mode.)
Verified: mobile 22/22, and 0/50 full-mobile runs under load (was ~3/95 pre-fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventSnap is a client-only SPA: auth is a localStorage JWT and every page fetches its
data in onMount, so SSR only ever produced a logged-out skeleton — including interactive
controls that exist in the DOM before hydration attaches their handlers. Interacting in
that window silently no-ops: a caption typed on /upload never reaches the reactive state
(so cancel() navigates away instead of confirming), and a click on /account's custom
role=radio / leave button does nothing. This surfaced as a ~1-4% mobile e2e flake and is
a real (if brief) bug for users on slow connections too.
Disable SSR app-wide (csr stays on). The server now ships a ~2.5 KB shell with no page
controls, so the pre-hydration window is structurally impossible. No SEO is lost (private,
QR-gated app). app.html paints a themed boot spinner to cover the JS-load gap (script-free;
inline <style> is CSP-safe via style-src 'unsafe-inline'); the root layout's onMount removes
it once the app paints.
Also fixes the one regression ssr=false exposed: /recover's back chevron used
`cameFromApp = from !== null`, which assumes SSR semantics (cold load => from is null). In
CSR mode the initial navigation reports a non-null `from`, sending history.back() to
about:blank. Key on `type !== 'enter'` instead — SvelteKit's initial-load marker in both
SSR and CSR modes.
Verified: desktop 210 passed, mobile 22 passed, back-chevron 10/10, frontend gates green
(svelte-check 0 errors, eslint, prettier, vitest 46).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new linters/formatters only help if they can't be bypassed. checks.yml now runs
`npm run lint` and `npm run format:check` for both frontend and e2e, alongside the existing
svelte-check / tsc / unit-test gates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).
Rules keep the ones that catch real TEST bugs and drop the noise:
- no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
- no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
`api` arg, an unused JPEG_MAGIC import), all removed.
- no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
a page.evaluate() return.
- no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.
Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.
Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files)
so no future functional diff is buried under formatting churn, then gating `cargo fmt
--check` in checks.yml so it stays clean.
Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat:
cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean.
This is the deferred cleanup noted when CI's Format step was first left out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the coverage gaps the audit flagged and I verified were real:
pin-reset-lifecycle.spec.ts (new): the whole "I forgot my PIN" journey (§4) had only its
403 gate tested. Now: the always-204 non-enumeration contract (unknown name looks
identical to known); dedup (ON CONFLICT); admins are EXCLUDED from the queue; the
3-per-15-min throttle; and a host reset REVOKES the target's sessions (the pre-reset JWT
401s afterward) and clears the request. Sessions are validated per-request against the
DB, so the revoke is observable.
logout-everywhere.spec.ts (new): DELETE /sessions ("sign out everywhere") had ZERO tests.
Proves it revokes ALL of the caller's sessions across two devices (both tokens 401 after),
not just the current one, and doesn't touch another user's sessions.
media-gating: the file claimed delete AND ban-hide both revoke preview access, but only
delete was exercised. Add the ban case — a banned uploader's gated preview 404s, same as
a takedown. (Thumbnail shares the identical find_by_id_visible gate; the seed fixture
produces no thumbnail derivative, so preview is the honest thing to assert.)
export caption test: an edit after release regenerates the viewer (epoch bumps) while the
ZIP is carried forward, not rebuilt — guards the fix in the previous commit.
Helpers: api.listPinResetRequests; db.countSessionsForUser / countPinResetRequestsForUser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
edit_upload updated the caption/hashtags and nothing else. A caption lives in the HTML
viewer keepsake (the ZIP holds media only — export.rs), so after release the downloadable
viewer kept showing the OLD caption forever while the live feed showed the new one.
Editing stays allowed while locked/released — like comments and likes, the lock freezes
new uploads only (USER_JOURNEYS §9.3) — so the fix is to regenerate, not forbid: the
edit and an invalidate_and_arm(ViewerOnly) share one transaction (same atomicity as
delete_upload), then start_regen after commit. ViewerOnly carries the finished ZIP forward
untouched since the media didn't change; when the gallery isn't released, invalidate_and_arm
returns None and this is a no-op. Mutation-verified: without it, an edit doesn't bump the
epoch and the caption test fails.
Also (test isolation): the compression worker now carries a generation counter that
TRUNCATE bumps. A worker queued on the concurrency semaphore when a truncate wiped media
would otherwise wake in the NEXT test, fail to find its file, and broadcast
upload-error/upload-deleted into that test's SSE stream — corrupting any test asserting on
toasts or feed. It now abandons itself when the generation moved. No-op in production
(TRUNCATE is the only caller). Export workers were already inert across a truncate (epoch
guard on a fresh-UUID event).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin-login IP rate limit already existed (auth/handlers.rs: 5/min/IP, gated by
`rate_limits_enabled` + `admin_login_rate_enabled`, both default true in prod) — the earlier
"no rate-limit or lockout" note was wrong. What was missing was any TEST of it: the e2e
reseed sets `rate_limits_enabled=false`, so the old test observed all-401 under a disabled
limiter and "documented" the opposite of reality.
Replace that test with three that enable the limiter and prove it, mutation-verified
(deleting the check in the handler fails the first two):
- a burst of wrong passwords from one IP starts returning 429 (first is a normal 401, so
the password path ran and the limiter had budget; none is ever 200)
- once throttled, even the CORRECT password is refused — isolates the RATE LIMIT from the
password check (this is the assertion that dies if the limiter is removed)
- with `admin_login_rate_enabled=false` the same burst is NOT limited (the toggle gates it)
Two fixes this surfaced:
- `admin_login_rate_enabled` and `recover_rate_enabled` are read by their handlers but
were missing from patch_config's BOOL_KEYS allowlist — the switches existed in code and
could never be flipped. Added both.
- Test isolation: exhausting the admin-login limiter locks out the NEXT test's truncate
fixture, which must itself call admin_login before it can reset anything. An afterEach
disables the toggle via patchConfig (JWT-based, not rate-limited) so the next login is
clear; truncate then wipes the counter map. Runs on failure too.
Full desktop suite 201 passed / 1 skipped; backend 56 tests; clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI ran Playwright (chromium-desktop) + the dependency audit and nothing else. cargo test,
clippy, the frontend vitest suite, svelte-check and the e2e typecheck were all green on a
developer's machine and gated NOTHING.
checks.yml (new): cargo test (with a Postgres service; --test-threads bounded so the
sqlx per-test DBs can't exhaust connections) + clippy; vitest + svelte-check; e2e tsc.
e2e.yml: also run the chromium-mobile project — 22 tests (focus traps, touch targets,
safe-area, viewport reflow) that never ran in CI on a phone-first app.
playwright.config: widen webkit-iphone beyond @smoke (2 specs) to the core guest journeys
(auth/upload/feed) — iOS Safari is the PRIMARY browser here; and retries 2 → 0.
retries:0 is deliberate and against the usual advice: this repo's real bugs are races, a
race is indistinguishable from a flake from outside, and a retry resolves that ambiguity
toward "flake" every time — it took a real 3%-flaky product bug from 1-in-33 to 1-in-37000,
green. A flake here is a bug report.
Not gated: cargo fmt (the tree has never been rustfmt'd — a 112-file reformat would bury
every real diff; do it separately). WebKit widening is unverified locally (needs libavif16
via sudo), so its first CI run may surface real iOS bugs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An audit found tests that pass on broken code. The dominant pattern: fire a security
assertion at the all-zeros UUID and accept [403, 404] — the 404 comes from the resource
LOOKUP, not the guard, so the guard can be deleted and the test still passes. Repaired to
use real resources and demand exactly 403:
- banned-user cannot like / comment (the only coverage of those ban invariants)
- host cannot promote a real guest (or self) to admin — asserts nobody's role changed
- IDOR comment-delete already used a real resource; kept
Also inverted recovery.spec's "unknown name → nicht gefunden" test: that asserted the exact
account-enumeration oracle the F4 fix removed, so restoring the vuln would have made it
pass. Now: an unknown name must be byte-identical to a wrong PIN.
New coverage for paths that ran in production but in zero tests:
- quota.spec.ts: storage quota enforcement (413 over-limit, atomic increment under two
uploads held mid-body so both carry a stale total=0 — the real race; a naive Promise.all
version was itself vacuous and is documented as such). Proven to fail without the guard.
- authz-sweep.spec.ts: table-driven guest→403 / host→403 over ALL 19 privileged routes +
anonymous + __truncate. No live hole found; the whole surface is now locked.
- ban / unban / host-comment-delete AFTER release regenerate the keepsake (data-loss
paths that were dead under test); comment-delete mid-build doesn't strand the ZIP.
Lower-severity de-vacuuming: 10 MB comment test hit Caddy's 502 before the real 500-char
cap (now seeds a real upload, 501→400 / 500→201, mutation-verified); XSS name payloads
shortened under the 50-char cap so they actually store+render; ui-rendering XSS test now
proves the payload rendered before asserting no <b>; export page-object locators fixed to
the real "Download" label with a positive empty-state anchor; avatar palette spread test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All 40 backend tests were pure-function tests — not one line of SQL ran under `cargo test`,
even though the riskiest code in the repo is SQL. Add 12 DB-backed `#[sqlx::test]` tests
(fresh throwaway DB per test, real migrations) pinning the invariants that code is
load-bearing for, each mutation-verified to fail on broken code:
export_epoch.rs (8): epoch is post-increment on release (a worker born pre-increment is
inert); epoch strictly monotonic across reopen; a retired-epoch worker's writes are
no-ops; export_current is EXACTLY the invariant (8-case table); the ViewerOnly
carry-forward carries a done ZIP forward AND matches nothing when the ZIP is unfinished
(the af997a8 strand bug); boot recovery doesn't clobber a live ZIP.
upload_concurrency.rs (4): the atomic quota UPDATE stops two stale-snapshot uploads from
both committing (sequential AND concurrent-tx via EPQ); the FOR SHARE upload lock
serializes against release, blocking a photo from committing after the export snapshot.
Deleting FOR SHARE, or the carry-forward's `status='done'`, each makes a test fail.
Test isolation: TRUNCATE now also resets disk_cache and sse_tickets. The stale disk
reading was harmless only while quotas were globally off in e2e (they no longer are — see
the new quota spec), i.e. two holes were masking each other.
clippy: `cargo clippy --all-targets -- -D warnings` now passes (it did not). Deleted the
dead jobs.rs (an unused BackgroundJob sketch) and unused model methods; `#[allow(dead_code)]`
+ comment on the sqlx FromRow field-sets that ARE populated by the DB. No SQL or logic
changed. `cargo test` requires DATABASE_URL by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both are the same class as the autocomplete bug fixed in bf68bc0: a handler must never
destroy the DOM node that is being clicked.
feed grid tiles: VirtualFeed keys tiles by upload.id but slices the uploads array
POSITIONALLY (`uploads.slice(i*COLS, …)`). A `new-upload` SSE prepends to the array, so
every tile shifts one slot and each row's keyed {#each} sees a new set of ids — Svelte
destroys and recreates the tile nodes. At a party, where photos stream in continuously, a
guest mid-tap can have the node torn out (tap swallowed) or, worse, like/open a DIFFERENT
photo that slid under their finger. Grid now buffers live arrivals behind the existing
"neue Beiträge" pill; list view is keyed at the top level and stays live.
host rebuild button: it lived inside an SSE-driven {#if exportGenerating}{:else if
exportReady}{:else} block, and rebuildExport() makes the backend broadcast export-progress
immediately — so pressing it flipped the branch and unmounted the button mid-click, on the
one screen whose purpose is recovering a broken keepsake. One button now stays mounted
across all three states; only its label and disabled vary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found while auditing the test suite: the ViewerOnly carry-forward (added by me in
9666d74) permanently breaks the ZIP download if a comment is deleted while the ZIP is
still being built.
A comment-only change doesn't alter the ZIP's media, so `Affects::ViewerOnly` carries the
finished archive into the new epoch rather than rebuilding it. But "finished" is a
PRECONDITION, and between `release_gallery` and the ZIP worker completing it is false —
for a real multi-GB gallery, for MINUTES. Deleting a comment in that window is an utterly
ordinary thing to do.
The carry-forward UPDATE is guarded on `status = 'done'`, so in that window it matched
nothing — and ViewerOnly re-armed only the viewer. The ZIP row was left at the retired
epoch; the in-flight worker finished and wrote `done` at an epoch `export_current` no
longer matches. `GET /export/zip` then 404s FOREVER: recovery only runs at boot, and
`release_gallery` refuses an already-released event. The guests' keepsake is simply gone,
and the only escape is the rebuild button added one commit ago.
Fix: the carry-forward's own `rows_affected()` decides. It matched => there is a current,
finished ZIP and only the viewer needs rebuilding. It didn't => there is nothing to
preserve, so rebuild the ZIP too, like any other invalidation. Never assume the
precondition; ask the UPDATE.
Proven non-vacuous: with the fix reverted the new test fails with the ZIP stranded at the
retired epoch (job epoch 1, event epoch 2, export_current holding only "html", download
404). Backend 40 tests, e2e 163 passed / 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `filter-search` spec was flaky (~3%: 5 failures in 180 runs), always as
"element was detached from the DOM" while clicking a suggestion. It was not a test
bug — it was reporting a real defect.
`selectSuggestion` sets `showAutocomplete = false`, which unmounts the dropdown, so a
suggestion button DESTROYS ITSELF when activated. It was wired to `onmousedown` — the
first event of the click sequence — purely to beat the input's `onblur`, which would
otherwise close the dropdown before a click could land. So whether the node survived to
`mouseup` depended on whether Svelte's flush happened to fall between the two events.
Under load it did, and the click was lost. That is also a real user-facing bug: because
it committed on PRESS, sliding off a suggestion you didn't mean to hit still applied the
filter, with no way to abort.
Fixed the standard combobox way: preventDefault on the dropdown's mousedown suppresses
the blur, which is the only thing onmousedown was buying — so the handler moves to
`onclick`, the LAST event, where self-destruction is harmless and press-then-slide-off
aborts like a button should. Because the input now keeps focus across a selection,
`onfocus` no longer re-fires, so reopening is also wired to click/input — without that
the picker could not be reopened without clicking away first.
Also freezes the suggestion SOURCE while the dropdown is open. `allTags` is ordered by
frequency and derives from `uploads`, which every `upload-processed` SSE replaces via
refreshFeedInPlace — so an arriving photo could REORDER the open dropdown under the
user's finger. At a party, where photos stream in continuously, that is a live mis-tap
hazard. This was NOT the cause of the flake (I first believed it was; a clean 60-run
said so and a 120-run refuted it), but it is a genuine defect on the same interaction,
so it stays.
Verified: 240/240 consecutive passes on the previously-flaky spec (baseline 2.8%, so
~0.1% odds of luck), full suite 162 passed / 1 skipped with zero failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>