From 2dd563b3eefa8b7756bf4d8c1bdc9f357985ac4f Mon Sep 17 00:00:00 2001
From: MechaCat02
Date: Sat, 22 Aug 2026 09:30:59 +0200
Subject: [PATCH] fix(upload): a truncated body no longer destroys the guest's
photo, and the in-app camera can be switched off
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.env.example | 15 ++++++++
docker-compose.yml | 8 +++++
e2e/docker-compose.sim.yml | 1 +
.../src/lib/components/OnboardingGuide.svelte | 9 ++++-
.../src/lib/components/UploadSheet.svelte | 20 ++++++++---
frontend/src/lib/feature-flags.ts | 36 +++++++++++++++++++
frontend/src/lib/upload-queue.ts | 22 ++++++++++++
7 files changed, 105 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/lib/feature-flags.ts
diff --git a/.env.example b/.env.example
index e810f08..cfcd7bc 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
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 ───────────────────────────────────────────────────────────────────
# 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
diff --git a/docker-compose.yml b/docker-compose.yml
index 2d77138..ebf03b0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -226,6 +226,14 @@ services:
# 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.
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:
# 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
diff --git a/e2e/docker-compose.sim.yml b/e2e/docker-compose.sim.yml
index 940182e..a878035 100644
--- a/e2e/docker-compose.sim.yml
+++ b/e2e/docker-compose.sim.yml
@@ -112,6 +112,7 @@ services:
PORT: '3001'
HOST: '0.0.0.0'
ORIGIN: 'http://localhost:3102'
+ PUBLIC_CAMERA_ENABLED: ${SIM_CAMERA:-true}
deploy:
resources:
limits:
diff --git a/frontend/src/lib/components/OnboardingGuide.svelte b/frontend/src/lib/components/OnboardingGuide.svelte
index 9e0442a..0393ba6 100644
--- a/frontend/src/lib/components/OnboardingGuide.svelte
+++ b/frontend/src/lib/components/OnboardingGuide.svelte
@@ -6,6 +6,7 @@
import { scrollLock } from '$lib/actions/scroll-lock';
import { vibrate } from '$lib/haptics';
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
+ import { cameraEnabled } from '$lib/feature-flags';
type Step =
| { kind: 'text'; icon: string; title: string; body: string }
@@ -30,7 +31,13 @@
kind: 'text',
icon: '⬆️',
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',
diff --git a/frontend/src/lib/components/UploadSheet.svelte b/frontend/src/lib/components/UploadSheet.svelte
index b5c8b79..982a8a5 100644
--- a/frontend/src/lib/components/UploadSheet.svelte
+++ b/frontend/src/lib/components/UploadSheet.svelte
@@ -8,6 +8,7 @@
import type { PendingFile } from '$lib/pending-upload-store';
import { eventState, uploadsClosed } from '$lib/event-state-store';
import { commentsEnabled } from '$lib/event-config-store';
+ import { cameraEnabled } from '$lib/feature-flags';
import { isBanned } from '$lib/ban-store';
// A ban closes uploads just as hard as an event lock does — the backend refuses every
@@ -150,8 +151,12 @@
}
-
-{#if showCamera}
+
+{#if showCamera && cameraEnabled}
-
+
+ {#if cameraEnabled}
+ {/if}
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ü
- des In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
+ Öffne diese Seite in Safari oder Chrome — dort funktioniert die Auswahl. (Im Menü des
+ In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
{/if}
diff --git a/frontend/src/lib/feature-flags.ts b/frontend/src/lib/feature-flags.ts
new file mode 100644
index 0000000..34bb004
--- /dev/null
+++ b/frontend/src/lib/feature-flags.ts
@@ -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 ``, handles video, and is the path most guests use anyway.
+ */
+export const cameraEnabled = flag(env.PUBLIC_CAMERA_ENABLED, true);
diff --git a/frontend/src/lib/upload-queue.ts b/frontend/src/lib/upload-queue.ts
index db0be38..565756e 100644
--- a/frontend/src/lib/upload-queue.ts
+++ b/frontend/src/lib/upload-queue.ts
@@ -1252,6 +1252,28 @@ async function uploadItem(id: string): Promise {
);
break;
case 'terminal': {
+ // A 400 the APP raised always carries `bad_request` in a JSON envelope
+ // (too large, wrong type, caption too long, NUL byte). Axum's own multipart
+ // rejection does not: it is a PLAIN-TEXT 400 ("Error parsing
+ // `multipart/form-data` request"), so `body` is null here.
+ //
+ // That distinction decides whether a guest keeps their photo. An
+ // unparseable 400 means the request body never arrived intact — a transport
+ // failure, not a verdict on the file — and it is exactly what an iOS in-app
+ // browser (WhatsApp) produces when it truncates an XHR upload. Classified as
+ // terminal, it purged the blob from IndexedDB and offered no retry, so a
+ // webview hiccup destroyed the only copy the guest had.
+ //
+ // Same reasoning the 403 rule below already applies to an unparseable body,
+ // and safe to retry: 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.
+ if (xhr.status === 400 && body?.error !== 'bad_request') {
+ settle(() =>
+ reject(new NetworkError('Übertragung unvollständig — bitte erneut versuchen'))
+ );
+ break;
+ }
// A REVERSIBLE lock (event closed / gallery released) is tagged
// `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.