2 Commits

Author SHA1 Message Date
2b57f1728e fix(upload): the truncation guard never fired -- it checked the wrong field
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m29s
Audit / npm audit (frontend) (push) Successful in 59s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 59s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m40s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m18s
Checks / E2E — typecheck + lint (push) Failing after 37s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m55s
E2E / Cross-UA smoke matrix (push) Failing after 4m2s
v0.18.3 added a 400 branch to keep a guest's photo when the request body arrives
truncated, gated on `body.error !== 'bad_request'`. Its premise was that axum's
multipart rejection is a plain-text 400 with no envelope, so an envelope with
`bad_request` in it must be a considered verdict on the file.

That is false for the path that actually fails, and the branch was inert against
the exact incident it was written for. A guest on the live event lost a photo at
07:22; replaying the same truncation against the running backend at 07:50
produced a byte-identical log line:

  WARN request rejected status=400 code="bad_request"
       detail="Error parsing `multipart/form-data` request"

and this response body:

  400 application/json
  {"error":"bad_request","message":"Error parsing `multipart/form-data` request"}

A `bad_request` envelope. The guard evaluates false, the item still goes
terminal, and the blob is still purged.

The handler never reaches axum's extractor rejection: it pulls the fields itself
and wraps `MultipartError` in `AppError::BadRequest` -- bare from the field loop,
prefixed with "Datei konnte nicht gelesen werden: " from the chunk loop. Both
arrive indistinguishable BY CODE from "file too large". Axum's own plain-text
rejection does exist (no boundary in Content-Type -> "Invalid `boundary` ...")
but is a different error and not one a webview produces.

So the rule keys on the MESSAGE, and moves into an exported `isIncompleteBody`
beside `isReversibleLock`, matching how the other data-loss-critical rules in
this file are made testable. Tests transcribe the four responses captured from
the running backend, so reverting to an envelope check fails them.

Matching an upstream Display string is the weakness here and is called out in
the doc comment: an axum upgrade could reword it and silently re-open the data
loss. The durable fix is a distinct backend code (`body_incomplete`) this can
prefer once it exists -- deliberately not done now, because it means an app image
release and the backend has not needed one since v0.18.0.
2026-08-22 08:13:40 +00:00
MechaCat02
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>
2026-08-22 09:30:59 +02:00
8 changed files with 188 additions and 6 deletions

View File

@@ -231,6 +231,21 @@ COMMENTS_ENABLED=true
# Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake. # Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake.
KEEPSAKE_ENABLED=true KEEPSAKE_ENABLED=true
# ── In-app camera ─────────────────────────────────────────────────────────────
# Whether the upload sheet offers "Kamera — Jetzt aufnehmen" alongside "Galerie".
# Read at RUNTIME by the frontend container, so changing it is this line plus
# `docker compose up -d frontend` — no rebuild.
#
# Set to false when the in-app camera misbehaves on the guests' actual phones: switching
# between front and back throwing "Kamera konnte nicht gestartet werden", or video capture
# failing its permission prompt. Those failures are per-device and cannot be diagnosed
# mid-event, so this removes the broken path instead of letting guests find it.
#
# Nothing is lost by turning it off. The gallery picker opens the phone's own file chooser,
# which reaches the camera app on both iOS and Android and handles video — it is the path
# most guests use anyway. The onboarding text and the upload sheet adjust themselves.
PUBLIC_CAMERA_ENABLED=true
# ── Logging ─────────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────────
# SET THIS IN PRODUCTION. Without it the app falls back to # SET THIS IN PRODUCTION. Without it the app falls back to
# `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a # `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a

View File

@@ -226,6 +226,14 @@ services:
# produces `https://` here and collapses the Caddyfile's site block below, so the stack # produces `https://` here and collapses the Caddyfile's site block below, so the stack
# comes up with no TLS and no site and the only symptom is a browser error. # comes up with no TLS and no site and the only symptom is a browser error.
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}" ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
# In-app camera switch, read at RUNTIME by adapter-node — so flipping it is this line
# plus `docker compose up -d frontend`, not a rebuild. Set it to "false" when
# `getUserMedia` misbehaves on the guests' phones (front/back switching throwing
# "Kamera konnte nicht gestartet werden", video capture failing its permission prompt).
# Guests then upload through the gallery picker, which still reaches the phone's own
# camera app and handles video. Lives on the FRONTEND service, not `app`: the backend
# cannot tell a camera upload from a gallery upload and has no stake in the choice.
PUBLIC_CAMERA_ENABLED: "${PUBLIC_CAMERA_ENABLED:-true}"
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured: # V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the # heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response # container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response

View File

@@ -112,6 +112,7 @@ services:
PORT: '3001' PORT: '3001'
HOST: '0.0.0.0' HOST: '0.0.0.0'
ORIGIN: 'http://localhost:3102' ORIGIN: 'http://localhost:3102'
PUBLIC_CAMERA_ENABLED: ${SIM_CAMERA:-true}
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -6,6 +6,7 @@
import { scrollLock } from '$lib/actions/scroll-lock'; import { scrollLock } from '$lib/actions/scroll-lock';
import { vibrate } from '$lib/haptics'; import { vibrate } from '$lib/haptics';
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding'; import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
import { cameraEnabled } from '$lib/feature-flags';
type Step = type Step =
| { kind: 'text'; icon: string; title: string; body: string } | { kind: 'text'; icon: string; title: string; body: string }
@@ -30,7 +31,13 @@
kind: 'text', kind: 'text',
icon: '⬆️', icon: '⬆️',
title: 'Fotos & Videos hochladen', title: 'Fotos & Videos hochladen',
body: 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!' // The second half is conditional: with the in-app camera switched off, promising
// "direkt mit der Kamera aufnehmen" describes a button that is not there. The
// gallery picker still reaches the phone's camera app on both iOS and Android, so
// the capability survives — only the in-app shortcut is gone.
body: cameraEnabled
? 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
: 'Tippe auf den Kamera-Button unten in der Mitte und wähle Fotos oder Videos aus deiner Galerie. Frisch aufnehmen kannst du direkt in der Auswahl deines Handys. Mehrere Dateien auf einmal sind kein Problem!'
}, },
{ {
kind: 'text', kind: 'text',

View File

@@ -8,6 +8,7 @@
import type { PendingFile } from '$lib/pending-upload-store'; import type { PendingFile } from '$lib/pending-upload-store';
import { eventState, uploadsClosed } from '$lib/event-state-store'; import { eventState, uploadsClosed } from '$lib/event-state-store';
import { commentsEnabled } from '$lib/event-config-store'; import { commentsEnabled } from '$lib/event-config-store';
import { cameraEnabled } from '$lib/feature-flags';
import { isBanned } from '$lib/ban-store'; import { isBanned } from '$lib/ban-store';
// A ban closes uploads just as hard as an event lock does — the backend refuses every // A ban closes uploads just as hard as an event lock does — the backend refuses every
@@ -150,8 +151,12 @@
} }
</script> </script>
<!-- Camera (rendered outside sheet so it gets full viewport) --> <!-- Camera (rendered outside sheet so it gets full viewport).
{#if showCamera} `cameraEnabled` is checked here as well as on the button: `showCamera` is ordinary
component state, and a belt-and-braces guard means no future entry point (a deep link, a
restored state, a stray keyboard shortcut) can mount the capture UI while it is switched
off for the event. -->
{#if showCamera && cameraEnabled}
<CameraCapture <CameraCapture
oncapture={handleCapture} oncapture={handleCapture}
onclose={handleCameraClose} onclose={handleCameraClose}
@@ -262,7 +267,11 @@
</div> </div>
</button> </button>
<!-- Camera option --> <!-- Camera option. Hidden entirely when PUBLIC_CAMERA_ENABLED=false: on some devices
`getUserMedia` fails when switching front/back or when asked for video, and a
button that throws an error modal is worse than no button. The gallery entry
above still reaches the OS camera and handles video. -->
{#if cameraEnabled}
<button <button
onclick={openCamera} onclick={openCamera}
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600" class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
@@ -294,6 +303,7 @@
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p> <p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
</div> </div>
</button> </button>
{/if}
<!-- The in-app-browser escape hatch. <!-- The in-app-browser escape hatch.
The join link travels through WhatsApp groups, and a link tapped inside one opens The join link travels through WhatsApp groups, and a link tapped inside one opens
@@ -311,8 +321,8 @@
hint they cannot act on into an instruction they can. --> hint they cannot act on into an instruction they can. -->
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400"> <p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä. Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä.
Öffne diese Seite in Safari oder Chrome — dort funktionieren Kamera und Galerie. (Im Menü Öffne diese Seite in Safari oder Chrome — dort funktioniert die Auswahl. (Im Menü des
des In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.) In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
</p> </p>
{/if} {/if}

View File

@@ -0,0 +1,36 @@
import { env } from '$env/dynamic/public';
/**
* Build-independent feature switches read from the frontend container's environment.
*
* Deliberately NOT routed through the backend's `/api/v1/event` payload the way
* `comments_enabled` is. That flag describes the EVENT (whether guests may comment at all);
* this one describes what the CLIENT can do on the device in front of it. The backend has no
* stake in how bytes were captured — an upload from the camera and an upload from the gallery
* arrive on the same endpoint, indistinguishable — so putting the switch on the server would
* add a schema, a DTO field and a release of the app image to answer a question only the
* browser can ask.
*
* `$env/dynamic/public` is read at RUNTIME by adapter-node, so this is a compose variable and
* a restart, not a rebuild.
*/
/** Interpret a flag the same way `config.rs` does, so operators only learn one convention. */
function flag(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value.trim() === '') return fallback;
return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase());
}
/**
* Whether the in-app camera is offered (`PUBLIC_CAMERA_ENABLED`, default true).
*
* Turned off for events where `getUserMedia` misbehaves on the guests' actual phones —
* switching between front and back cameras throwing "Kamera konnte nicht gestartet werden",
* or video capture failing the permission prompt outright. Those failures are per-device and
* cannot be diagnosed mid-event, so the switch removes the broken path rather than leaving
* guests to discover it.
*
* Nothing is lost by disabling it: the gallery picker reaches the same OS camera through
* `capture`-less `<input type="file">`, handles video, and is the path most guests use anyway.
*/
export const cameraEnabled = flag(env.PUBLIC_CAMERA_ENABLED, true);

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
classifyUploadStatus, classifyUploadStatus,
isIncompleteBody,
isReversibleLock, isReversibleLock,
entryToQueueItem, entryToQueueItem,
shouldAbortForStall, shouldAbortForStall,
@@ -54,6 +55,59 @@ describe('classifyUploadStatus', () => {
* reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either * reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
* loses a photo the guest expected to survive a reopen, or lets a banned device retry forever. * loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
*/ */
/**
* Regression guard for the data loss this was written for: an iPhone guest on the live event
* got "Error parsing `multipart/form-data` request", the item went terminal, and the ONLY copy
* of the photo was purged from IndexedDB with no retry offered.
*
* The first attempt at the fix keyed on the envelope (`body.error !== 'bad_request'`) and was
* inert, because the backend wraps the multipart error in its own `bad_request` envelope. These
* cases are transcribed from real responses captured against the running backend, so they fail
* if that reasoning is ever reverted.
*/
describe('isIncompleteBody', () => {
const parseError = 'Error parsing `multipart/form-data` request';
it('the exact live failure: bad_request envelope carrying the parse error → incomplete', () => {
expect(isIncompleteBody(400, { error: 'bad_request', message: parseError, status: 400 })).toBe(
true
);
});
it('the same error raised mid-file, with the German prefix → incomplete', () => {
expect(
isIncompleteBody(400, {
error: 'bad_request',
message: `Datei konnte nicht gelesen werden: ${parseError}`,
status: 400
})
).toBe(true);
});
it('an unparseable body (plain-text rejection, proxy, WAF) → incomplete', () => {
expect(isIncompleteBody(400, null)).toBe(true);
expect(isIncompleteBody(400, undefined)).toBe(true);
});
it('a real verdict on the file → NOT incomplete, so it still purges', () => {
expect(
isIncompleteBody(400, { error: 'bad_request', message: 'Datei ist zu groß. Maximum: 500 MB.' })
).toBe(false);
expect(
isIncompleteBody(400, { error: 'bad_request', message: 'Keine Datei hochgeladen.' })
).toBe(false);
expect(
isIncompleteBody(400, { error: 'bad_request', message: 'Dateityp wird nicht unterstützt.' })
).toBe(false);
});
it('only applies to 400 — other statuses keep their own rules', () => {
expect(isIncompleteBody(413, { error: 'quota_exceeded' })).toBe(false);
expect(isIncompleteBody(403, null)).toBe(false);
expect(isIncompleteBody(500, null)).toBe(false);
});
});
describe('isReversibleLock', () => { describe('isReversibleLock', () => {
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => { it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
expect(isReversibleLock(403, 'uploads_locked')).toBe(true); expect(isReversibleLock(403, 'uploads_locked')).toBe(true);

View File

@@ -701,6 +701,48 @@ export function classifyUploadStatus(status: number): UploadOutcome {
return 'transient'; return 'transient';
} }
/**
* Within the `terminal` bucket, is this 400 a TRUNCATED REQUEST rather than a verdict on the
* file? Keep the blob and retry if so. Pure + exported for the same reason as
* `isReversibleLock`: it decides whether a guest keeps their photo.
*
* Keyed on the MESSAGE, not the envelope. The obvious rule — "an app-raised 400 carries
* `bad_request`, so a 400 without it is Axum's plain-text rejection" — does not hold, and was
* verified against the running backend rather than reasoned about:
*
* stream breaks between parts → 400 application/json
* {"error":"bad_request","message":"Error parsing `multipart/…"}
* stream breaks mid-file → 400 application/json, same code, message prefixed
* "Datei konnte nicht gelesen werden: …"
* no boundary in Content-Type → 400 text/plain "Invalid `boundary` for `multipart/…"
*
* Only the third is Axum's own extractor rejection. The first two — the ones a webview or a
* dropping mobile link actually produce — never reach it: the handler pulls the fields itself
* and wraps `MultipartError` in `AppError::BadRequest` (`upload.rs` field loop and chunk loop),
* so they arrive as an ordinary `bad_request` envelope, indistinguishable by code from "file too
* large" or "caption too long". An envelope check therefore never fires for the case this
* exists to catch. Confirmed live: the log line for a real guest failure and for a synthetic
* truncation are byte-identical.
*
* Retrying is safe: nothing was parsed, so nothing was stored and no quota was charged, and
* `X-Client-Upload-Id` makes a duplicate impossible even if the server did see it.
*
* The substring is Axum's `MultipartError` Display text and is therefore an UPSTREAM contract
* this file does not own — an axum upgrade could reword it and silently re-open the data loss.
* The durable fix is a distinct backend code (e.g. `body_incomplete`) that this can prefer once
* it exists; the match is kept as the fallback because it needs no app-image release.
*/
export function isIncompleteBody(status: number, body: unknown): boolean {
if (status !== 400) return false;
const envelope = body as { error?: unknown; message?: unknown } | null | undefined;
// An unparseable body (proxy, WAF, captive portal) cannot be a considered rejection either.
if (!envelope || envelope.error !== 'bad_request') return true;
return (
typeof envelope.message === 'string' &&
envelope.message.includes('Error parsing `multipart/form-data` request')
);
}
/** /**
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob, * Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
* park retryable for a host reopen) rather than a permanent rejection (purge the blob). * park retryable for a host reopen) rather than a permanent rejection (purge the blob).
@@ -1252,6 +1294,15 @@ async function uploadItem(id: string): Promise<void> {
); );
break; break;
case 'terminal': { case 'terminal': {
// A truncated body is a transport failure, not a verdict on the file, so it
// must not purge the blob. See `isIncompleteBody` for why the envelope alone
// cannot decide this.
if (isIncompleteBody(xhr.status, body)) {
settle(() =>
reject(new NetworkError('Übertragung unvollständig — bitte erneut versuchen'))
);
break;
}
// A REVERSIBLE lock (event closed / gallery released) is tagged // A REVERSIBLE lock (event closed / gallery released) is tagged
// `uploads_locked` by the backend — keep the blob and park it retryable so // `uploads_locked` by the backend — keep the blob and park it retryable so
// a host reopen resumes it, instead of purging it like a permanent 4xx. // a host reopen resumes it, instead of purging it like a permanent 4xx.