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}
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