main
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2dd563b3ee |
fix(upload): a truncated body no longer destroys the guest's photo, and the in-app camera can be switched off
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m31s
Audit / npm audit (frontend) (push) Successful in 1m2s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m7s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m37s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m13s
Checks / E2E — typecheck + lint (push) Failing after 36s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m8s
E2E / Cross-UA smoke matrix (push) Failing after 4m17s
Two event-day failures, both frontend-only.
TRUNCATED UPLOADS PURGED THE PHOTO. An iPhone guest uploading from the gallery
inside WhatsApp's browser got "Error parsing `multipart/form-data` request" and
the item went to "Gesperrt" with no retry. That message is AXUM's own multipart
rejection — a plain-text 400, no JSON envelope — which means the request body
never arrived intact. It is a transport failure, not a verdict on the file.
`classifyUploadStatus` maps every 4xx to `terminal`, and terminal PURGES the blob
from IndexedDB and offers no retry. So a webview hiccup deleted the only copy the
guest had, and told them the photo was rejected.
Every 400 the app itself raises carries `bad_request` in a JSON envelope (too
large, wrong type, caption too long, NUL byte), so an unparseable 400 is
distinguishable and is now a NetworkError: blob kept, retry offered. This is the
same rule the 403 branch already applies — "an unparseable body must NOT purge
the blob, losing a photo is the worst outcome" — extended to the status that was
actually hit. Retrying is safe because nothing was parsed, so nothing was stored
and no quota was charged, and `X-Client-Upload-Id` makes a duplicate impossible.
IN-APP CAMERA SWITCH. `PUBLIC_CAMERA_ENABLED=false` removes the "Kamera — Jetzt
aufnehmen" entry from the upload sheet. On some phones `getUserMedia` fails when
switching front/back ("Kamera konnte nicht gestartet werden") or when asked for
video, and those failures are per-device and undiagnosable mid-event; the switch
removes the broken path rather than leaving guests to find it. Nothing is lost:
the gallery picker reaches the phone's own camera app and handles video.
Read at RUNTIME via `$env/dynamic/public`, so flipping it is a compose variable
and `up -d frontend`, not a rebuild. Deliberately NOT routed through the
backend's event payload like `comments_enabled`: that flag describes the event,
this one describes what the client can do — the backend cannot tell a camera
upload from a gallery upload and has no stake in it. Keeping it off the app image
also means no backend release on the day of the event.
The onboarding step and the in-app-browser hint drop their camera wording when it
is off, so no text promises a button that is not there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0a33189e7e |
test(loadtest): audit which real files the upload validator actually refuses
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m9s
Audit / npm audit (frontend) (push) Successful in 41s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m11s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
"Will my photos be accepted?" was being answered by reasoning about the size caps, which is the wrong method — size is only one of the paths that refuses a file. The magic-byte allowlist, the decode budget (12000 px axis / 256 MiB alloc, both code constants no setting can relax), the disk gate and the per-user quota all reject too, and only the running server knows how they interact. `acceptance-audit.mjs` pushes every file in the pool through the real endpoint and groups the refusals by the server's own German message. Against the 945-file wedding set with the raised limits it answered the question exactly: 935 accepted, and the only 10 refusals are the HEIC files. Deliberately not a load test — no personas, no viewers, no think-time. It measures admission, so it does not wait for the compression backlog to drain. The sim stack gains two overridable vars, so the harness can be pointed at the configuration actually deployed rather than the defaults: SIM_APP_IMAGE / SIM_FE_IMAGE (audit a release image, not a local build) and KEEPSAKE_ENABLED, which changes the disk gate and therefore changes what gets refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e3159299c0 |
test(loadtest): an event simulation on a real 2 vCPU / 4 GB / 30 GB box
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m2s
Audit / npm audit (frontend) (push) Successful in 51s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 52s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m5s
Checks / E2E — typecheck + lint (push) Failing after 39s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m13s
E2E / Cross-UA smoke matrix (push) Failing after 4m20s
`driver.mjs` is a pipeline benchmark: synthetic images, uniform load, rate limits off, and — the part that mattered — an unconstrained host, so the 1 GB app cap was never exercised and the disk gate never fired. It could not have found either of the two defects fixed in the preceding commits. This harness differs in three ways that earn their keep: REAL CONTENT. Uploads come from a pool of actual wedding photos and videos, unedited, including the HEIC files and 25 MB frames the app is supposed to REFUSE. Those refusals are the test, not noise to filter out — 152 of 932 attempts were refused, and the breakdown of WHY is the most actionable output. PERSONAS. ~100 viewers and ~50 uploaders across nine behaviour profiles, six device profiles, each with a join time and a session length. A casual guest who posts four photos generates a completely different request mix than a photographer dumping 130, and both differ from a kiosk holding one SSE stream all night. A 37-case abuse suite covers malicious payloads, injection, cross-user tampering, enumeration and the rate limiters. RATE LIMITS STAY ON. `driver.mjs` disabled them because it ran every guest from one IP. Almost every limit that matters is per USER, not per IP, and those are as real for 150 synthetic sessions as for 150 phones — leaving them on is what lets the abuse personas prove the defences work. The per-IP limits ARE distorted by the single source address; that distortion is measured and reported rather than configured away. `docker-compose.sim.yml` reproduces the CX22 rather than asserting it: production's per-service cpus/memory/cpu_shares verbatim, every container pinned to the SAME two cores with `cpuset` so they genuinely contend, and a real loopback ext4 volume so the app's own statvfs returns true numbers. Run the driver under `taskset` onto other cores, or the load generator competes with the thing it measures. `browser-check.mjs` covers what an HTTP driver structurally cannot: the SvelteKit container, and whether the frontend ESCAPES the XSS caption the backend stores verbatim. The backend stores captions raw by design, so the renderer is the entire defence — and only a browser can prove the payload is inert. It reports INCONCLUSIVE rather than PASS when the payload never reached the DOM, because a check that renders nothing proves nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |