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.