fix(audit-2): video playback, role identity, keepsake EXIF, recover ceiling, WebKit CI

Second round: the two regressions the first round introduced, the remaining
gaps it left, and the CI change that makes the iOS guarantees actually gate a
change rather than being asserted.

Squashed from 9 commits, original messages preserved below.

──────── docs(deploy): document the update path — `up -d` alone ships nothing

The README only ever described a fresh install. There was no update section
anywhere, and `--build` appeared nowhere in the docs.

That matters because `app` and `frontend` are `build:` services with no published
image tag, and Compose has no source-change detection: if an image by that name
exists it is reused. So the natural `git pull && docker compose up -d` reports
"Container app-1 Running", rebuilds nothing, and exits 0. A deploy that shipped
none of the new code is indistinguishable from a successful one — which is how
eleven merged fixes can sit in the repo and never reach the box.

Verified both halves against a real stack rather than asserting them: with a
source change staged, `up -d` left the image ID untouched; `up -d --build`
produced a new image ID and a healthy /health.

Adds an "Updating an existing deployment" section covering backup-before-migrate,
pull, rebuild, health check, and an image-ID comparison to prove a build actually
happened. Also spells out the rollback trap: migrations run on boot and are not
undone by checking out an older commit, so rolling back code without restoring
the snapshot leaves the schema ahead of the binary and the app refusing to start.

──────── fix(video): play the actual video, and answer Range requests

Every video in the app was unplayable. Two independent defects, either one
sufficient on its own, and nothing in the suite covered either — no test
anywhere played media or asserted a `<video>` src.

1. The lightbox handed `<video>` a JPEG.

`pickMediaUrl` is mime-agnostic, and compression only ever produces a THUMBNAIL
for a video (one `ffmpeg -vframes 1` frame) — no preview, no display. So in the
DEFAULT saver mode the element's src resolved to `/api/v1/upload/{id}/thumbnail`,
served as `image/jpeg` with `nosniff` so the browser can't even sniff its way
out. Chromium reports DEMUXER_ERROR_COULD_NOT_OPEN.

Fixed in the lightbox rather than in `pickMediaUrl`: FeedListCard shares that
helper and legitimately wants the thumbnail for its `<img>` poster, so a central
mime branch would break the feed. This mirrors the rule the diashow already
applies ("videos play the original file directly"). Added `preload="none"` so
saver-mode guests on cellular still fetch nothing until they press play — there
is no smaller video derivative to offer them — plus `playsinline`, without which
iOS hijacks playback into fullscreen.

2. `stream_media_file` ignored Range entirely.

It took no request headers, so it could not see `Range`; it always returned 200
with the whole body and never sent Accept-Ranges or Content-Range. iOS Safari
opens every `<video>` with a `Range: bytes=0-1` probe and abandons the load
without a 206 — so video failed on the app's primary platform even in `original`
mode, where the src was already correct.

Adds single-range support (`bytes=N-`, `bytes=N-M`, `bytes=-S`) with 206 +
Content-Range, 416 + `bytes */len` past EOF, and Accept-Ranges advertised on
every response. Anything it won't handle — multi-range, non-bytes units, garbage
— falls back to a full 200, which RFC 9110 explicitly permits and which is safer
than guessing. All four media routes share the helper, so seeking works
uniformly.

`get_original` now serves `inline` instead of `attachment`. An attachment
disposition is hostile to a `<video>` element, and this route is the only source
of playable video bytes; it also matches what the UI promises, since the action
is labelled "Original anzeigen" — view, not download. `no-store` is deliberately
kept so a takedown still revokes access promptly; ranges work fine under it, the
client just re-fetches.

Tests: 11 unit tests pin the parser (the iOS `bytes=0-1` probe, inclusive ends,
suffix ranges, clamping past EOF, 416 vs 200, malformed fallbacks). A new
03-feed/video-playback spec asserts the src is the original and not the
thumbnail, that the browser accepts the bytes as media (readyState > 0, no
MediaError), that no video bytes are delivered before play, and that Range
returns the correct 206 slices and a 416 past EOF — verified on both Chromium
and WebKit.

The "not downloaded before play" test asserts no *delivered body* rather than no
request: WebKit opens a connection for a preload="none" video and immediately
aborts it (GET, no Range, status 0, nothing transferred) while Chromium issues
nothing at all. The portable guarantee is that no response carrying bytes
completes.

──────── fix(auth): bind the role store to the identity, not to the tab

The role store I added in the moderation work is a module-level singleton seeded
ONCE at import. `goto()` is a client-side navigation, so leaving and re-joining in
the same tab re-imports no module and re-runs no onMount — the previous user's
role simply stayed resident. Nothing reset it: not join, recover, admin login,
"Event verlassen", `clearAuth`, nor the api.ts 401 auto-clear.

So a host who left, followed by a guest joining on the same phone, left that guest
with `isStaff === true` and a "🚫 Beitrag entfernen" action on other people's
photos. The backend 403s the delete, so this was a false affordance rather than a
privilege escalation — but `/feed` never fetched `/me/context`, so unlike every
other route it never self-corrected either. It survived until a hard reload.

The mirror case was equally broken and easier to overlook: a guest who recovered
into a host account got NO host affordances.

`clearAuth` already had a hook registry for exactly this shape of problem, with a
comment explaining it exists to avoid circular imports. Add the missing mirror,
`onSetAuth`, fired by both `setAuth` and `setAdminAuth` after the new token is
resident, and have the role store register on both sides: clear to null on
logout, re-seed from the new token on login. That also gives
`syncRoleFromToken` — dead code with zero callers since I introduced it — its
intended purpose.

Seeding from the claim fixes the reported bug, but the claim is frozen for the
token's 30-day life, so a promotion or demotion still wouldn't reach the feed.
`/feed` now calls the existing `refreshEventState()` on mount, which fetches
`/me/context` and applies both the authoritative role and the lock/release state
in one request. The feed is the one route gating a destructive action on the role,
so it should not be the only route running on a stale claim.

Tests: 04-host/role-identity-reset drives the real flows. The first asserts the
host DOES see the action before asserting the newcomer does not — a negative
assertion alone would pass against a build that shipped no moderation at all. The
second covers the mirror, promoting a guest server-side while their resident token
still claims `role: guest`, so a fix that only cleared the role would fail it.

──────── fix(compression): reclaim failed originals instead of leaking them

Round 1 stopped the compression worker deleting an upload's original on failure —
a transient ENOSPC or a codec panic must never destroy the only copy of a photo a
guest cannot retake. But it left `Upload::soft_delete`'s quota refund in place, so
the bytes stayed on disk while the uploader was charged nothing for them.

That is worse than it first looks. The row is soft-deleted, so the file is
invisible and unowned; a guest hitting a reproducible codec failure can accumulate
orphans indefinitely at zero personal cost. And `active_uploaders` counts only
users with non-deleted uploads, so dropping out of that count RAISES everyone's
per-user ceiling — the leak loosens the very quota meant to contain it.

Keep the refund: the uploader didn't cause the failure and shouldn't silently lose
quota to it. Bound the leak instead, with an hourly sweep alongside the existing
session cleanup in `spawn_periodic_tasks`, reclaiming failed originals older than
14 days — comfortably longer than any single event, so an operator investigating a
failed upload still has the file.

The selection predicate is the entire safety argument, so it is deliberately
narrow: `compression_status = 'failed'` AND soft-deleted AND past the window AND
`original_path <> ''`. That is exactly the state the give-up path leaves behind,
and it cannot reach a live upload, an owner-deleted one, or a failure still inside
its recovery window. `original_path` is cleared after a successful reclaim, which
makes the sweep idempotent — otherwise a row whose file is already gone is
re-selected on every tick forever. The row itself is kept as the audit trail.

Tests reproduce the selection verbatim (same pattern as upload_concurrency) and
assert it against five near-misses that must survive, both sides of the retention
boundary, and the idempotence property.

Also fixes two comments in export.rs still claiming "the compression worker
hard-deletes an original when its transcode fails" — no longer true, and the
defensive handling they justify is now justified by this sweep and by ordinary
deletes instead.

──────── fix(export): apply EXIF orientation in the keepsake too

Round 1 fixed EXIF orientation in the compression worker, which corrected the live
app — feed preview and diashow display. The export worker was missed, and it does
not reuse those derivatives: it re-decodes the originals itself with `image::open`,
which ignores the orientation tag, then re-encodes to JPEG, which drops the tag —
so the viewer has no way to recover it.

The damage was oddly shaped, which is exactly why it reads as a viewer bug:

  Gallery.zip originals              correct  (byte-copied, EXIF intact)
  Memories viewer grid thumbnails    SIDEWAYS (always)
  Memories viewer full image >5 MB   SIDEWAYS (re-encoded at 2000px)
  Memories viewer full image ≤5 MB   correct  (streamed byte-for-byte)

So in the keepsake people actually keep, every portrait photo in the grid was on
its side, and clicking through silently "fixed" small photos but not large ones.

Rather than paste the decoder dance a third time, extract `services::imaging::
decode_oriented` and route both workers through it, so there is exactly one way to
turn a file on disk into a DynamicImage. It carries a second invariant that had
also drifted: `image::open` applies NO decode limits, so the export path was
decoding arbitrary user-supplied images unbounded — the decompression-bomb cap
existed only in the compression worker. Both now come as a pair, which is the
point of having one function.

Not done: switching export to consume the existing `display` derivative. It would
fix orientation and drop a redundant full-resolution decode per photo, but it
would also replace the pristine ≤5 MB originals in the keepsake with 2048px
re-encodes — a real quality regression in the one artefact people keep forever.

Test uploads the round-1 fixture (40x20 landscape tagged Orientation=6), runs a
real export, pulls the thumbnail out of Memories.zip and asserts it came back
portrait — with a sanity check that the source really is stored landscape, so the
test can't pass against a pipeline that does nothing.

──────── fix(recover): cap name cycling, and stop bcrypt blocking the runtime

Round 1 gave /join a per-IP ceiling and left /recover with only its
`recover:{ip}:{name}` bucket. That key is right for the job it was written for —
stopping someone who knows a display name (they're listed on the feed) from
burning the victim's 3-strike PIN counter and locking them out on repeat. But the
name is ATTACKER-CHOSEN, so cycling names mints a fresh 5-attempt bucket every
time and the per-IP cost is unbounded.

What sits behind that limiter makes it worse than a normal flood: every call runs
a cost-12 bcrypt verify, including an UNCONDITIONAL throwaway verify for names
that don't exist — added deliberately to close a timing oracle. So an unknown name
is the single cheapest way to make the server do ~200ms of hashing.

Adds `recover_ip_rate_per_min` (default 30, migration 019), checked BEFORE the
per-name bucket so a name generator can't walk past it. 30/min is far above any
real recovery attempt while capping a flood. The per-name bucket is untouched and
remains the anti-guessing control.

The second half matters as much as the first: bcrypt was running inline on the
async runtime everywhere. At cost 12 that pins a tokio worker thread for ~200ms,
and there is only one per core — so a login flood stalled every other request on
the box, including the feed. There was no spawn_blocking anywhere in the auth
module, despite SECURITY-BACKLOG claiming bcrypt had been offloaded.

Route all of it through `verify_password` / `hash_password` on the blocking pool.
That covers /recover, /admin/login, the host PIN reset, and — the one most likely
to bite at a real event — the PIN hash minted on every single /join. Saturating
the blocking pool degrades logins; saturating the worker threads degrades
everything.

Tests: cycling distinct names from one IP now hits the ceiling with a Retry-After,
and — the assertion that keeps the fix honest — repeated wrong PINs against ONE
name are still throttled with the ceiling set generously high, so the ceiling
added protection rather than replacing it.

──────── ci(e2e): run WebKit, so the iOS guarantees actually gate a PR

The workflow installed only Chromium and ran chromium-desktop + chromium-mobile.
iOS Safari is the app's stated primary user — a wedding guest opening a QR link —
and WebKit is the only engine in the matrix that reproduces two of its behaviours:

  - it enforces X-Frame-Options on the hidden download iframe, so a site-wide DENY
    makes the keepsake download silently do nothing. Blink hands attachments to
    the download manager before the frame check and never notices.
  - it abandons a <video> load unless its Range probe gets a 206.

Both of those shipped. Adding 06-export to the webkit project in the round-1 fix
bought nothing on a PR, because CI never ran that project at all — the regression
test written specifically to catch the blocker only ever executed locally.

Runs 71 tests (67 pass, 4 skip on the documented IndexedDB-blob harness
limitation) in ~1.5 minutes locally, using the exact command added here.

──────── chore(backend): satisfy cargo fmt

`checks.yml` runs `cargo fmt --check`, and it has been failing since the round-1
audit fixes: I gated those on `cargo build` and `cargo clippy` but never ran fmt,
so three files drifted then and eight more this round. Pure formatting — no
behaviour change; clippy stays at zero and all 70 backend tests still pass.

Worth noting for next time: clippy passing is not evidence fmt does.

──────── chore: satisfy prettier in frontend and e2e

`checks.yml` runs `npm run format:check` for both projects and both were failing.

- frontend/src/lib/ui-store.ts is mine, unformatted since the round-1 upload-queue
  badge fix — the same miss as the rustfmt one: I gated on svelte-check and eslint
  but never on format:check.
- e2e/loadtest/* and e2e/shots.mjs have been unformatted since 7758270 and are
  unrelated to the audit work. Fixed here because they block the same gate and the
  fix is mechanical; no behaviour change in either project.

Still red and deliberately NOT fixed here: `npm run lint` in the frontend reports
`svelte/prefer-svelte-reactivity` on routes/diashow/+page.svelte:208 (a mutable
`Set` where the rule wants `SvelteSet`), pre-existing since 5009590. That one is a
real reactivity change in code I have no test coverage for, so it belongs in its
own change rather than smuggled into a formatting commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-28 21:28:11 +02:00
parent 0c0eed885a
commit b601c062bd
30 changed files with 1503 additions and 161 deletions

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
e2e: e2e:
name: Playwright E2E (chromium-desktop) name: Playwright E2E (chromium + webkit)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@@ -25,7 +25,7 @@ jobs:
- name: Install Playwright browsers - name: Install Playwright browsers
working-directory: ./e2e working-directory: ./e2e
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium webkit
- name: Bring up the test stack - name: Bring up the test stack
working-directory: ./e2e working-directory: ./e2e
@@ -54,6 +54,22 @@ jobs:
working-directory: ./e2e working-directory: ./e2e
run: npm run test:e2e -- --project=chromium-mobile run: npm run test:e2e -- --project=chromium-mobile
# iOS Safari is the app's stated primary user (a wedding guest opening a QR link), and
# WebKit is the ONLY engine here that reproduces two of its behaviours:
# - it enforces X-Frame-Options on the download iframe, so a site-wide `DENY` makes the
# keepsake download silently do nothing. Blink hands attachments to the download
# manager first and never notices. That shipped once already.
# - it abandons a <video> load without a 206 response to its Range probe.
# Both regressions are invisible to every Chromium project, so running WebKit is what
# actually gates them on a PR rather than on someone remembering to test locally.
#
# The project is scoped in playwright.config.ts to the journeys a guest walks
# (01-auth, 02-upload, 03-feed, 06-export); four IndexedDB-blob tests skip themselves
# there — see helpers/webkit.ts for why that is the harness and not the app.
- name: Run E2E tests (webkit / iOS)
working-directory: ./e2e
run: npm run test:e2e -- --project=webkit-iphone
- name: Upload Playwright report - name: Upload Playwright report
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -115,6 +115,45 @@ Caddy automatically obtains a Let's Encrypt certificate on first start. The app
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up > docker compose -f docker-compose.yml -f docker-compose.dev.yml up
> ``` > ```
### Updating an existing deployment
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
> `build:` services with no published image tag, and Compose has no source-change detection:
> if an image with that name already exists it is reused. After a `git pull` the command
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
> nothing looks exactly like a successful one. `--build` is what makes it real.
```bash
cd /path/to/eventsnap
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
# (See "Backup" below; the database dump is the one that matters here.)
# 2. Fetch the new code.
git pull
# 3. Rebuild and restart. --build is NOT optional.
docker compose up -d --build
# 4. Confirm the app came back up. Anything other than "ok" means check the logs.
curl -fsS https://DOMAIN/health && echo
# 5. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
# compare — it must have changed. (Ignore the CREATED column; it reports the base
# layer's age, not this build's.) An unchanged ID means step 3 ran without --build
# and you are still serving the old code.
docker compose images app frontend
```
Migrations are applied by the backend on startup, so step 3 covers them. If `app` stays
unhealthy afterwards, `docker compose logs app` will name the failing migration — and note
that a migration applied by a *newer* build is not removed by checking out an older commit,
so rolling back code without restoring the database snapshot from step 1 leaves the schema
ahead of the binary and the app refusing to boot.
Only the two application services rebuild; `db` and `caddy` are pinned upstream images and
are untouched, so data volumes and the TLS certificate survive.
### Generate required secrets ### Generate required secrets
```bash ```bash

View File

@@ -0,0 +1 @@
DELETE FROM config WHERE key = 'recover_ip_rate_per_min';

View File

@@ -0,0 +1,19 @@
-- Per-IP flood ceiling for /recover, mirroring the one migration 017 added for /join.
--
-- Rationale: /recover is keyed `recover:{ip}:{name}` at 5 per 15 minutes. That is the
-- right shape for its actual job — stopping someone who knows a display name (they are
-- visible on the feed) from burning the victim's 3-strike PIN counter and locking them
-- out repeatedly. But the name is attacker-chosen, so cycling names mints a fresh bucket
-- every time and the per-IP cost is unbounded.
--
-- Behind that limiter sits a cost-12 bcrypt verify, including an UNCONDITIONAL throwaway
-- verify for names that don't exist — deliberately, to close a timing oracle. So an
-- unknown name is the cheapest possible way to make the server do ~200ms of hashing.
-- Without a ceiling, one client can saturate the box's CPU with a name generator.
--
-- 30/min is far above any real recovery attempt (a guest tries their PIN a handful of
-- times) while capping a name-cycling flood. The per-(ip, name) bucket is unchanged and
-- remains the anti-guessing control.
INSERT INTO config (key, value) VALUES
('recover_ip_rate_per_min', '30')
ON CONFLICT (key) DO NOTHING;

View File

@@ -111,7 +111,7 @@ pub async fn join(
// Generate a 4-digit PIN // Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let pin_hash = hash_password(pin.clone(), 12).await?;
// The pre-check above is racy: two simultaneous joins with the same name can both // The pre-check above is racy: two simultaneous joins with the same name can both
// pass it, and the DB's unique index then rejects the loser. Map that unique // pass it, and the DB's unique index then rejects the loser. Map that unique
@@ -175,6 +175,28 @@ fn dummy_pin_hash() -> &'static str {
}) })
} }
/// Run a bcrypt verify on the blocking pool.
///
/// bcrypt at cost 12 is ~200ms of deliberate CPU. Called inline on an async task it pins a
/// tokio WORKER thread for that whole time, and the runtime only has one per core — so a
/// 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.
async fn verify_password(candidate: String, hash: String) -> bool {
tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
.await
.unwrap_or(false)
}
/// Hash a secret on the blocking pool. Same reasoning as [`verify_password`] — and this one
/// runs on the busiest auth path there is, since every guest who joins gets a PIN hashed.
pub async fn hash_password(secret: String, cost: u32) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
pub async fn recover( pub async fn recover(
State(state): State<AppState>, State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>, ConnectInfo(peer): ConnectInfo<SocketAddr>,
@@ -192,6 +214,26 @@ pub async fn recover(
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; 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; let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
if rate_limits_on && recover_rate_on { if rate_limits_on && recover_rate_on {
// Coarse per-IP ceiling FIRST. The per-(ip, name) bucket below is the anti-guessing
// control, but the name is attacker-chosen, so cycling names mints a fresh bucket
// every time and leaves the per-IP cost unbounded. That matters more here than
// anywhere else: every call runs a cost-12 bcrypt verify, including an
// unconditional throwaway one for names that don't exist (see below), so an unknown
// name is the CHEAPEST way to make the server do ~200ms of hashing. Checked before
// the per-name bucket so a name generator can't walk past it.
let ip_ceiling =
config::get_usize(&state.config_cache, "recover_ip_rate_per_min", 30).await;
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("recover_ip:{ip}"),
ip_ceiling,
Duration::from_secs(60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
Some(retry_after_secs),
));
}
let name_key = display_name.to_lowercase(); let name_key = display_name.to_lowercase();
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry( if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("recover:{ip}:{name_key}"), format!("recover:{ip}:{name_key}"),
@@ -217,7 +259,7 @@ pub async fn recover(
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or // PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
// timing. Display names are already public on the feed, but this still closes // timing. Display names are already public on the feed, but this still closes
// the /recover enumeration + timing oracle. // the /recover enumeration + timing oracle.
let _ = bcrypt::verify(&body.pin, dummy_pin_hash()); let _ = verify_password(body.pin.clone(), dummy_pin_hash().to_string()).await;
return Err(AppError::Unauthorized("PIN ist falsch.".into())); return Err(AppError::Unauthorized("PIN ist falsch.".into()));
} }
@@ -241,7 +283,7 @@ pub async fn recover(
User::reset_pin_attempts(&state.pool, user.id).await?; User::reset_pin_attempts(&state.pool, user.id).await?;
} }
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash).unwrap_or(false); let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await;
if pin_matches { if pin_matches {
// Reset failed attempts on success // Reset failed attempts on success
@@ -340,7 +382,11 @@ pub async fn admin_login(
)); ));
} }
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash).unwrap_or(false); let valid = verify_password(
body.password.clone(),
state.config.admin_password_hash.clone(),
)
.await;
if !valid { if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password"); tracing::warn!(ip = %ip, "admin_login: wrong password");
@@ -366,8 +412,7 @@ pub async fn admin_login(
let dummy_pin: String = (0..32) let dummy_pin: String = (0..32)
.map(|_| rand::rng().random_range(b'a'..=b'z') as char) .map(|_| rand::rng().random_range(b'a'..=b'z') as char)
.collect(); .collect();
let dummy_hash = let dummy_hash = hash_password(dummy_pin.clone(), 4).await?;
bcrypt::hash(&dummy_pin, 4).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?; let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1") sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
.bind(user.id) .bind(user.id)

View File

@@ -124,6 +124,9 @@ pub async fn patch_config(
// only bounds raw volume from one source, so it must stay well above the size of a // only bounds raw volume from one source, so it must stay well above the size of a
// party arriving at once (see migration 017). // party arriving at once (see migration 017).
("join_ip_rate_per_min", true, 1.0, 100_000.0), ("join_ip_rate_per_min", true, 1.0, 100_000.0),
// Same shape for /recover: the per-(ip, name) bucket is the anti-guessing control,
// this only bounds a name-cycling flood in front of a cost-12 bcrypt (migration 019).
("recover_ip_rate_per_min", true, 1.0, 100_000.0),
("quota_tolerance", false, 0.0, 1.0), ("quota_tolerance", false, 0.0, 1.0),
("estimated_guest_count", true, 1.0, 1_000_000.0), ("estimated_guest_count", true, 1.0, 1_000_000.0),
]; ];

View File

@@ -433,7 +433,7 @@ pub async fn reset_user_pin(
} }
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let pin_hash = crate::auth::handlers::hash_password(pin.clone(), 12).await?;
sqlx::query( sqlx::query(
"UPDATE \"user\" "UPDATE \"user\"

View File

@@ -41,7 +41,7 @@ pub async fn truncate_all(
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Reseed config. The NUMERIC values mirror migrations 005/015/016; the BOOLEAN // Reseed config. The NUMERIC values mirror migrations 005/015/016/017/019; the BOOLEAN
// toggles deliberately do NOT — migration 009 seeds every one of them `true` // toggles deliberately do NOT — migration 009 seeds every one of them `true`
// (production), and this forces them `false` so the suite isn't fighting rate limits // (production), and this forces them `false` so the suite isn't fighting rate limits
// and quotas it isn't testing. // and quotas it isn't testing.
@@ -62,6 +62,7 @@ pub async fn truncate_all(
('feed_rate_per_min', '60'), ('feed_rate_per_min', '60'),
('export_rate_per_day', '3'), ('export_rate_per_day', '3'),
('join_ip_rate_per_min', '60'), ('join_ip_rate_per_min', '60'),
('recover_ip_rate_per_min', '30'),
('quota_tolerance', '0.75'), ('quota_tolerance', '0.75'),
('estimated_guest_count', '100'), ('estimated_guest_count', '100'),
('compression_concurrency', '2'), ('compression_concurrency', '2'),

View File

@@ -647,12 +647,95 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
} }
} }
/// Outcome of parsing a `Range` request header against a known file length.
#[derive(Debug, PartialEq, Eq)]
enum RangeSpec {
/// No `Range` header, or one we deliberately don't honour (multi-range, non-`bytes`
/// unit, malformed). RFC 9110 lets a server ignore a Range it can't process and reply
/// 200 with the full body, which is what every one of these cases does.
Full,
/// A single satisfiable range, resolved to inclusive absolute offsets.
Partial { start: u64, end: u64 },
/// Syntactically valid but starts beyond EOF — must be answered 416, not 200, or a
/// player can loop re-requesting it.
Unsatisfiable,
}
/// Parse a single-range `bytes=` header against `len`.
///
/// Deliberately supports only the three forms a media element actually sends —
/// `bytes=N-`, `bytes=N-M`, `bytes=-S` (suffix) — and treats everything else as `Full`.
/// Multi-range responses need `multipart/byteranges`, which no `<video>` requires.
fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
let Some(raw) = header else {
return RangeSpec::Full;
};
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
return RangeSpec::Full;
};
// Multi-range → fall back to the whole body rather than lie about the content.
if spec.contains(',') {
return RangeSpec::Full;
}
let Some((from, to)) = spec.split_once('-') else {
return RangeSpec::Full;
};
let (from, to) = (from.trim(), to.trim());
// A zero-length file can satisfy no range at all.
if len == 0 {
return if from.is_empty() && to.is_empty() {
RangeSpec::Full
} else {
RangeSpec::Unsatisfiable
};
}
let (start, end) = if from.is_empty() {
// Suffix form: the last `to` bytes.
let Ok(suffix) = to.parse::<u64>() else {
return RangeSpec::Full;
};
if suffix == 0 {
return RangeSpec::Unsatisfiable;
}
(len.saturating_sub(suffix), len - 1)
} else {
let Ok(start) = from.parse::<u64>() else {
return RangeSpec::Full;
};
let end = if to.is_empty() {
len - 1
} else {
match to.parse::<u64>() {
// An end past EOF is clamped, not rejected (RFC 9110 §14.1.1).
Ok(end) => end.min(len - 1),
Err(_) => return RangeSpec::Full,
}
};
(start, end)
};
if start >= len || start > end {
RangeSpec::Unsatisfiable
} else {
RangeSpec::Partial { start, end }
}
}
/// Stream a media file from disk into an HTTP response with a fixed set of security /// Stream a media file from disk into an HTTP response with a fixed set of security
/// headers. Every media response (original, preview, thumbnail) goes through here so /// headers. Every media response (original, preview, display, thumbnail) goes through here
/// they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against /// so they consistently carry `X-Content-Type-Options: nosniff` (defense-in-depth against
/// content-type confusion, even if the edge proxy is bypassed) plus an explicit /// content-type confusion, even if the edge proxy is bypassed) plus an explicit
/// `Content-Disposition` and `Cache-Control`. /// `Content-Disposition` and `Cache-Control`.
///
/// Honours a single `Range`. This is not an optimisation: iOS Safari opens every `<video>`
/// with a `Range: bytes=0-1` probe and abandons the load unless it gets a `206` with a
/// `Content-Range`. Without this, video is unplayable on the app's primary platform no
/// matter what `src` the element is given. `Accept-Ranges: bytes` is advertised on every
/// response so clients know seeking is available before they ask.
async fn stream_media_file( async fn stream_media_file(
req_headers: &axum::http::HeaderMap,
absolute: &std::path::Path, absolute: &std::path::Path,
content_type: String, content_type: String,
disposition: &str, disposition: &str,
@@ -660,30 +743,60 @@ async fn stream_media_file(
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
use axum::body::Body; use axum::body::Body;
use axum::http::{Response, StatusCode, header}; use axum::http::{Response, StatusCode, header};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
if !absolute.exists() { if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into())); return Err(AppError::NotFound("Datei nicht gefunden.".into()));
} }
let file = tokio::fs::File::open(absolute) let mut file = tokio::fs::File::open(absolute)
.await .await
.map_err(|e| AppError::Internal(e.into()))?; .map_err(|e| AppError::Internal(e.into()))?;
let metadata = file let len = file
.metadata() .metadata()
.await .await
.map_err(|e| AppError::Internal(e.into()))?; .map_err(|e| AppError::Internal(e.into()))?
let stream = ReaderStream::new(file); .len();
Response::builder() let range = parse_range(
.status(StatusCode::OK) req_headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
.header(header::CONTENT_TYPE, content_type) len,
.header(header::CONTENT_DISPOSITION, disposition) );
.header(header::CONTENT_LENGTH, metadata.len())
.header(header::CACHE_CONTROL, cache_control) let base = |status: StatusCode| {
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") Response::builder()
.body(Body::from_stream(stream)) .status(status)
.map_err(|e| AppError::Internal(e.into())) .header(header::CONTENT_TYPE, content_type.clone())
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CACHE_CONTROL, cache_control)
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(header::ACCEPT_RANGES, "bytes")
};
match range {
RangeSpec::Full => base(StatusCode::OK)
.header(header::CONTENT_LENGTH, len)
.body(Body::from_stream(ReaderStream::new(file)))
.map_err(|e| AppError::Internal(e.into())),
RangeSpec::Partial { start, end } => {
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|e| AppError::Internal(e.into()))?;
let span = end - start + 1;
base(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_LENGTH, span)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
.body(Body::from_stream(ReaderStream::new(file.take(span))))
.map_err(|e| AppError::Internal(e.into()))
}
RangeSpec::Unsatisfiable => base(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
.body(Body::empty())
.map_err(|e| AppError::Internal(e.into())),
}
} }
/// Streaming download of the original file behind an upload. Used by: /// Streaming download of the original file behind an upload. Used by:
@@ -700,6 +813,7 @@ async fn stream_media_file(
/// [`get_preview`] / [`get_thumbnail`]). /// [`get_preview`] / [`get_thumbnail`]).
pub async fn get_original( pub async fn get_original(
State(state): State<AppState>, State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id) let media = Upload::find_visible_media(&state.pool, upload_id)
@@ -711,10 +825,22 @@ pub async fn get_original(
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("original"); .unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\""); // `inline`, not `attachment`. This route is the only source of playable video bytes
// (there is no video derivative), and an attachment disposition is hostile to a
// `<video>` element — Safari in particular. It also matches what the UI promises:
// the action is labelled "Original anzeigen", i.e. view, not download.
let disposition = format!("inline; filename=\"{filename}\"");
// Full-res original: force download, never cache at the edge. // Full-res original: never cache at the edge, so a takedown revokes access promptly.
stream_media_file(&absolute, media.mime_type, &disposition, "no-store").await // Range requests still work under no-store; the client simply re-fetches each range.
stream_media_file(
&headers,
&absolute,
media.mime_type,
&disposition,
"no-store",
)
.await
} }
/// Streaming access to an upload's compressed **preview** image. Gated exactly like /// Streaming access to an upload's compressed **preview** image. Gated exactly like
@@ -729,6 +855,7 @@ pub async fn get_original(
/// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case. /// `upload-deleted` / `user-hidden` SSE events, so this only bounds the raw-URL edge case.
pub async fn get_preview( pub async fn get_preview(
State(state): State<AppState>, State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id) let media = Upload::find_visible_media(&state.pool, upload_id)
@@ -739,6 +866,7 @@ pub async fn get_preview(
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?; .ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel); let absolute = state.config.media_path.join(&rel);
stream_media_file( stream_media_file(
&headers,
&absolute, &absolute,
"image/jpeg".to_string(), "image/jpeg".to_string(),
"inline", "inline",
@@ -753,6 +881,7 @@ pub async fn get_preview(
/// back to the original. /// back to the original.
pub async fn get_display( pub async fn get_display(
State(state): State<AppState>, State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id) let media = Upload::find_visible_media(&state.pool, upload_id)
@@ -763,6 +892,7 @@ pub async fn get_display(
.ok_or_else(|| AppError::NotFound("Anzeige nicht verfügbar.".into()))?; .ok_or_else(|| AppError::NotFound("Anzeige nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel); let absolute = state.config.media_path.join(&rel);
stream_media_file( stream_media_file(
&headers,
&absolute, &absolute,
"image/jpeg".to_string(), "image/jpeg".to_string(),
"inline", "inline",
@@ -775,6 +905,7 @@ pub async fn get_display(
/// [`get_preview`]. /// [`get_preview`].
pub async fn get_thumbnail( pub async fn get_thumbnail(
State(state): State<AppState>, State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id) let media = Upload::find_visible_media(&state.pool, upload_id)
@@ -785,6 +916,7 @@ pub async fn get_thumbnail(
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?; .ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel); let absolute = state.config.media_path.join(&rel);
stream_media_file( stream_media_file(
&headers,
&absolute, &absolute,
"image/jpeg".to_string(), "image/jpeg".to_string(),
"inline", "inline",
@@ -795,7 +927,108 @@ pub async fn get_thumbnail(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::quota_limit_bytes; use super::{RangeSpec, parse_range, quota_limit_bytes};
// `Range` handling exists because iOS Safari probes every `<video>` with
// `Range: bytes=0-1` and abandons the load without a 206. These pin the forms a
// media element actually sends, plus the edges that decide 206 vs 200 vs 416.
#[test]
fn no_range_header_is_a_full_response() {
assert_eq!(parse_range(None, 100), RangeSpec::Full);
}
#[test]
fn open_ended_range_runs_to_eof() {
assert_eq!(
parse_range(Some("bytes=10-"), 100),
RangeSpec::Partial { start: 10, end: 99 }
);
}
#[test]
fn closed_range_is_inclusive_on_both_ends() {
// The iOS probe. Two bytes, 0 and 1 — an exclusive end would return one.
assert_eq!(
parse_range(Some("bytes=0-1"), 100),
RangeSpec::Partial { start: 0, end: 1 }
);
}
#[test]
fn suffix_range_returns_the_last_n_bytes() {
assert_eq!(
parse_range(Some("bytes=-20"), 100),
RangeSpec::Partial { start: 80, end: 99 }
);
}
#[test]
fn suffix_longer_than_the_file_clamps_to_the_whole_file() {
assert_eq!(
parse_range(Some("bytes=-500"), 100),
RangeSpec::Partial { start: 0, end: 99 }
);
}
#[test]
fn end_past_eof_is_clamped_not_rejected() {
// RFC 9110 §14.1.1 — players routinely ask for more than is there.
assert_eq!(
parse_range(Some("bytes=90-999"), 100),
RangeSpec::Partial { start: 90, end: 99 }
);
}
#[test]
fn start_past_eof_is_416_not_a_full_body() {
// Answering 200 here makes a player re-request forever.
assert_eq!(
parse_range(Some("bytes=100-"), 100),
RangeSpec::Unsatisfiable
);
assert_eq!(
parse_range(Some("bytes=200-300"), 100),
RangeSpec::Unsatisfiable
);
}
#[test]
fn inverted_range_is_unsatisfiable() {
assert_eq!(
parse_range(Some("bytes=50-10"), 100),
RangeSpec::Unsatisfiable
);
}
#[test]
fn unsupported_or_malformed_forms_fall_back_to_the_full_body() {
// Ignoring a Range we can't process and sending 200 is explicitly allowed, and
// safer than guessing. Multi-range would need multipart/byteranges, which no
// <video> asks for.
for header in [
"bytes=0-10,20-30", // multi-range
"items=0-10", // non-bytes unit
"bytes=abc-def", // garbage
"bytes=", // empty spec
"nonsense", // no unit at all
] {
assert_eq!(parse_range(Some(header), 100), RangeSpec::Full, "{header}");
}
}
#[test]
fn last_byte_is_reachable() {
assert_eq!(
parse_range(Some("bytes=99-99"), 100),
RangeSpec::Partial { start: 99, end: 99 }
);
}
#[test]
fn empty_file_satisfies_no_range() {
assert_eq!(parse_range(Some("bytes=0-"), 0), RangeSpec::Unsatisfiable);
assert_eq!(parse_range(None, 0), RangeSpec::Full);
}
#[test] #[test]
fn divides_free_space_by_uploaders_with_tolerance() { fn divides_free_space_by_uploaders_with_tolerance() {

View File

@@ -70,6 +70,7 @@ async fn main() -> Result<()> {
pool, pool,
state.rate_limiter.clone(), state.rate_limiter.clone(),
state.sse_tickets.clone(), state.sse_tickets.clone(),
config.media_path.clone(),
); );
// Ensure media directories exist // Ensure media directories exist

View File

@@ -150,11 +150,7 @@ impl Upload {
/// Stamp which revision of the derivative pipeline produced this row's preview/display, /// Stamp which revision of the derivative pipeline produced this row's preview/display,
/// so the startup backfill can find rows generated by an older one exactly once. /// so the startup backfill can find rows generated by an older one exactly once.
pub async fn set_derivatives_rev( pub async fn set_derivatives_rev(pool: &PgPool, id: Uuid, rev: i16) -> Result<(), sqlx::Error> {
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 WHERE id = $1")
.bind(id) .bind(id)
.bind(rev) .bind(rev)

View File

@@ -3,7 +3,6 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use image::ImageDecoder;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::{Semaphore, broadcast}; use tokio::sync::{Semaphore, broadcast};
use uuid::Uuid; use uuid::Uuid;
@@ -87,8 +86,7 @@ impl CompressionWorker {
error = ?e, %upload_id, attempt, error = ?e, %upload_id, attempt,
"compression attempt failed; retrying" "compression attempt failed; retrying"
); );
tokio::time::sleep(std::time::Duration::from_secs(2u64.pow(attempt))) tokio::time::sleep(std::time::Duration::from_secs(2u64.pow(attempt))).await;
.await;
attempt += 1; attempt += 1;
// The data may have been reset while we slept (e2e TRUNCATE). // The data may have been reset while we slept (e2e TRUNCATE).
if worker.generation.load(Ordering::SeqCst) != born_at { if worker.generation.load(Ordering::SeqCst) != born_at {
@@ -205,38 +203,9 @@ impl CompressionWorker {
// Run blocking image operations in a spawn_blocking task // Run blocking image operations in a spawn_blocking task
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
// Reject decompression bombs *before* fully decoding: the upload body // Decompression-bomb limits + EXIF orientation, both in one place — see
// cap bounds the file size on disk, but a small file can still decode to // services::imaging for why neither may be skipped.
// enormous dimensions (e.g. a ~1 MB image expanding to 50k×50k px → let img = crate::services::imaging::decode_oriented(&original)?;
// gigabytes), OOM-ing the box during decode/resize. 12000×12000 covers
// any real phone photo; max_alloc hard-caps the decode allocation.
let mut reader = image::ImageReader::open(&original)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
// Apply the EXIF orientation. Phones do not rotate the sensor data — they record
// the physical camera orientation in a tag and store the pixels as shot. `decode()`
// hands back those raw pixels, and the JPEG re-encode below writes no EXIF at all,
// so skipping this stores EVERY portrait photo sideways in the feed preview, the
// 2048px diashow display and the keepsake — while the untouched original still
// renders upright, which is why it looks like a viewer bug rather than a pipeline
// one. `into_decoder` carries the limits set above through to the decoder, so the
// decompression-bomb guard is unaffected.
let mut decoder = reader.into_decoder().context("failed to decode image")?;
// A missing or malformed tag is not a failure: most images simply have none.
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let mut img =
image::DynamicImage::from_decoder(decoder).context("failed to decode image")?;
img.apply_orientation(orientation);
let img = img;
// Preview: max 800px, preserving aspect ratio (data-saver feed). // Preview: max 800px, preserving aspect ratio (data-saver feed).
img.resize( img.resize(
@@ -332,12 +301,9 @@ impl CompressionWorker {
Ok((preview_rel, display_rel)) => { Ok((preview_rel, display_rel)) => {
let _ = Upload::set_preview_path(&worker.pool, id, &preview_rel).await; let _ = Upload::set_preview_path(&worker.pool, id, &preview_rel).await;
let _ = Upload::set_display_path(&worker.pool, id, &display_rel).await; let _ = Upload::set_display_path(&worker.pool, id, &display_rel).await;
let _ = Upload::set_derivatives_rev( let _ =
&worker.pool, Upload::set_derivatives_rev(&worker.pool, id, Self::DERIVATIVES_REV)
id, .await;
Self::DERIVATIVES_REV,
)
.await;
tracing::info!("derivatives regenerated for upload {id}"); tracing::info!("derivatives regenerated for upload {id}");
} }
Err(e) => { Err(e) => {

View File

@@ -720,10 +720,11 @@ async fn run_html_export_inner(
let src = media_path.join(&row.original_path); let src = media_path.join(&row.original_path);
// Stat ONCE, up front, and skip this upload if the source is gone. The old code probed with // Stat ONCE, up front, and skip this upload if the source is gone. The old code probed with
// `exists()` here and then did `metadata(&src).await?` further down — a TOCTOU whose `?` // `exists()` here and then did `metadata(&src).await?` further down — a TOCTOU whose `?`
// aborted the ENTIRE keepsake if the file vanished in between. It genuinely can: the // aborted the ENTIRE keepsake if the file vanished in between. It can still happen: the
// compression worker hard-deletes an original when its transcode fails, and it can still be // compression worker no longer deletes originals on failure, but the hourly sweep reclaims
// running when the gallery is released. A missing source must degrade one entry, never the // them once past the retention window, and an owner or host delete can land mid-export. A
// whole archive (which, once released, the host cannot rebuild without reopening uploads). // missing source must degrade one entry, never the whole archive (which, once released, the
// host cannot rebuild without reopening uploads).
let src_meta = match tokio::fs::metadata(&src).await { let src_meta = match tokio::fs::metadata(&src).await {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
@@ -790,7 +791,12 @@ async fn run_html_export_inner(
let thumb_path_clone = thumb_path.clone(); let thumb_path_clone = thumb_path.clone();
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> { let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&src_clone).context("failed to open image for thumbnail")?; // `decode_oriented`, not `image::open`: the latter ignores the EXIF
// orientation tag AND applies no decode limits. Using it here is why every
// portrait photo came out sideways in the keepsake's HTML viewer grid — the
// re-encode below drops the tag, so the viewer cannot recover it.
let img = crate::services::imaging::decode_oriented(&src_clone)
.context("failed to open image for thumbnail")?;
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3); let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
resized resized
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg) .save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
@@ -811,8 +817,12 @@ async fn run_html_export_inner(
let full_path_clone = full_path.clone(); let full_path_clone = full_path.clone();
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> { let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
let img = // Same reason as the thumbnail above. This branch only runs for originals
image::open(&src_clone).context("failed to open image for compression")?; // over 5 MB, which is why the viewer's full image looked correct for small
// photos and sideways for large ones — an inconsistency that reads as a
// viewer bug rather than an export one.
let img = crate::services::imaging::decode_oriented(&src_clone)
.context("failed to open image for compression")?;
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3); let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
resized resized
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg) .save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
@@ -953,9 +963,9 @@ async fn run_html_export_inner(
for (name, source) in &media_manifest { for (name, source) in &media_manifest {
let path = source.path(); let path = source.path();
// Open-first: a source that disappeared between the manifest being built and now (the // Open-first: a source that disappeared between the manifest being built and now (a
// compression worker deletes originals on transcode failure) must skip this entry, not // delete, or the hourly sweep reclaiming a long-failed original) must skip this entry,
// fail the whole viewer. Opening collapses the check and the use into one operation. // not fail the whole viewer. Opening collapses the check and the use into one operation.
let src_file = match tokio::fs::File::open(path).await { let src_file = match tokio::fs::File::open(path).await {
Ok(f) => f, Ok(f) => f,
Err(e) => { Err(e) => {

View File

@@ -0,0 +1,52 @@
//! Shared image decoding.
//!
//! Exists so there is exactly ONE way to turn a file on disk into a `DynamicImage` in this
//! codebase. Two properties have to hold everywhere an image is decoded, and both were
//! previously re-derived per call site — which is how they drifted apart:
//!
//! - **EXIF orientation must be applied.** Phones do not rotate sensor data; they record how
//! the camera was held in a tag and store the pixels as shot. `image::open` and
//! `ImageReader::decode` both hand back the raw pixels and ignore that tag, and re-encoding
//! to JPEG writes no EXIF, so the derivative is permanently sideways while the untouched
//! original still renders upright. The compression worker was fixed; the export worker was
//! not, so every portrait photo came out sideways in the keepsake's HTML viewer.
//! - **Decode limits must be set.** The upload body cap bounds the file on disk, but a small
//! file can decode to enormous dimensions (a ~1 MB image expanding to 50k×50k px), OOM-ing
//! the box. `image::open` applies NO limits at all, so the export path was also decoding
//! arbitrary user-supplied images unbounded.
use anyhow::{Context, Result};
use image::{DynamicImage, ImageDecoder};
use std::path::Path;
/// Bounds for any decode of user-supplied image data. 12000×12000 covers any real phone
/// photo; `max_alloc` hard-caps the decode allocation.
fn decode_limits() -> image::Limits {
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(256 * 1024 * 1024);
limits
}
/// Decode an image from disk with decompression-bomb limits applied and its EXIF
/// orientation baked into the pixels.
///
/// Blocking — call inside `spawn_blocking`.
pub fn decode_oriented(path: &Path) -> Result<DynamicImage> {
let mut reader = image::ImageReader::open(path)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
reader.limits(decode_limits());
// `into_decoder` carries the limits above through, so reading the tag costs nothing in
// safety. A missing or malformed tag is not an error — most images simply have none.
let mut decoder = reader.into_decoder().context("failed to decode image")?;
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let mut img = DynamicImage::from_decoder(decoder).context("failed to decode image")?;
img.apply_orientation(orientation);
Ok(img)
}

View File

@@ -9,10 +9,12 @@
//! users staring at a spinner. Resetting them on startup recovers gracefully. //! users staring at a spinner. Resetting them on startup recovers gracefully.
//! //!
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per //! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
//! request: expired sessions (otherwise the table grows unboundedly), and the //! request: expired sessions (otherwise the table grows unboundedly), the
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't //! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
//! accumulate). //! accumulate), and the originals of uploads whose compression permanently failed
//! (which are deliberately retained for a recovery window, then reclaimed).
use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use sqlx::PgPool; use sqlx::PgPool;
@@ -20,6 +22,21 @@ use sqlx::PgPool;
use crate::services::rate_limiter::RateLimiter; use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore; use crate::services::sse_tickets::SseTicketStore;
/// How long a permanently-failed upload's original is kept on disk before it is
/// reclaimed.
///
/// The compression worker stops deleting originals on failure — a transient error must
/// never destroy the guest's only copy of a photo they can't retake. But the row is
/// soft-deleted and the uploader's quota IS refunded, so without a sweep those bytes are
/// invisible, unowned, and free: a reproducible codec failure lets one guest accumulate
/// orphans at no personal cost, and because `active_uploaders` counts only users with
/// non-deleted uploads, dropping out of that count actually RAISES everyone's per-user
/// ceiling while the disk gets fuller.
///
/// Two weeks is comfortably longer than any single event, so an operator investigating a
/// failed upload still has the file, while the leak stays bounded.
const FAILED_ORIGINAL_RETENTION_DAYS: i64 = 14;
/// Reset rows left in flight by a previous crashed instance. Run once on startup, /// Reset rows left in flight by a previous crashed instance. Run once on startup,
/// before the HTTP server starts taking requests, so users never observe the /// before the HTTP server starts taking requests, so users never observe the
/// half-state. /// half-state.
@@ -87,7 +104,12 @@ pub async fn startup_recovery(pool: &PgPool) {
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned) /// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
/// ///
/// Cadence is 1h — fine for both jobs at our scale. /// Cadence is 1h — fine for both jobs at our scale.
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) { pub fn spawn_periodic_tasks(
pool: PgPool,
rate_limiter: RateLimiter,
sse_tickets: SseTicketStore,
media_path: PathBuf,
) {
tokio::spawn(async move { tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600)); let mut tick = tokio::time::interval(Duration::from_secs(3600));
// Fire the first tick immediately, then hourly. // Fire the first tick immediately, then hourly.
@@ -95,12 +117,76 @@ pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets
loop { loop {
tick.tick().await; tick.tick().await;
cleanup_sessions(&pool).await; cleanup_sessions(&pool).await;
cleanup_failed_originals(&pool, &media_path).await;
rate_limiter.prune(); rate_limiter.prune();
sse_tickets.prune(); sse_tickets.prune();
} }
}); });
} }
/// Reclaim the originals of uploads whose compression permanently failed, once they are
/// past [`FAILED_ORIGINAL_RETENTION_DAYS`].
///
/// Deliberately narrow. It only touches rows that are BOTH `compression_status = 'failed'`
/// AND soft-deleted — i.e. the exact state the compression worker's give-up path leaves
/// behind — so it can never reach a live upload or one whose preview works. `original_path`
/// is cleared in the same pass, which makes the sweep idempotent and stops a later run
/// re-reporting a file that is already gone. The row itself is kept: it is the audit trail
/// for the failure, and it costs a few hundred bytes.
async fn cleanup_failed_originals(pool: &PgPool, media_path: &std::path::Path) {
let rows = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT id, original_path FROM upload
WHERE compression_status = 'failed'
AND deleted_at IS NOT NULL
AND deleted_at < NOW() - ($1 || ' days')::interval
AND original_path <> ''",
)
.bind(FAILED_ORIGINAL_RETENTION_DAYS.to_string())
.fetch_all(pool)
.await;
let rows = match rows {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = ?e, "failed-original sweep query failed");
return;
}
};
if rows.is_empty() {
return;
}
let mut reclaimed = 0usize;
for (id, original_path) in rows {
let absolute = media_path.join(&original_path);
match tokio::fs::remove_file(&absolute).await {
Ok(()) => reclaimed += 1,
// Already gone (manual cleanup, restored backup) — still clear the column so
// the row stops being re-selected every hour.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(error = ?e, %id, path = %absolute.display(),
"could not reclaim failed original; leaving the row for the next sweep");
continue;
}
}
if let Err(e) = sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
.bind(id)
.execute(pool)
.await
{
tracing::warn!(error = ?e, %id, "reclaimed the file but could not clear original_path");
}
}
if reclaimed > 0 {
tracing::info!(
"reclaimed {reclaimed} original(s) from uploads that failed compression more than \
{FAILED_ORIGINAL_RETENTION_DAYS} days ago"
);
}
}
async fn cleanup_sessions(pool: &PgPool) { async fn cleanup_sessions(pool: &PgPool) {
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'") match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
.execute(pool) .execute(pool)

View File

@@ -2,6 +2,7 @@ pub mod compression;
pub mod config; pub mod config;
pub mod disk; pub mod disk;
pub mod export; pub mod export;
pub mod imaging;
pub mod maintenance; pub mod maintenance;
pub mod rate_limiter; pub mod rate_limiter;
pub mod sse_tickets; pub mod sse_tickets;

View File

@@ -0,0 +1,215 @@
//! DB-backed tests for the failed-original sweep (`services/maintenance.rs`).
//!
//! Context: the compression worker deliberately no longer deletes an upload's original when
//! its transcode fails — a transient ENOSPC or a codec panic must never destroy the only
//! copy of a photo a guest cannot retake. But the row is soft-deleted and the uploader's
//! quota IS refunded, so those bytes become invisible, unowned and free. A guest hitting a
//! reproducible codec failure could accumulate orphans at no personal cost, and since
//! `active_uploaders` counts only users with non-deleted uploads, dropping out of that count
//! actually RAISES everyone's per-user ceiling while the disk fills.
//!
//! The sweep reclaims them after a retention window. Its selection predicate is the whole
//! safety argument — it must reach the give-up path's leftovers and nothing else — so that
//! is what these tests pin, following the same "reproduce the SQL verbatim" pattern as
//! `upload_concurrency.rs`.
//!
//! `#[sqlx::test]` gives each test a fresh database with the real migrations applied.
mod common;
use common::*;
use sqlx::PgPool;
use uuid::Uuid;
/// SRC: `services/maintenance.rs::cleanup_failed_originals` — the selection, verbatim.
async fn sweep_selects(pool: &PgPool, retention_days: i64) -> Vec<Uuid> {
sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, original_path FROM upload
WHERE compression_status = 'failed'
AND deleted_at IS NOT NULL
AND deleted_at < NOW() - ($1 || ' days')::interval
AND original_path <> ''",
)
.bind(retention_days.to_string())
.fetch_all(pool)
.await
.expect("sweep query")
.into_iter()
.map(|(id, _)| id)
.collect()
}
#[allow(clippy::too_many_arguments)]
async fn seed_upload(
pool: &PgPool,
event_id: Uuid,
user_id: Uuid,
status: &str,
deleted_days_ago: Option<i64>,
original_path: &str,
) -> Uuid {
let id: Uuid = sqlx::query_scalar(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes,
compression_status, deleted_at)
VALUES ($1, $2, $3, 'image/jpeg', 1000, $4,
CASE WHEN $5::bigint IS NULL THEN NULL
ELSE NOW() - ($5::text || ' days')::interval END)
RETURNING id",
)
.bind(event_id)
.bind(user_id)
.bind(original_path)
.bind(status)
.bind(deleted_days_ago)
.fetch_one(pool)
.await
.expect("seed upload");
id
}
#[sqlx::test]
async fn sweeps_only_long_failed_soft_deleted_uploads(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-event").await;
let user_id = seed_user(&pool, event_id, "Sweeper").await;
// The one and only thing the sweep may touch: the exact state the compression worker's
// give-up path leaves behind, aged past the window.
let target = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(30),
"originals/e/target.jpg",
)
.await;
// Everything below is a near-miss that must survive.
// A healthy live upload — the catastrophic case if the predicate were ever loosened.
let live = seed_upload(
&pool,
event_id,
user_id,
"done",
None,
"originals/e/live.jpg",
)
.await;
// Failed but still inside the retention window: the recovery window is the entire point
// of keeping the file, so reclaiming it early would defeat the fix it protects.
let recent = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(1),
"originals/e/recent.jpg",
)
.await;
// Failed but NOT soft-deleted — not the give-up path; something else set this status.
let failed_live = seed_upload(
&pool,
event_id,
user_id,
"failed",
None,
"originals/e/failed-live.jpg",
)
.await;
// Soft-deleted by the OWNER, compression fine. Its file is already gone; this row must
// never be re-processed.
let owner_deleted = seed_upload(
&pool,
event_id,
user_id,
"done",
Some(30),
"originals/e/owner.jpg",
)
.await;
// Already swept: `original_path` cleared. Re-selecting it every hour would log a
// phantom reclaim forever.
let already_swept = seed_upload(&pool, event_id, user_id, "failed", Some(30), "").await;
let selected = sweep_selects(&pool, 14).await;
assert_eq!(
selected,
vec![target],
"the sweep must select exactly the aged give-up-path leftovers"
);
for (id, what) in [
(live, "a live upload"),
(recent, "a failure still inside the retention window"),
(failed_live, "a failed but not soft-deleted upload"),
(owner_deleted, "an owner-deleted upload"),
(already_swept, "an already-swept row"),
] {
assert!(!selected.contains(&id), "the sweep must not touch {what}");
}
}
#[sqlx::test]
async fn retention_window_is_honoured_at_the_boundary(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-boundary").await;
let user_id = seed_user(&pool, event_id, "Boundary").await;
let inside = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(13),
"originals/e/inside.jpg",
)
.await;
let outside = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(15),
"originals/e/outside.jpg",
)
.await;
let selected = sweep_selects(&pool, 14).await;
assert!(
selected.contains(&outside),
"15 days old must be past a 14-day window"
);
assert!(
!selected.contains(&inside),
"13 days old must still be retained"
);
}
#[sqlx::test]
async fn clearing_original_path_makes_the_sweep_idempotent(pool: PgPool) {
// The sweep clears `original_path` after reclaiming the file. Without that, a row whose
// file is already gone is re-selected on every hourly tick forever.
let event_id = seed_event(&pool, "sweep-idempotent").await;
let user_id = seed_user(&pool, event_id, "Idem").await;
let id = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(30),
"originals/e/once.jpg",
)
.await;
assert_eq!(sweep_selects(&pool, 14).await, vec![id]);
sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("clear path");
assert!(
sweep_selects(&pool, 14).await.is_empty(),
"a swept row must not come back"
);
}

View File

@@ -9,12 +9,12 @@ acting as the showcase display.
**Validate the shipping config.** We run at the real production defaults — **Validate the shipping config.** We run at the real production defaults —
compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB
pool (default **10**), quotas **on** — and answer: *does the app survive the pool (default **10**), quotas **on** — and answer: _does the app survive the
event, and how far behind real-time does the diashow fall?* event, and how far behind real-time does the diashow fall?_
The headline metric is **pipeline latency**: time from an upload succeeding to The headline metric is **pipeline latency**: time from an upload succeeding to
its preview being ready (`upload-processed` SSE event) — i.e. *how long until the its preview being ready (`upload-processed` SSE event) — i.e. _how long until the
photo appears on the diashow*. A backlog that builds is fine; a backlog that photo appears on the diashow_. A backlog that builds is fine; a backlog that
**never drains** is a fail for a live event. **never drains** is a fail for a live event.
## Methodology: what we change vs. shipping ## Methodology: what we change vs. shipping
@@ -86,13 +86,13 @@ compression backlog to drain** before reporting.
## What the flags mean ## What the flags mean
| Flag | Meaning | | Flag | Meaning |
|------|---------| | ------------------------- | ---------------------------------------------------------------------------- |
| `✗ 5xx` | server errored under load — hard fail | | `✗ 5xx` | server errored under load — hard fail |
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size | | `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size |
| `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up | | `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up |
| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak | | `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak |
| `⚠ SSE resyncs` | live consumers lagged the broadcast channel | | `⚠ SSE resyncs` | live consumers lagged the broadcast channel |
## Knobs ## Knobs
@@ -101,7 +101,7 @@ All via env (see header of `driver.mjs`): `LT_GUESTS`, `LT_IMAGES`,
`LT_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`, `LT_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`,
`LT_APP_CONTAINER`, `LT_DB_CONTAINER`. `LT_APP_CONTAINER`, `LT_DB_CONTAINER`.
To later answer *"what config should I deploy?"*, re-run with a rebuilt stack To later answer _"what config should I deploy?"_, re-run with a rebuilt stack
that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in
`docker-compose.test.yml`) and compare the pipeline-latency / drain numbers. `docker-compose.test.yml`) and compare the pipeline-latency / drain numbers.

View File

@@ -10,17 +10,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const j = (r) => r.json(); const j = (r) => r.json();
const adminLogin = () => const adminLogin = () =>
fetch(`${BASE}/api/v1/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }).then(j).then((b) => b.jwt); fetch(`${BASE}/api/v1/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'admin-test-pw' }),
})
.then(j)
.then((b) => b.jwt);
const join = (name) => const join = (name) =>
fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }).then(j); fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name }),
}).then(j);
async function truncate(admin) { async function truncate(admin) {
await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: { Authorization: `Bearer ${admin}` } }); await fetch(`${BASE}/api/v1/admin/__truncate`, {
method: 'POST',
headers: { Authorization: `Bearer ${admin}` },
});
} }
async function upload(jwt) { async function upload(jwt) {
const form = new FormData(); const form = new FormData();
form.append('file', new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], { type: 'image/jpeg' }), 'live.jpg'); form.append(
'file',
new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], {
type: 'image/jpeg',
}),
'live.jpg'
);
form.append('caption', 'LIVE-PROBE'); form.append('caption', 'LIVE-PROBE');
const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form }); const r = await fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
body: form,
});
return (await r.json()).id; return (await r.json()).id;
} }
@@ -35,7 +58,9 @@ const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage(); const page = await ctx.newPage();
const streamReqs = []; const streamReqs = [];
page.on('request', (req) => { if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, '')); }); page.on('request', (req) => {
if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, ''));
});
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
await page.evaluate((g) => { await page.evaluate((g) => {
@@ -53,7 +78,9 @@ const emptyBefore = (await page.locator('text=Noch keine Beiträge').count()) >
const streamOpened = streamReqs.length > 0; const streamOpened = streamReqs.length > 0;
console.log(`\n1) direct /diashow on empty event:`); console.log(`\n1) direct /diashow on empty event:`);
console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`); console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`);
console.log(` SSE stream opened: ${streamOpened} ${streamOpened ? '('+streamReqs.join(', ')+')' : ''} (expected: true — this is the fix)`); console.log(
` SSE stream opened: ${streamOpened} ${streamOpened ? '(' + streamReqs.join(', ') + ')' : ''} (expected: true — this is the fix)`
);
// Now a guest uploads a photo while the display is open. // Now a guest uploads a photo while the display is open.
console.log(`\n2) guest uploads a photo (display already open)…`); console.log(`\n2) guest uploads a photo (display already open)…`);
@@ -65,7 +92,11 @@ for (let i = 0; i < 15; i++) {
await sleep(1000); await sleep(1000);
const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0; const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0;
const imgs = await page.locator('img').count(); const imgs = await page.locator('img').count();
if (!stillEmpty && imgs > 0) { appeared = true; console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`); break; } if (!stillEmpty && imgs > 0) {
appeared = true;
console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`);
break;
}
} }
if (!appeared) console.log(` ✗ photo did NOT appear within 15s`); if (!appeared) console.log(` ✗ photo did NOT appear within 15s`);

View File

@@ -132,9 +132,19 @@ const truncate = (adminJwt) =>
api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] }); api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] });
const CAPTIONS = [ const CAPTIONS = [
'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte 🍰', 'Was für ein magischer Tag 💍',
'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸', 'Der erste Tanz 🕺',
'Sonnenuntergang 🌅', 'Tanzfläche brennt 🔥', null, null, null, 'Prost! 🥂',
'Die Torte 🍰',
'Feuerwerk 🎆',
'Beste Freunde 💕',
'Was für eine Stimmung! 🎉',
'Details 🌸',
'Sonnenuntergang 🌅',
'Tanzfläche brennt 🔥',
null,
null,
null,
]; ];
const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert']; const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert'];
@@ -238,9 +248,12 @@ async function sampleResources() {
const out = { ts: now() }; const out = { ts: now() };
try { try {
const { stdout } = await execFileAsync('docker', [ const { stdout } = await execFileAsync('docker', [
'stats', '--no-stream', '--format', 'stats',
'--no-stream',
'--format',
'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}', '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}',
cfg.appContainer, cfg.dbContainer, cfg.appContainer,
cfg.dbContainer,
]); ]);
out.docker = stdout.trim(); out.docker = stdout.trim();
} catch (e) { } catch (e) {
@@ -259,8 +272,15 @@ async function sampleResources() {
function psql(sql) { function psql(sql) {
return execFileAsync('docker', [ return execFileAsync('docker', [
'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test', 'exec',
'-tAc', sql, cfg.dbContainer,
'psql',
'-U',
'eventsnap_test',
'-d',
'eventsnap_test',
'-tAc',
sql,
]); ]);
} }
@@ -292,8 +312,13 @@ function summarize(nums) {
const s = [...nums].sort((a, b) => a - b); const s = [...nums].sort((a, b) => a - b);
const sum = s.reduce((a, b) => a + b, 0); const sum = s.reduce((a, b) => a + b, 0);
return { return {
n: s.length, min: s[0], max: s[s.length - 1], mean: Math.round(sum / s.length), n: s.length,
p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99), min: s[0],
max: s[s.length - 1],
mean: Math.round(sum / s.length),
p50: pct(s, 50),
p95: pct(s, 95),
p99: pct(s, 99),
}; };
} }
@@ -490,7 +515,9 @@ async function main() {
await sleep(cfg.windowSec * 1000 + 500); await sleep(cfg.windowSec * 1000 + 500);
await Promise.all(burstPromises); await Promise.all(burstPromises);
clearInterval(ticker); clearInterval(ticker);
console.log(`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`); console.log(
`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`
);
// Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids) // Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids)
console.log('[drain] waiting for compression backlog to clear…'); console.log('[drain] waiting for compression backlog to clear…');
@@ -589,21 +616,28 @@ async function main() {
console.log('\n' + '━'.repeat(72)); console.log('\n' + '━'.repeat(72));
console.log('RESULTS'); console.log('RESULTS');
console.log('━'.repeat(72)); console.log('━'.repeat(72));
console.log(`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`); console.log(
`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`
);
console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`); console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`);
console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`); console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`);
console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`); console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`);
console.log(`final db compression status: ${JSON.stringify(finalCounts)}`); console.log(`final db compression status: ${JSON.stringify(finalCounts)}`);
console.log(`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`); console.log(
`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`
);
console.log(`\nfull report → ${outPath}`); console.log(`\nfull report → ${outPath}`);
// Heuristic pass/fail flags (validate shipping config) // Heuristic pass/fail flags (validate shipping config)
const flags = []; const flags = [];
const err5xx = Object.entries(byStatus).filter(([s]) => +s >= 500).reduce((a, [, n]) => a + n, 0); const err5xx = Object.entries(byStatus)
.filter(([s]) => +s >= 500)
.reduce((a, [, n]) => a + n, 0);
if (err5xx > 0) flags.push(`${err5xx} server errors (5xx)`); if (err5xx > 0) flags.push(`${err5xx} server errors (5xx)`);
if (byStatus['507']) flags.push(`${byStatus['507']} quota rejections (507)`); if (byStatus['507']) flags.push(`${byStatus['507']} quota rejections (507)`);
if (byStatus['413']) flags.push(`${byStatus['413']} too-large (413)`); if (byStatus['413']) flags.push(`${byStatus['413']} too-large (413)`);
if (byStatus['429']) flags.push(`${byStatus['429']} rate-limited (429) — unexpected with limits off`); if (byStatus['429'])
flags.push(`${byStatus['429']} rate-limited (429) — unexpected with limits off`);
if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`); if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`);
if (totalResyncs > sseClients.length) flags.push(`${totalResyncs} SSE resyncs (consumer lag)`); if (totalResyncs > sseClients.length) flags.push(`${totalResyncs} SSE resyncs (consumer lag)`);
if (pipelineLatency && pipelineLatency.p95 > 60000) if (pipelineLatency && pipelineLatency.p95 > 60000)

View File

@@ -4,24 +4,44 @@ import { chromium, devices } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
import { readFileSync, mkdirSync } from 'node:fs'; import { readFileSync, mkdirSync } from 'node:fs';
const BASE = 'http://localhost:3101'; const BASE = 'http://localhost:3101';
const PHOTOS = '/tmp/eventsnap-shots/photos'; const PHOTOS = '/tmp/eventsnap-shots/photos';
const OUT = '/tmp/eventsnap-shots'; const OUT = '/tmp/eventsnap-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
const api = (path, opts = {}) => const api = (path, opts = {}) =>
fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({ status: r.status, body: await r.text().then((t) => { try { return JSON.parse(t); } catch { return t; } }) })); fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({
status: r.status,
body: await r.text().then((t) => {
try {
return JSON.parse(t);
} catch {
return t;
}
}),
}));
const authHeaders = (jwt, json = true) => ({ Authorization: `Bearer ${jwt}`, ...(json ? { 'Content-Type': 'application/json' } : {}) }); const authHeaders = (jwt, json = true) => ({
Authorization: `Bearer ${jwt}`,
...(json ? { 'Content-Type': 'application/json' } : {}),
});
async function adminLogin() { async function adminLogin() {
const r = await api('/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }); const r = await api('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'admin-test-pw' }),
});
return r.body.jwt; return r.body.jwt;
} }
async function joinGuest(name) { async function joinGuest(name) {
const r = await api('/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }); const r = await api('/join', {
if (r.status !== 201) throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body)); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name }),
});
if (r.status !== 201)
throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body));
return r.body; // {jwt,pin,user_id} return r.body; // {jwt,pin,user_id}
} }
async function upload(jwt, file, caption, hashtags) { async function upload(jwt, file, caption, hashtags) {
@@ -29,7 +49,11 @@ async function upload(jwt, file, caption, hashtags) {
form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg'); form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg');
if (caption) form.append('caption', caption); if (caption) form.append('caption', caption);
if (hashtags) form.append('hashtags', hashtags); if (hashtags) form.append('hashtags', hashtags);
const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: authHeaders(jwt, false), body: form }); const r = await fetch(`${BASE}/api/v1/upload`, {
method: 'POST',
headers: authHeaders(jwt, false),
body: form,
});
if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text())); if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text()));
return (await r.json()).id; return (await r.json()).id;
} }
@@ -37,16 +61,34 @@ async function like(jwt, id) {
await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) }); await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) });
} }
async function comment(jwt, id, body) { async function comment(jwt, id, body) {
await fetch(`${BASE}/api/v1/upload/${id}/comments`, { method: 'POST', headers: authHeaders(jwt), body: JSON.stringify({ body }) }); await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
method: 'POST',
headers: authHeaders(jwt),
body: JSON.stringify({ body }),
});
} }
const pg = () => new Client({ host: 'localhost', port: 55432, user: 'eventsnap_test', password: 'eventsnap_test', database: 'eventsnap_test' }); const pg = () =>
new Client({
host: 'localhost',
port: 55432,
user: 'eventsnap_test',
password: 'eventsnap_test',
database: 'eventsnap_test',
});
async function truncate(adminJwt) { async function truncate(adminJwt) {
await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: authHeaders(adminJwt) }); await fetch(`${BASE}/api/v1/admin/__truncate`, {
method: 'POST',
headers: authHeaders(adminJwt),
});
} }
async function patchConfig(adminJwt, patch) { async function patchConfig(adminJwt, patch) {
await fetch(`${BASE}/api/v1/admin/config`, { method: 'PATCH', headers: authHeaders(adminJwt), body: JSON.stringify(patch) }); await fetch(`${BASE}/api/v1/admin/config`, {
method: 'PATCH',
headers: authHeaders(adminJwt),
body: JSON.stringify(patch),
});
} }
// ---- SEED ---- // ---- SEED ----
@@ -54,9 +96,27 @@ console.log('[seed] admin login + reset');
let admin = await adminLogin(); let admin = await adminLogin();
await truncate(admin); await truncate(admin);
admin = await adminLogin(); admin = await adminLogin();
await patchConfig(admin, { rate_limits_enabled: 'false', upload_rate_enabled: 'false', feed_rate_enabled: 'false', export_rate_enabled: 'false', join_rate_enabled: 'false', quota_enabled: 'false', storage_quota_enabled: 'false', upload_count_quota_enabled: 'false' }); await patchConfig(admin, {
rate_limits_enabled: 'false',
upload_rate_enabled: 'false',
feed_rate_enabled: 'false',
export_rate_enabled: 'false',
join_rate_enabled: 'false',
quota_enabled: 'false',
storage_quota_enabled: 'false',
upload_count_quota_enabled: 'false',
});
const guests = ['Anna Bauer', 'Lukas Weber', 'Mia Schulz', 'Jonas Fischer', 'Emma Wagner', 'Ben Hoffmann', 'Sophie Klein', 'Paul Richter']; const guests = [
'Anna Bauer',
'Lukas Weber',
'Mia Schulz',
'Jonas Fischer',
'Emma Wagner',
'Ben Hoffmann',
'Sophie Klein',
'Paul Richter',
];
const accounts = {}; const accounts = {};
for (const g of guests) accounts[g] = await joinGuest(g); for (const g of guests) accounts[g] = await joinGuest(g);
console.log('[seed] joined', guests.length, 'guests'); console.log('[seed] joined', guests.length, 'guests');
@@ -94,13 +154,23 @@ await comment(accounts['Ben Hoffmann'].jwt, ids[1], 'Was ein Abend!');
console.log('[seed] likes + comments done'); console.log('[seed] likes + comments done');
// Make one guest a host so /host renders populated // Make one guest a host so /host renders populated
await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, { method: 'PATCH', headers: authHeaders(admin), body: JSON.stringify({ role: 'host' }) }); await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, {
method: 'PATCH',
headers: authHeaders(admin),
body: JSON.stringify({ role: 'host' }),
});
// Wait for compression to finish so previews render // Wait for compression to finish so previews render
const c = pg(); await c.connect(); const c = pg();
await c.connect();
for (let t = 0; t < 40; t++) { for (let t = 0; t < 40; t++) {
const r = await c.query(`SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`); const r = await c.query(
if (r.rows[0].n === 0) { console.log('[seed] compression done'); break; } `SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`
);
if (r.rows[0].n === 0) {
console.log('[seed] compression done');
break;
}
await new Promise((res) => setTimeout(res, 500)); await new Promise((res) => setTimeout(res, 500));
} }
await c.end(); await c.end();
@@ -113,17 +183,20 @@ const shot = async (label, theme, who, route, prep) => {
const page = await ctx.newPage(); const page = await ctx.newPage();
// Seed localStorage on the origin // Seed localStorage on the origin
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ jwt, pin, uid, name, theme, mode }) => { await page.evaluate(
localStorage.setItem('eventsnap_theme', theme); ({ jwt, pin, uid, name, theme, mode }) => {
localStorage.setItem('eventsnap_data_mode', mode); localStorage.setItem('eventsnap_theme', theme);
if (jwt) { localStorage.setItem('eventsnap_data_mode', mode);
localStorage.setItem('eventsnap_jwt', jwt); if (jwt) {
localStorage.setItem('eventsnap_pin', pin); localStorage.setItem('eventsnap_jwt', jwt);
localStorage.setItem('eventsnap_user_id', uid); localStorage.setItem('eventsnap_pin', pin);
localStorage.setItem('eventsnap_display_name', name); localStorage.setItem('eventsnap_user_id', uid);
} localStorage.setItem('eventsnap_display_name', name);
localStorage.setItem('eventsnap_guide_seen', '1'); }
}, { jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }); localStorage.setItem('eventsnap_guide_seen', '1');
},
{ jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }
);
await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
if (prep) await prep(page); if (prep) await prep(page);
@@ -141,10 +214,17 @@ for (const theme of ['light', 'dark']) {
await shot('01-join', theme, null, '/join'); await shot('01-join', theme, null, '/join');
await shot('02-feed-list', theme, hostWho, '/feed'); await shot('02-feed-list', theme, hostWho, '/feed');
await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => { await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => {
await p.getByLabel('Rasteransicht').click().catch(() => {}); await p
.getByLabel('Rasteransicht')
.click()
.catch(() => {});
}); });
await shot('04-lightbox', theme, hostWho, '/feed', async (p) => { await shot('04-lightbox', theme, hostWho, '/feed', async (p) => {
await p.locator('img').first().click().catch(() => {}); await p
.locator('img')
.first()
.click()
.catch(() => {});
await p.waitForTimeout(500); await p.waitForTimeout(500);
}); });
await shot('05-account', theme, hostWho, '/account'); await shot('05-account', theme, hostWho, '/account');

View File

@@ -142,3 +142,65 @@ test.describe('Rate limits — guests behind a shared NAT', () => {
expect((await mintAndFetch(host.jwt)).status).not.toBe(429); expect((await mintAndFetch(host.jwt)).status).not.toBe(429);
}); });
}); });
test.describe('Rate limits — /recover name cycling', () => {
test('cycling names from one IP hits the ceiling, while one name is still throttled', async ({
api,
adminToken,
}) => {
// /recover is keyed `recover:{ip}:{name}` — right for its job (stopping someone who
// knows a display name from burning the victim's 3-strike PIN counter), but the name is
// ATTACKER-CHOSEN, so cycling names minted a fresh bucket every time. Behind it sits a
// cost-12 bcrypt verify, including an unconditional throwaway one for unknown names, so
// a name generator was the cheapest way to make the server hash forever.
//
// Squeeze the ceiling so the flood is reproducible without firing 30+ requests.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
recover_rate_enabled: 'true',
recover_ip_rate_per_min: '5',
});
const attempt = (name: string) =>
fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name, pin: '0000' }),
});
// Every name is distinct, so the per-name bucket can never fire — only the ceiling can.
const codes: number[] = [];
for (let i = 0; i < 12; i++) codes.push((await attempt(`Unbekannt${i}_${Date.now()}`)).status);
expect(
codes.filter((c) => c === 429).length,
'name cycling must be capped by the per-IP ceiling'
).toBeGreaterThan(0);
const throttled = await attempt(`Unbekannt99_${Date.now()}`);
expect(throttled.status).toBe(429);
expect(Number(throttled.headers.get('retry-after'))).toBeGreaterThan(0);
});
test('the per-name bucket still protects a real account', async ({ api, adminToken, guest }) => {
// The ceiling must not have REPLACED the anti-guessing control. With a generous ceiling,
// repeated wrong PINs against ONE name must still be shut down by the per-name bucket.
const victim = await guest('PinVictim');
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
recover_rate_enabled: 'true',
recover_ip_rate_per_min: '1000',
});
const attempt = () =>
fetch(`${BASE}/api/v1/recover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: victim.displayName, pin: '9999' }),
});
const codes: number[] = [];
for (let i = 0; i < 7; i++) codes.push((await attempt()).status);
expect(codes.at(-1), 'guessing one name must still be throttled').toBe(429);
});
});

View File

@@ -0,0 +1,172 @@
/**
* Regression guard — videos must actually play.
*
* Two independent defects made every video unplayable, and nothing in the suite covered
* either one (no test anywhere played media or asserted a `<video>` src):
*
* 1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
* Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
* the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
* served as image/jpeg with nosniff so the browser can't even sniff its way out.
* Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
*
* 2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
* an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
* `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
* app's primary platform even in `original` data mode.
*
* Fixing either alone still leaves video broken, so both are asserted here.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');
async function seedVideo(jwt: string): Promise<string> {
const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
filename: 'clip.mp4',
contentType: 'video/mp4',
caption: 'ein Video',
});
if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
return ((await res.json()) as { id: string }).id;
}
test.describe('Video — the lightbox plays it', () => {
test('the <video> src is the original, not the thumbnail JPEG', async ({
page,
guest,
signIn,
}) => {
const g = await guest('VideoWatcher');
const id = await seedVideo(g.jwt);
await signIn(page, g);
await page.goto('/feed');
const card = page.locator('article').first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const video = page.locator('video');
await expect(video).toBeVisible({ timeout: 10_000 });
// The bug in one assertion: this was `/thumbnail` in the default data mode.
await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);
// The poster SHOULD still be the thumbnail — that's what it's for.
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
// 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) => {
el.preload = 'metadata';
el.load();
await new Promise<void>((resolve) => {
if (el.readyState > 0) return resolve();
el.addEventListener('loadedmetadata', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
setTimeout(resolve, 15_000);
});
return { ready: el.readyState, err: el.error?.message ?? null };
});
expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
});
test('the video body is not downloaded before the user presses play', async ({
page,
guest,
signIn,
}) => {
// saver mode exists to protect a guest's mobile data, and there is no smaller video
// derivative to fall back to — so opening the lightbox must not pull the file down.
//
// Asserting "no request at all" would be wrong: WebKit opens a connection for a
// preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
// response status 0, nothing transferred), whereas Chromium issues nothing. The
// portable guarantee — and the one that actually protects the data plan — is that no
// response carrying the body ever completes. Dropping preload="none" fails this.
const g = await guest('VideoThrifty');
const id = await seedVideo(g.jwt);
await signIn(page, g);
const delivered: string[] = [];
page.on('response', (r) => {
if (!r.url().includes(`/upload/${id}/original`)) return;
// status 0 = aborted before any bytes landed.
if (r.status() === 200 || r.status() === 206) {
delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
}
});
await page.goto('/feed');
const card = page.locator('article').first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(2000);
expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
});
});
test.describe('Media — HTTP Range', () => {
test('a range request is answered 206 with the right slice', async ({ guest }) => {
const g = await guest('RangeReader');
const id = await seedVideo(g.jwt);
const url = `${BASE}/api/v1/upload/${id}/original`;
const full = await fetch(url);
expect(full.status).toBe(200);
expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
const total = Number(full.headers.get('content-length'));
expect(total).toBeGreaterThan(0);
// The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
expect(Number(probe.headers.get('content-length'))).toBe(2);
expect((await probe.arrayBuffer()).byteLength).toBe(2);
// A mid-file seek must return the matching slice, not the whole body.
const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
expect(mid.status).toBe(206);
expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
const midBytes = Buffer.from(await mid.arrayBuffer());
expect(midBytes.byteLength).toBe(10);
expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));
// Open-ended range runs to EOF.
const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
expect(tail.status).toBe(206);
expect(Number(tail.headers.get('content-length'))).toBe(5);
// Past EOF must be 416 — answering 200 makes a player re-request forever.
const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
expect(bad.status).toBe(416);
expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
});
test('image derivatives are range-capable too', async ({ guest, db }) => {
// Same helper serves all four media routes, so the guarantee is uniform.
const g = await guest('RangeImages');
const res = await uploadRaw(
g.jwt,
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
{ filename: 'r.jpg', contentType: 'image/jpeg' }
);
const { id } = (await res.json()) as { id: string };
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
headers: { Range: 'bytes=0-9' },
});
expect(preview.status).toBe(206);
expect(Number(preview.headers.get('content-length'))).toBe(10);
});
});

View File

@@ -0,0 +1,102 @@
/**
* Regression guard — the role must follow the identity, not the tab.
*
* The `role` store is a module-level singleton seeded ONCE at import. `goto()` is a
* client-side navigation, so leaving and re-joining in the same tab re-imports nothing and
* re-runs no `onMount` — the previous user's role simply stayed. A host who left and a
* guest who then joined kept `isStaff === true` and were offered "🚫 Beitrag entfernen" on
* other people's photos. The backend 403s the delete, so it was a false affordance rather
* than a privilege escalation, but `/feed` never fetched `/me/context`, so it never
* self-corrected either — it survived until a hard reload.
*
* The mirror case matters just as much and is easier to forget: a guest who recovers into a
* host account must GAIN the affordance without a reload.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { JoinPage } from '../../page-objects';
const REMOVE = /beitrag entfernen/i;
test.describe('Role — follows the identity across a same-tab switch', () => {
test('a guest joining after a host leaves does NOT inherit host actions', async ({
page,
host,
guest,
signIn,
}) => {
// Someone else's photo — the only kind the removal action is offered on.
const author = await guest('RoleAuthor');
await seedUpload(author.jwt);
// 1. Host is signed in and DOES see the moderation action. Establishing this first is
// what makes the negative assertion below meaningful.
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
await expect(page.getByRole('button', { name: REMOVE })).toBeVisible();
await page.keyboard.press('Escape');
// 2. Host leaves, in-app — no reload. This is the path "Event verlassen" takes.
await page.goto('/account');
await page.getByRole('button', { name: /event verlassen/i }).click();
const confirm = page.getByTestId('confirm-sheet-confirm');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
await page.waitForURL('**/join', { timeout: 10_000 });
// 3. A brand-new guest joins in the same tab — the real flow, PIN modal and all.
const join = new JoinPage(page);
await join.joinAs(`Nachzuegler${Date.now() % 100000}`);
await join.continueToFeed();
await expect(page).toHaveURL(/\/feed$/, { timeout: 15_000 });
// 4. They must NOT be offered moderation on someone else's photo.
const card2 = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card2).toBeVisible({ timeout: 15_000 });
await card2.getByRole('button', { name: 'Mehr Aktionen' }).click();
await expect(
page.getByRole('button', { name: REMOVE }),
'a fresh guest must not inherit the previous users role'
).toHaveCount(0);
});
test('a guest who recovers into a host account GAINS host actions without a reload', async ({
page,
api,
adminToken,
guest,
signIn,
}) => {
// The mirror. If the fix only cleared the role it would pass the test above and still
// leave a real host with no moderation until they reloaded.
const author = await guest('RoleAuthor2');
await seedUpload(author.jwt);
const futureHost = await guest('WillBeHost');
await signIn(page, futureHost);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
await expect(page.getByRole('button', { name: REMOVE })).toHaveCount(0);
await page.keyboard.press('Escape');
// Promote them server-side. Their resident JWT still claims `role: guest`.
await api.setRole(adminToken, futureHost.userId, 'host');
const claim = JSON.parse(Buffer.from(futureHost.jwt.split('.')[1], 'base64').toString());
expect(claim.role, 'the token must still be stale for this to prove anything').toBe('guest');
// A plain in-app navigation back to the feed must pick up the live role.
await page.goto('/account');
await page.goto('/feed');
const card2 = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card2).toBeVisible({ timeout: 15_000 });
await card2.getByRole('button', { name: 'Mehr Aktionen' }).click();
await expect(
page.getByRole('button', { name: REMOVE }),
'the live role from /me/context must reach the feed'
).toBeVisible({ timeout: 10_000 });
});
});

View File

@@ -0,0 +1,117 @@
/**
* Regression guard — the keepsake must not be sideways.
*
* Round 1 taught the compression worker to apply EXIF orientation, which fixed the live app
* (feed preview + diashow display). The export worker was missed: it does NOT reuse those
* derivatives — it re-decodes the originals itself with `image::open`, which ignores the
* orientation tag — and then re-encodes to JPEG, which drops the tag, so the viewer has no
* way to recover it.
*
* The resulting damage was oddly shaped, which is what made it read as a viewer bug:
* - Gallery.zip originals → correct (byte-copied, EXIF intact)
* - Memories viewer grid thumbnails → ALWAYS sideways
* - Memories viewer full image >5 MB → sideways (re-encoded at 2000px)
* - Memories viewer full image ≤5 MB → correct (streamed byte-for-byte)
*
* So clicking a small photo silently "fixed" it and a large one didn't. This pins the
* thumbnail, which is the path every photo takes.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"), so anything
// that honours the tag emits a PORTRAIT derivative.
const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
/** Pixel dimensions from a JPEG's SOF marker — avoids an image dep for one assertion. */
function jpegSize(buf: Buffer): { width: number; height: number } {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xff) {
i++;
continue;
}
const marker = buf[i + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
}
i += 2 + buf.readUInt16BE(i + 2);
}
throw new Error('no SOF marker found — not a JPEG?');
}
test.describe('Export — EXIF orientation in the keepsake', () => {
test('the Memories viewer thumbnail of a rotated photo is upright', async ({ host, db }) => {
test.setTimeout(90_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
const src = readFileSync(EXIF_FIXTURE);
// Sanity: the SOURCE really is stored landscape, or this test proves nothing.
const srcSize = jpegSize(src);
expect(srcSize.width).toBeGreaterThan(srcSize.height);
const up = await uploadRaw(host.jwt, src, {
filename: 'hochkant.jpg',
contentType: 'image/jpeg',
caption: 'hochkant',
});
expect(up.status).toBe(201);
const { id } = (await up.json()) as { id: string };
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: bearer,
});
expect(rel.status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).html?.status;
},
{ timeout: 60_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-exif-'));
try {
const zipPath = join(dir, 'Memories.zip');
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
.split('\n')
.filter(Boolean);
const thumbEntry = entries.find((e) => e.includes(`${id}_thumb`));
expect(thumbEntry, `no thumbnail for ${id} in Memories.zip`).toBeTruthy();
// `-p` streams the entry to stdout. Extracting to disk instead fails with EACCES:
// the archive preserves the container's file mode, which the test user can't read.
const thumb = execFileSync('unzip', ['-p', zipPath, thumbEntry!], {
maxBuffer: 64 * 1024 * 1024,
});
const { width, height } = jpegSize(thumb);
expect(
height,
`the keepsake grid thumbnail is ${width}x${height} — EXIF orientation was not applied`
).toBeGreaterThan(width);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -86,6 +86,7 @@ export function setAuth(
localStorage.setItem(USER_ID_KEY, userId); localStorage.setItem(USER_ID_KEY, userId);
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName); if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true); isAuthenticated.set(true);
fireSetAuthHooks();
} }
/** /**
@@ -106,6 +107,7 @@ export function setAdminAuth(jwt: string, userId: string, displayName?: string):
sessionStorage.setItem(USER_ID_KEY, userId); sessionStorage.setItem(USER_ID_KEY, userId);
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName); if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true); isAuthenticated.set(true);
fireSetAuthHooks();
} }
// Hook registry: cross-cutting stores (export-status, etc.) register a callback // Hook registry: cross-cutting stores (export-status, etc.) register a callback
@@ -118,6 +120,26 @@ export function onClearAuth(fn: () => void): void {
clearAuthHooks.push(fn); clearAuthHooks.push(fn);
} }
// The mirror of `onClearAuth`, for stores that must be RE-SEEDED when a new identity
// arrives rather than merely cleared. Without it, anything derived from the token
// survives a logout→login in the same tab: `goto()` is a client-side navigation, so no
// module is re-imported and no `onMount` re-runs, and the previous user's value simply
// stays. Fires after the new token is resident, so hooks can read it.
const setAuthHooks: Array<() => void> = [];
export function onSetAuth(fn: () => void): void {
setAuthHooks.push(fn);
}
function fireSetAuthHooks(): void {
for (const fn of setAuthHooks) {
try {
fn();
} catch {
/* hook failure is non-fatal */
}
}
}
export function clearAuth(): void { export function clearAuth(): void {
if (!browser) return; if (!browser) return;
// Clear from BOTH stores — a guest token lives in localStorage, an admin token in // Clear from BOTH stores — a guest token lives in localStorage, an admin token in

View File

@@ -41,7 +41,20 @@
let heartBurst = $state(false); let heartBurst = $state(false);
let burstTimer: ReturnType<typeof setTimeout> | null = null; let burstTimer: ReturnType<typeof setTimeout> | null = null;
const mediaSrc = $derived(pickMediaUrl($dataMode, upload)); // Videos always play the ORIGINAL. `pickMediaUrl` is mime-agnostic and compression only
// ever produces a *thumbnail* for a video (one ffmpeg frame), so in the default saver
// mode it hands back `/thumbnail` — a JPEG, served as image/jpeg with nosniff. Feeding
// that to <video> is why every video failed with DEMUXER_ERROR_COULD_NOT_OPEN. There is
// no smaller video derivative to offer, so `preload="none"` keeps saver-mode users on
// cellular from fetching anything until they actually press play; the poster is the
// thumbnail, which is what they saw in the feed anyway.
// Fixed here rather than in `pickMediaUrl` because FeedListCard shares that helper and
// legitimately wants the thumbnail for its <img>. Same rule as the diashow.
const mediaSrc = $derived(
isVideo(upload.mime_type)
? `/api/v1/upload/${upload.id}/original`
: pickMediaUrl($dataMode, upload)
);
function triggerHeartBurst() { function triggerHeartBurst() {
heartBurst = true; heartBurst = true;
@@ -177,6 +190,8 @@
<video <video
src={mediaSrc} src={mediaSrc}
controls controls
preload="none"
playsinline
class="max-h-[60vh] w-full object-contain" class="max-h-[60vh] w-full object-contain"
poster={upload.thumbnail_url ?? undefined} poster={upload.thumbnail_url ?? undefined}
></video> ></video>

View File

@@ -1,5 +1,5 @@
import { derived, writable } from 'svelte/store'; import { derived, writable } from 'svelte/store';
import { getRole } from './auth'; import { getRole, onClearAuth, onSetAuth } from './auth';
export type Role = 'guest' | 'host' | 'admin'; export type Role = 'guest' | 'host' | 'admin';
@@ -32,3 +32,14 @@ export function setRole(next: Role | null): void {
export function syncRoleFromToken(): void { export function syncRoleFromToken(): void {
role.set(getRole()); role.set(getRole());
} }
// Bind the store to the identity lifecycle. The store is a module-level singleton seeded
// ONCE at import, and `goto()` navigations re-import nothing — so without these hooks the
// previous user's role survives a logout→login in the same tab. A host who left and a guest
// who then joined kept `isStaff === true` and were offered "Beitrag entfernen" on other
// people's photos (the backend 403s it, so it was a false affordance rather than an
// escalation — but the feed never fetches /me/context, so it never self-corrected either).
// The mirror case is just as wrong: a guest recovering into a host account got no host
// affordances at all.
onClearAuth(() => role.set(null));
onSetAuth(syncRoleFromToken);

View File

@@ -11,12 +11,14 @@ export const uploadSheetOpen = writable(false);
// states on purpose: counting only pending/uploading meant a rejected upload decremented // states on purpose: counting only pending/uploading meant a rejected upload decremented
// the badge exactly as if it had succeeded, so the failure was indistinguishable from a // the badge exactly as if it had succeeded, so the failure was indistinguishable from a
// completed upload. 'blocked' and 'error' stay counted until the user clears or retries them. // completed upload. 'blocked' and 'error' stay counted until the user clears or retries them.
export const uploadBadgeCount = derived(queueItems, ($items) => export const uploadBadgeCount = derived(
$items.filter( queueItems,
(i) => ($items) =>
i.status === 'pending' || $items.filter(
i.status === 'uploading' || (i) =>
i.status === 'error' || i.status === 'pending' ||
i.status === 'blocked' i.status === 'uploading' ||
).length i.status === 'error' ||
i.status === 'blocked'
).length
); );

View File

@@ -18,6 +18,7 @@
import { pullToRefresh } from '$lib/actions/pull-to-refresh'; import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics'; import { vibrate } from '$lib/haptics';
import { filterUploads } from '$lib/feed-filter'; import { filterUploads } from '$lib/feed-filter';
import { refreshEventState } from '$lib/event-state-store';
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types'; import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
let uploads = $state<FeedUpload[]>([]); let uploads = $state<FeedUpload[]>([]);
@@ -233,6 +234,15 @@
} }
} }
// Pull the authoritative role/event state. The root layout does this on a full page
// load, but arriving here from /join or /recover is a client-side navigation, so its
// onMount never re-runs — and the feed is the one route that gates a destructive
// action (host "Beitrag entfernen") on the role. Without this the feed would run on
// whatever the JWT claim said, which is frozen for the token's 30-day lifetime and so
// misses a promotion or demotion entirely. Cheap, and it refreshes the lock/release
// state in the same request.
void refreshEventState();
await Promise.all([loadFeed(), loadHashtags()]); await Promise.all([loadFeed(), loadHashtags()]);
connectSse(); connectSse();