fix(audit-1): media gating, per-user limits, moderation UI, upload integrity

First audit round: a blocker and eight high-severity findings across the media
access gate, the guest-facing rate limiters, host moderation, and the upload
pipeline — plus the backup commands and the e2e/production divergences that hid
several of them.

Squashed from 7 commits, original messages preserved below.

──────── fix(export): let the keepsake download through X-Frame-Options on iOS

The keepsake download navigates a hidden, same-origin iframe (deliberately:
a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a
site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`.

Blink hands a `Content-Disposition: attachment` response to the download
manager at the network layer, so Chromium never noticed. WebKit enforces XFO
on the frame navigation first and aborts the load — so on iOS Safari, the
app's primary platform, tapping Download did nothing at all, silently.

Carve the two export endpoints out to SAMEORIGIN, which still blocks
cross-origin framing. Implemented as two disjoint matchers rather than an
override: Caddy applies the FIRST `header` directive outermost, so it wins on
write and a later, more specific `header` is silently ignored (verified
against the running test stack).

Also close the test gap that let this ship:

- `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the
  only engine that enforces XFO on the download frame.
- No test in the suite ever clicked a download button — every archive
  assertion used Node `fetch`, which has no frame and no XFO enforcement.
  Add a spec that clicks it and awaits a real `download` event. Verified
  falsifiable: with the blanket DENY reinstated it fails and reports the
  WebKit refusal as the cause.
- Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards
  carry `class="card p-5"` (a Tailwind `@apply` component class), never the
  `rounded-xl` the page object looked for. This had left the "shows enabled
  download buttons" test red on main.

──────── fix(media): close the percent-escape bypass of the media gate

`/media/%70reviews/{id}.jpg` served a taken-down photo to anyone,
unauthenticated. Verified against the running stack: the literal path 404s,
the escaped one returned 200 with the full image. Same for displays,
thumbnails and originals, and any escaped byte in any position works.

Cause: the block was four `nest_service("/media/previews", 404)` route
matches sitting above a `ServeDir` on `/media`. axum matches on the RAW path
(matchit does no percent-decoding), while `ServeDir` percent-decodes when it
resolves the file. So `%70reviews` missed every blocker, fell through to the
ServeDir, and was decoded back to `previews/` on disk — reaching the bytes
with no soft-delete and no ban-hide check. That defeats a host takedown,
which is the entire point of the gate.

Remove the `/media` route tree outright instead of racing the decoder.
Nothing needs it: every media URL the backend emits is already a gated
`/api/v1/upload/{id}/{original,preview,display,thumbnail}` alias
(handlers::feed), the frontend contains zero `/media/` references, and the
`/media` in config.rs/disk.rs is the filesystem path while `media/` in
export.rs is a path inside the zip. `/media/**` now 404s regardless of
encoding. The route's own comment already said it "serves nothing" — it
wasn't a backstop, it was the vector.

Caddy keeps proxying /media/* deliberately: the app 404s it, and forwarding
means the e2e gating specs exercise the app's refusal exactly as production
would rather than being masked by the SvelteKit 404 page.

Extend the gating spec with the encoded variants — asserting only the literal
spelling is what let this sit undetected.

──────── fix(rate-limit): key the guest-facing limiters per user, not per IP

At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.

`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.

- feed:{ip}   -> feed:{user_id}    (auth was already in scope)
- export:{ip} -> export:{user_id}  (resolved from the download ticket's
  session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
  per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
  default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
  per (ip, name), mirroring the existing `recover:{ip}:{name}`.

admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.

Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.

Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.

Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.

Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.

──────── feat(moderation): let a host remove a guest's photo or comment from the UI

`DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were complete on the
backend — transactional, SSE-broadcasting, audit-logged — and had zero frontend
callers. The feed context sheet offered "Löschen" only when
`target.user_id === myUserId`, so the only lever a host actually had against an
unwanted photo was banning the uploader.

That is both disproportionate and ineffective. A ban doesn't retract what was
already posted, and it makes things strictly worse for comments: the ban check
runs BEFORE the ownership check on the guest delete route, so banning an abusive
author leaves their comment on screen and permanently undeletable by them. With
no host affordance, nobody could remove it at all.

- feed: hosts/admins get "Beitrag entfernen" on other people's posts, routed to
  the host endpoint (the guest route 403s anything the caller doesn't own) with
  moderation-specific confirm copy. Own-post "Löschen" is unchanged.
- lightbox: same for comments, via /host/comment/{id}.
- Ban semantics are deliberately untouched (USER_JOURNEYS §10 — banned users keep
  read access and cannot write). The deadlock is broken by giving the host a way
  in, not by loosening the ban.

Live role (this had to come first). `getRole()` decodes the JWT claim, but the
token is never reissued — the backend slides the session row forward and treats
the DB row as authoritative. The claim is therefore frozen for the token's
lifetime: up to 30 days. A guest promoted at the party saw no Host-Dashboard and
no moderation actions until they signed out and back in, even though
`/me/context` had been returning their real role on every page load and 4 of its
6 call sites dropped the field on the floor.

Add `role-store.ts`: seeded from the claim so there's no flash of the wrong nav,
then corrected by every `/me/context` response. Point the ad-hoc `getRole()`
callers at it (account, upload, host, admin, and the new feed gate). The host and
admin dashboards now derive `myRole` reactively, so a demotion disables their
controls immediately instead of at next login.

Tests: 04-host/moderation-ui drives the real UI — host removes a guest photo and
it's gone from /feed server-side; a plain guest is offered nothing on someone
else's post (the mirror that keeps the first test honest); a promoted guest gains
the dashboard on reload while their token still carries `role: guest`; and a host
removes the comment of an already-banned guest, asserting first that the author's
own delete 403s so the deadlock is real.

──────── fix(upload): stop destroying originals, apply EXIF orientation, surface rejections

Three defects in the same pipeline, each of which loses a photo or misrepresents
one.

1. A transient error destroyed the guest's only copy.

`process`'s error arm unconditionally `remove_file`d the original. Every failure
routed there: `create_dir_all`, both derivative `save_with_format` calls (disk
full is the canonical case, and it arrives exactly when many guests upload at
once), a panic inside the image codec, or a momentary DB-pool exhaustion. The
row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and
they were the part we deleted. The author already knew this was wrong next door:
`backfill_missing_display` says it "must NEVER soft-delete an upload that already
has a working preview".

Retry up to 3 times with backoff (re-checking the e2e generation guard after each
sleep), and on final failure keep the refund + soft-delete but leave the original
on disk, logging its path. A failed upload is now recoverable instead of gone.

2. Every portrait photo was stored sideways.

Phones don't rotate sensor data — they record the camera orientation in EXIF and
store the pixels as shot. `decode()` returns those raw pixels and the JPEG
re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and
the keepsake were all rotated 90°, while "Original anzeigen" rendered upright
because the original keeps its tag. That asymmetry is why it reads as a viewer
bug. There was no EXIF handling anywhere in the repo and no exif crate.

Read the tag via `into_decoder()` (which carries the decode Limits through, so
the decompression-bomb cap is untouched) and apply it. Missing/malformed tags
fall back to NoTransforms — most images have none.

Existing derivatives are already baked wrong, so migration 018 adds
`derivatives_rev` and `backfill_missing_display` becomes
`backfill_stale_derivatives`: it now also picks up anything below the current rev
and regenerates it once from the original, which still carries its EXIF. Videos
are marked current in the migration — ffmpeg already honours the rotation matrix.
Bump DERIVATIVES_REV for any future change that invalidates derivatives.

3. A rejected upload vanished without a word.

`UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error
text, the only "Erneut" retry button and the only rate-limit countdown — was
never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were
unreachable at runtime. On a terminal rejection the store purged the blob and
wrote a clear German reason into `entry.error` "so the UI shows a clear reason".
There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so
the badge decremented exactly as if the upload had succeeded.

Mount the queue on /upload, toast the reason immediately (the flow sends the user
to /feed straight after staging, so the list alone would still miss them), and
count blocked/error in the badge so a failure can't read as success.

Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6
and asserts both derivatives come back PORTRAIT, with a sanity check that the
source really is stored landscape. 02-upload/rejection-visible bans the uploader
between staging and sending, then asserts the toast, the queue row with the
server's reason, and that the item is still counted.

Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next
commit.

──────── docs(backup): make the backup commands work; fix the e2e/prod divergences

Backup. Both documented commands failed on the shipped stack, and the sentence
explaining them was wrong too:

- `pg_dump $DATABASE_URL` — `DATABASE_URL` is only ever in the compose
  environment, never an operator's shell, and it points at `db:5432`, which is
  compose-internal DNS. The app image has no postgres client either.
- `> /media/backups/…` — `/media` is a named volume mounted inside the app
  container, not a host path, and nothing ever creates a `backups` subdirectory.
- `rsync /opt/eventsnap/media/` — that path does not exist anywhere.
- "a single path to back up" — false, and dangerously so: exports were moved to
  their own `exports_data` volume precisely so a keepsake (which contains every
  photo in the event) can't be served off the media tree. Backing up only
  `media_data` silently loses every generated keepsake.

Rewritten as three commands — db via `docker compose exec -T db pg_dump`, and one
`docker run … tar` per volume — all verified against the running stack. The
volume mounts use `/src`, not `/media`: I hit the footgun while testing this.
Docker pre-populates an EMPTY volume from the image's own directory and chowns it
to match, so `-v media_data:/media alpine` tars alpine's cdrom/floppy/usb, writes
them into the volume, and leaves it root-owned so the non-root app can no longer
write. Mounting where the image has nothing avoids all of it. Documented inline
so the next person doesn't rediscover it.

Also correct the architecture notes: `/media/*` no longer routes to the backend
(that static tree was removed as a gating bypass), and `exports_data` was missing
from the volume list — the one volume an operator most needs to know about.

e2e stack: add the `EXPORT_PATH` + `/exports` volume it was missing. The file
says "mirrors production layout"; without these, exports landed on the container's
writable layer at the default path, so export-leak and export-video wrote real
archives into ephemeral storage and the "exports live outside media" invariant
was never actually exercised.

Pre-existing red test, unrelated to the audit: all four 02-upload/quota tests
have been failing since 4464147 "stop /me/quota leaking raw disk to guests"
(2026-07-19), which post-dates the spec's last edit. `setLimitTo` calibrated
`quota_tolerance` from `free_disk_bytes` read through the GUEST's token — a field
that commit deliberately zeroes for non-staff. Dividing by it yields a NaN
tolerance, so every test in the block died in the helper. Read the calibration
inputs through a staff token and keep reading the ceiling back through the guest,
whose limit is the thing under test.

──────── test(e2e): document why WebKit can't run the client-queue upload tests

Three 02-upload tests have been failing on `webkit-iphone` on main — `02-upload`
was already in that project's testMatch, so this is pre-existing red, not
something the audit work introduced.

Root cause is the harness, not the app. Playwright's Linux WebKit build cannot
store Blobs in IndexedDB at all: `put()` fails with "UnknownError: Error
preparing Blob/File data to be stored in object store". Confirmed it is not
about how Playwright delivers files — a Blob constructed in-page with
`new Blob([bytes])` fails identically, while Chromium stores both that and a
`setInputFiles` File without complaint.

That breaks every test driving the composer (FAB → UploadSheet → /upload →
submit), because `handleSubmit` awaits `addToQueue`, which persists the file
before it can navigate. The symptom is a submit button stuck on "Wird
hochgeladen…" and a timeout waiting for /feed — which reads like an app hang and
cost real time to run down.

Skip those four (the three above plus the new rejection-visible) on WebKit only,
behind a named helper carrying the full explanation, so the next person gets the
answer instead of the investigation. Deliberately narrow: WebKit still runs every
API-driven upload test, all of 01-auth, 03-feed and 06-export — including the
keepsake download, which only WebKit can meaningfully verify. Chromium continues
to run all 14.

Worth being explicit, since these tests exist to protect iOS: real Safari
supports Blobs in IndexedDB, so this is NOT evidence that the offline upload
queue is broken on the platform. It does mean that guarantee is currently
unverifiable in CI and rests on Chromium coverage plus manual device testing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-28 19:14:37 +02:00
parent a77c2ddc00
commit 0c0eed885a
43 changed files with 1276 additions and 222 deletions

View File

@@ -13,7 +13,11 @@ test.describe('Auth — join flow', () => {
const join = new JoinPage(page);
await join.goto();
await expect(page.getByRole('heading', { name: 'Willkommen!' })).toBeVisible();
// The join form's landing state. There is no "Willkommen!" heading — the wedding
// redesign (f243bfe) split it into a "Willkommen bei" lead-in plus the event name as
// the <h1>, and this assertion was never updated, so it had been failing since.
// Anchor on the testid the markup provides rather than on copy.
await expect(page.getByTestId('join-event-name')).toBeVisible();
const { pin } = await join.joinAs('Alice');
expect(pin).toMatch(/^\d{4}$/);

View File

@@ -0,0 +1,144 @@
/**
* Regression guard — the door must not close on a venue behind one NAT.
*
* `/join` was throttled 5 per 60s keyed purely on the client IP. Every guest at a venue
* arrives from the same public IP (that is what a NAT is), so the whole party shared one
* bucket: 12 guests scanning the QR code within a few seconds meant 5 got in and 7 were
* turned away — with no Retry-After to tell them when to try again. `/feed` (60/min) and
* `/export` (3/DAY) had the identical defect.
*
* These ran green for the same structural reason every time: the e2e reseed forces every
* limiter toggle OFF before each test, so nothing here was ever exercised. Enable them
* explicitly, exactly as 02-upload/rate-limit does.
*/
import { test, expect } from '../../fixtures/test';
import { BASE } from '../../helpers/env';
test.describe('Rate limits — guests behind a shared NAT', () => {
test('a dozen guests can all join from one IP, and 429s carry Retry-After', async ({
api,
adminToken,
}) => {
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
join_rate_enabled: 'true',
});
// Twelve DISTINCT guests, same source IP — the arrival burst at a real party.
const names = Array.from({ length: 12 }, (_, i) => `NatGuest${i}`);
const results = await Promise.all(
names.map((display_name) =>
fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name }),
})
)
);
const rejected = results.filter((r) => r.status === 429);
expect(
rejected.length,
`all 12 guests must get in from one IP; ${rejected.length} were turned away`
).toBe(0);
expect(results.every((r) => r.status === 201)).toBe(true);
});
test('one guest retrying their own name is still throttled, and told for how long', async ({
api,
adminToken,
}) => {
// The per-name bucket must still bite — otherwise the NAT fix would have simply
// removed the anti-spam limit rather than re-keyed it.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
join_rate_enabled: 'true',
});
const attempt = () =>
fetch(`${BASE}/api/v1/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: 'RepeatOffender' }),
});
// 5 per 60s for the same (ip, name): the first succeeds (201), the next four collide
// with the taken name (409), and the sixth exhausts the bucket.
const codes: number[] = [];
for (let i = 0; i < 6; i++) codes.push((await attempt()).status);
expect(codes[0], 'the first join should succeed').toBe(201);
expect(codes.at(-1), 'the 6th attempt on one name must be throttled').toBe(429);
const throttled = await attempt();
expect(throttled.status).toBe(429);
const retryAfter = throttled.headers.get('retry-after');
expect(retryAfter, '429 must tell the client when to come back').toBeTruthy();
expect(Number(retryAfter)).toBeGreaterThan(0);
expect(Number(retryAfter)).toBeLessThanOrEqual(60);
});
test('the feed limit is per-user, not per-IP', async ({ api, adminToken, guest }) => {
// Two guests, one IP. With a limit of 3/min an IP key would let the first guest's
// three reads starve the second entirely.
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
feed_rate_enabled: 'true',
feed_rate_per_min: '3',
});
const a = await guest('FeedHog');
const b = await guest('FeedVictim');
const read = (jwt: string) =>
fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${jwt}` } });
// Guest A burns their whole allowance.
for (let i = 0; i < 3; i++) expect((await read(a.jwt)).status).toBe(200);
expect((await read(a.jwt)).status, "A's own 4th read is throttled").toBe(429);
// Guest B must be entirely unaffected.
expect((await read(b.jwt)).status, 'B must not inherit As exhausted bucket').toBe(200);
});
test('the export limit is per-user — one guest cannot spend the whole venues quota', async ({
api,
adminToken,
guest,
host,
db,
}) => {
// The sharpest case: 3 downloads per DAY on an IP key meant the 4th guest to fetch
// their keepsake was locked out until tomorrow.
await db.setExportReleased('e2e-test-event', true);
await api.patchConfig(adminToken, {
rate_limits_enabled: 'true',
export_rate_enabled: 'true',
export_rate_per_day: '1',
});
const mintAndFetch = async (jwt: string) => {
const res = await fetch(`${BASE}/api/v1/export/ticket`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
});
const { ticket } = await res.json();
return fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
};
const a = await guest('ExportFirst');
const b = await guest('ExportSecond');
// A spends their single daily allowance. The archive itself may not exist (404) —
// what matters is that the limiter admitted the request rather than 429ing it.
expect((await mintAndFetch(a.jwt)).status).not.toBe(429);
expect((await mintAndFetch(a.jwt)).status, 'As second download is throttled').toBe(429);
// B shares A's IP and must still get their keepsake.
expect((await mintAndFetch(b.jwt)).status, 'B must not be locked out by As download').not.toBe(
429
);
// And the host too, for good measure.
expect((await mintAndFetch(host.jwt)).status).not.toBe(429);
});
});

View File

@@ -26,6 +26,7 @@
* large fixture files.
*/
import { test, expect } from '../../fixtures/test';
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
import { FeedPage, UploadSheet } from '../../page-objects';
import { mkdtempSync, copyFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
@@ -100,7 +101,9 @@ test.describe('Upload — client queue under a burst', () => {
guest,
signIn,
db,
browserName,
}) => {
skipIfNoIdbBlobs(browserName);
const g = await guest('BurstSerial');
await signIn(page, g);
await warmUploadChunk(page);
@@ -163,7 +166,9 @@ test.describe('Upload — client queue under a burst', () => {
guest,
signIn,
db,
browserName,
}) => {
skipIfNoIdbBlobs(browserName);
const g = await guest('BurstResume');
await signIn(page, g);
await warmUploadChunk(page);

View File

@@ -0,0 +1,80 @@
/**
* Regression guard — EXIF orientation must be applied when generating derivatives.
*
* Phones do not rotate sensor data. They shoot in the sensor's native landscape and record
* how the camera was held in an EXIF `Orientation` tag. `image`'s `decode()` returns the raw
* pixels and ignores that tag, and the JPEG re-encode writes no EXIF at all — so every
* portrait photo was stored SIDEWAYS in the 800px feed preview, the 2048px diashow display
* and the keepsake, while "Original anzeigen" still rendered it upright (the original keeps
* its tag). That asymmetry is why it reads as a viewer bug instead of a pipeline one.
*
* The fixture is 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"),
* so a correctly-processed derivative is PORTRAIT (20x40). Asserting on the aspect ratio
* rather than the bytes keeps this robust across encoder changes.
*/
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 EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
/**
* Read a baseline/progressive JPEG's pixel dimensions from its SOF marker.
* Avoids pulling an image dependency into the suite for one assertion.
*/
function jpegSize(buf: Buffer): { width: number; height: number } {
let i = 2; // skip SOI
while (i < buf.length) {
if (buf[i] !== 0xff) {
i++;
continue;
}
const marker = buf[i + 1];
// SOF0..SOF15, excluding DHT (c4), JPGA (c8) and DAC (cc)
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('Upload — EXIF orientation', () => {
test('a rotated photo is upright in the preview and the display derivative', async ({
guest,
db,
}) => {
const g = await guest('SidewaysShooter');
const res = await uploadRaw(g.jwt, readFileSync(EXIF_FIXTURE), {
filename: 'portrait-exif6.jpg',
contentType: 'image/jpeg',
caption: 'hochkant',
});
expect(res.status).toBe(201);
const { id } = (await res.json()) as { id: string };
// Sanity: the SOURCE really is stored landscape with the tag, otherwise this test
// could pass against a pipeline that does nothing.
const source = jpegSize(readFileSync(EXIF_FIXTURE));
expect(source.width).toBeGreaterThan(source.height);
await expect
.poll(() => db.compressionStatus(id), { timeout: 30_000, intervals: [250] })
.toBe('done');
for (const variant of ['preview', 'display'] as const) {
const r = await fetch(`${BASE}/api/v1/upload/${id}/${variant}`, {
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(r.status, `${variant} must be served`).toBe(200);
const { width, height } = jpegSize(Buffer.from(await r.arrayBuffer()));
expect(
height,
`${variant} must be portrait (${width}x${height}) — EXIF orientation was not applied`
).toBeGreaterThan(width);
}
});
});

View File

@@ -4,6 +4,7 @@
* IndexedDB queue resumption after refresh, and SSE `upload-processed`.
*/
import { test, expect } from '../../fixtures/test';
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
import { FeedPage, UploadSheet } from '../../page-objects';
import { SseListener } from '../../helpers/sse-listener';
import { join } from 'node:path';
@@ -49,7 +50,9 @@ test.describe('Upload — gallery path', () => {
guest,
signIn,
db,
browserName,
}) => {
skipIfNoIdbBlobs(browserName);
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
// `upgrade` callback opened a *new* transaction, which throws during a

View File

@@ -56,14 +56,21 @@ function upload(jwt: string, name: string) {
/**
* Pick a `quota_tolerance` that makes the per-user ceiling land on `targetBytes`.
* limit = floor(free_disk * tolerance / max(active, 1)) ⇒ tolerance = target * active / free.
*
* `staffJwt` reads the calibration inputs, `jwt` is the guest the limit is being aimed at.
* They must be different tokens: `free_disk_bytes` and `active_uploaders` are raw server
* telemetry and `/me/quota` zeroes both for non-staff (handlers/me.rs — "must never reach a
* guest"). Calibrating off the guest's own response divides by zero and yields a NaN
* tolerance, which is what silently broke this whole describe block.
*/
async function setLimitTo(
api: any,
adminToken: string,
staffJwt: string,
jwt: string,
targetBytes: number
): Promise<number> {
const q = await quotaOf(jwt);
const q = await quotaOf(staffJwt);
expect(
q.free_disk_bytes,
'the disk must be readable, else quota fails OPEN and proves nothing'
@@ -72,6 +79,7 @@ async function setLimitTo(
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
// Read back through the GUEST, whose ceiling is the one under test.
const after = await quotaOf(jwt);
expect(after.enabled).toBe(true);
return after.limit_bytes as number;
@@ -89,10 +97,11 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
const g = await guest('QuotaOver');
// Ceiling below one file: the very first upload must be refused.
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE / 2));
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE / 2));
expect(limit).toBeLessThan(SIZE);
const res = await upload(g.jwt, 'too-big.jpg');
@@ -111,9 +120,10 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
const g = await guest('QuotaUnder');
await setLimitTo(api, adminToken, g.jwt, SIZE * 4);
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 4);
expect((await upload(g.jwt, 'fine.jpg')).status).toBe(201);
expect((await quotaOf(g.jwt)).used_bytes).toBe(SIZE);
@@ -123,11 +133,12 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
const g = await guest('QuotaRacer');
// Room for exactly ONE file.
const limit = await setLimitTo(api, adminToken, g.jwt, Math.floor(SIZE * 1.5));
const limit = await setLimitTo(api, adminToken, host.jwt, g.jwt, Math.floor(SIZE * 1.5));
expect(limit).toBeGreaterThanOrEqual(SIZE);
expect(limit).toBeLessThan(SIZE * 2);
@@ -170,10 +181,11 @@ test.describe('Upload — storage quota enforcement', () => {
api,
adminToken,
guest,
host,
}) => {
// Zero test hits before this — and it is the source of the "X von Y MB genutzt" widget.
const g = await guest('QuotaWidget');
await setLimitTo(api, adminToken, g.jwt, SIZE * 10);
await setLimitTo(api, adminToken, host.jwt, g.jwt, SIZE * 10);
const before = await quotaOf(g.jwt);
expect(before.enabled).toBe(true);

View File

@@ -0,0 +1,87 @@
/**
* Regression guard — a rejected upload must tell the user something.
*
* `UploadQueue.svelte` was 162 lines of complete, working UI — the only renderer of an
* item's error text, the only "Erneut" retry button, the only rate-limit countdown — and it
* was never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were all
* unreachable at runtime. On a terminal rejection the store dropped the blob, wrote a clear
* German reason into `entry.error` with the comment "so the UI shows a clear reason", and
* there was no such UI.
*
* Meanwhile the FAB badge counted only pending/uploading, so a rejected photo decremented it
* exactly as if it had succeeded. Net effect: the photo silently vanished — no toast, no
* queue row, no error text, and it never appeared in the feed.
*/
import { test, expect } from '../../fixtures/test';
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
import { FeedPage, UploadSheet } from '../../page-objects';
import { join } from 'node:path';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
test.describe('Upload — a rejected upload is surfaced', () => {
test('a terminally rejected upload toasts, and stays visible in the queue', async ({
page,
api,
host,
guest,
signIn,
browserName,
}) => {
skipIfNoIdbBlobs(browserName);
const g = await guest('RejectedUploader');
await signIn(page, g);
const feed = new FeedPage(page);
const sheet = new UploadSheet(page);
await feed.openUploadSheet();
await sheet.stageFiles([SAMPLE_JPG]);
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
// Ban the uploader between staging and sending, so the POST comes back 403 — a
// terminal 4xx the server will keep rejecting, which is the path that purges the blob.
await api.banUser(host.jwt, g.userId);
await sheet.submit();
// 1. The user is told, wherever they are (the flow lands them on /feed).
const toast = page.getByRole('region', { name: 'Benachrichtigungen' });
await expect(toast).toContainText(/sample\.jpg/i, { timeout: 15_000 });
// 2. The queue row survives with its reason and is reachable on /upload — this is what
// the orphaned component made impossible.
await page.goto('/upload');
const queue = page.getByText('Upload-Warteschlange');
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
timeout: 10_000,
});
// Both the status chip ("Gesperrt") and the server's reason ("Du bist gesperrt.") must
// render — the reason is the part that had no UI at all before.
await expect(page.getByText('Gesperrt', { exact: true })).toBeVisible();
await expect(page.getByText('Du bist gesperrt.')).toBeVisible();
// 3. The badge must not read as success. It counted only pending/uploading before, so a
// rejected item dropped it to 0 — indistinguishable from a completed upload.
await expect
.poll(
() =>
page.evaluate(async () => {
return new Promise<number>((resolve, reject) => {
const req = indexedDB.open('eventsnap-uploads', 3);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const tx = req.result.transaction('queue', 'readonly');
const all = tx.objectStore('queue').getAll();
all.onsuccess = () =>
resolve(
all.result.filter((r: { status: string }) => r.status === 'blocked').length
);
all.onerror = () => reject(all.error);
};
});
}),
{ timeout: 10_000 }
)
.toBe(1);
});
});

View File

@@ -0,0 +1,149 @@
/**
* Regression guard — a host must be able to remove a guest's content FROM THE UI.
*
* `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were fully implemented,
* transactional, SSE-broadcasting, audit-logged — and had zero frontend callers. The feed's
* context sheet offered "Löschen" only when `target.user_id === myUserId`, so the only lever
* a host actually had against an unwanted photo was banning the uploader. That is both
* disproportionate and ineffective: a ban doesn't retract what was already posted, and
* because the ban check runs BEFORE the ownership check on the guest delete route, banning
* the author makes their abusive comment permanently undeletable by them too.
*
* The API side was already covered (04-host/moderation). What was missing is the wiring,
* so these tests drive the real UI.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Host — moderation from the UI', () => {
test("a host removes a guest's photo via the feed context sheet", async ({
page,
host,
guest,
signIn,
}) => {
const g = await guest('PhotoOffender');
const uploadId = await seedUpload(g.jwt);
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: g.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
const remove = page.getByRole('button', { name: /beitrag entfernen/i });
await expect(remove, 'a host must be offered a removal action on a guest post').toBeVisible();
await remove.click();
const sheet = page.getByTestId('confirm-sheet');
await expect(sheet).toBeVisible();
// Moderation copy, not "delete my post" copy.
await expect(sheet).toContainText(/beitrag entfernen/i);
await page.getByTestId('confirm-sheet-confirm').click();
await expect(card).not.toBeVisible({ timeout: 10_000 });
// And it is really gone server-side, not just dropped from the local list.
const res = await fetch(`${BASE}/api/v1/feed`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
const body = await res.json();
expect(body.uploads.some((u: { id: string }) => u.id === uploadId)).toBe(false);
});
test('a guest is NOT offered any delete action on someone elses post', async ({
page,
guest,
signIn,
}) => {
// The mirror that makes the test above meaningful: if this affordance rendered for
// everyone, the host test would still pass on a build that shipped moderation to guests.
const author = await guest('SomeAuthor');
await seedUpload(author.jwt);
const viewer = await guest('NosyViewer');
await signIn(page, viewer);
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: /beitrag entfernen/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^löschen$/i })).toHaveCount(0);
});
test('a promoted guest gets host powers without signing out and back in', async ({
page,
api,
adminToken,
guest,
signIn,
}) => {
// The JWT is never reissued — the backend slides the session row forward and treats the
// DB row as authoritative. So the token of a promoted guest still claims `role: guest`
// for up to 30 days. The UI read that frozen claim, which meant a guest promoted at the
// party saw no Host-Dashboard and no moderation actions until they signed out and back
// in — while `/me/context` had been handing the client the real role all along.
const g = await guest('LatePromotion');
await signIn(page, g);
await page.goto('/account');
await expect(page.getByRole('link', { name: /host-dashboard/i })).toHaveCount(0);
// Promote mid-session. The token in localStorage is deliberately NOT refreshed.
await api.setRole(adminToken, g.userId, 'host');
const claim = JSON.parse(Buffer.from(g.jwt.split('.')[1], 'base64').toString());
expect(
claim.role,
'the token must still carry the stale claim for this to prove anything'
).toBe('guest');
await page.reload();
await expect(
page.getByRole('link', { name: /host-dashboard/i }),
'the live role from /me/context must win over the frozen JWT claim'
).toBeVisible({ timeout: 10_000 });
});
test('a host can remove the comment of a guest they have already banned', async ({
page,
api,
host,
guest,
signIn,
}) => {
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
// on the author gets 403 on their own delete, so if the host has no removal affordance
// the comment is stuck on screen forever.
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
// uploads, so if the comment sat on their own photo the whole card would vanish and
// there would be nothing left to moderate.
const victim = await guest('PhotoOwner');
const uploadId = await seedUpload(victim.jwt);
const author = await guest('CommentOffender');
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
await api.banUser(host.jwt, author.userId);
// Confirm the deadlock really exists — the author cannot retract it themselves.
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${author.jwt}` },
});
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: victim.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const comment = page.getByText('unangebrachter Kommentar');
await expect(comment).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
await expect(comment).toHaveCount(0, { timeout: 10_000 });
});
});

View File

@@ -0,0 +1,103 @@
/**
* Regression guard — the keepsake download must actually download, in WebKit.
*
* The bug this exists to catch: `/export` streams the archive by pointing a HIDDEN,
* SAME-ORIGIN iframe at `/api/v1/export/zip` (deliberately — a top-level navigation to a
* 404/429 would unload the PWA). Caddy stamped a site-wide `X-Frame-Options: DENY` that
* also covered `/api/*`. Blink hands a `Content-Disposition: attachment` response to the
* download manager at the network layer, so Chromium never noticed; WebKit enforces XFO on
* the frame navigation FIRST and aborts the load. Result: on iOS Safari — the app's primary
* platform — tapping Download did nothing, silently, with no error anywhere.
*
* Why the old suite was structurally blind to it:
* - `06-export` ran on `chromium-desktop` only (webkit-iphone's testMatch excluded it),
* - and no test in the entire suite ever CLICKED a download button; every archive
* assertion used Node `fetch`, which has no frame and therefore no XFO enforcement.
*
* So this spec must keep both properties to be worth anything: a real click, in WebKit.
*/
import { test, expect } from '../../fixtures/test';
import { ExportPage } from '../../page-objects';
import { BASE } from '../../helpers/env';
const SLUG = 'e2e-test-event';
function post(path: string, jwt: string) {
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
}
/** The real export job runs image processing; give it head-room over the tiny fixtures. */
async function releaseAndWait(jwt: string) {
expect((await post('/api/v1/host/gallery/release', jwt)).status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(BASE + '/api/v1/export/status', {
headers: { Authorization: `Bearer ${jwt}` },
});
const s = await res.json();
return s.released === true && s.zip?.status === 'done';
},
{ timeout: 60_000, intervals: [500] }
)
.toBe(true);
}
test.describe('Export — the download actually fires in the browser', () => {
test.slow();
test('clicking Download triggers a real download event', async ({ page, host, signIn, db }) => {
await db.setExportReleased(SLUG, false);
await releaseAndWait(host.jwt);
await signIn(page, host);
const exportPage = new ExportPage(page);
await exportPage.goto();
await expect(exportPage.zipDownloadButton).toBeEnabled({ timeout: 10_000 });
// Capture the frame-level refusal that XFO produces, so a failure reports the CAUSE
// rather than just a timeout. WebKit logs "Refused to display ... in a frame because it
// set 'X-Frame-Options'"; Chromium logs nothing here, which is the whole problem.
const refusals: string[] = [];
page.on('console', (m) => {
if (/X-Frame-Options|Refused to display/i.test(m.text())) refusals.push(m.text());
});
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 });
await exportPage.zipDownloadButton.click();
const download = await downloadPromise.catch((err) => {
throw new Error(
`No download event fired after clicking the ZIP button.` +
(refusals.length
? ` The browser refused the iframe navigation: ${refusals.join(' | ')}`
: ' No X-Frame-Options refusal was logged; check the ticket/readiness path.') +
`\n${err}`
);
});
expect(download.suggestedFilename()).toMatch(/\.zip$/i);
// The stream must produce real bytes, not a zero-length placeholder.
const path = await download.path();
expect(path).toBeTruthy();
expect(refusals, 'no frame should have been refused').toEqual([]);
});
test('the export endpoints are framable same-origin; everything else stays DENY', async () => {
// Locks the Caddy carve-out itself, independently of any browser. Cheap, and it fails
// loudly at the exact layer that regressed if someone reinstates a blanket DENY.
for (const path of ['/api/v1/export/zip', '/api/v1/export/html']) {
const res = await fetch(BASE + path);
expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must be framable`).toBe(
'SAMEORIGIN'
);
}
for (const path of ['/', '/api/v1/feed', '/api/v1/event']) {
const res = await fetch(BASE + path);
expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must stay DENY`).toBe(
'DENY'
);
}
});
});

View File

@@ -15,7 +15,15 @@ test.describe('Adversarial — small-scale abuse', () => {
await api.patchConfig(adminToken, { rate_limits_enabled: 'true', join_rate_enabled: 'true' });
});
test('20 parallel /join from one IP — rate limiter catches the excess', async () => {
test('a /join flood from one IP is caught by the per-IP ceiling', async ({ api, adminToken }) => {
// This used to assert that 20 joins from one IP produced 429s under a 5/min per-IP
// bucket. That "protection" was the bug: at a venue every guest shares one public IP,
// so it turned real arriving guests away (see 01-auth/rate-limit-shared-nat). The
// anti-spam bucket is now per (ip, name); what remains per-IP is a loose ceiling whose
// job is only to bound raw volume. Squeeze the ceiling so a flood is reproducible here
// without firing 60+ requests.
await api.patchConfig(adminToken, { join_ip_rate_per_min: '5' });
const requests = Array.from({ length: 20 }, (_, i) =>
fetch(`${BASE}/api/v1/join`, {
method: 'POST',
@@ -24,7 +32,7 @@ test.describe('Adversarial — small-scale abuse', () => {
})
);
const statuses = (await Promise.all(requests)).map((r) => r.status);
// 5/min limit → at least some should be 429.
// Ceiling of 5 → the excess must be shed.
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
// Server stays up — at least one succeeded.
expect(statuses.some((s) => s === 201 || s === 409)).toBe(true);

View File

@@ -45,6 +45,23 @@ test.describe('Media gating — moderation revokes preview access (F2)', () => {
const direct = await fetch(`${BASE}/media/previews/${id}.jpg`);
expect(direct.status, 'direct /media/previews must be blocked').toBe(404);
// …and it must stay blocked under percent-encoding. The block used to be four
// `nest_service("/media/previews", 404)` route matches sitting above a `/media`
// ServeDir. axum routes on the RAW path while ServeDir percent-decodes afterwards, so
// ONE escaped byte (`%70` = `p`) missed every blocker, fell through to the ServeDir,
// and was decoded back to `previews/` on disk — serving the bytes unauthenticated.
// Asserting only the literal spelling is what let that sit here undetected.
for (const variant of [
`/media/%70reviews/${id}.jpg`, // p
`/media/p%72eviews/${id}.jpg`, // r — any position works
`/media/%64isplays/${id}.jpg`, // d
`/media/%74humbnails/${id}.jpg`, // t
`/media/%6Friginals/${id}.jpg`, // o
]) {
const res = await fetch(`${BASE}${variant}`, { redirect: 'manual' });
expect(res.status, `${variant} must not bypass the media block`).toBe(404);
}
// Host deletes the upload → the preview must stop being served.
const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, {
method: 'DELETE',