Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd563b3ee |
15
.env.example
15
.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Camera (rendered outside sheet so it gets full viewport) -->
|
||||
{#if showCamera}
|
||||
<!-- Camera (rendered outside sheet so it gets full viewport).
|
||||
`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
|
||||
oncapture={handleCapture}
|
||||
onclose={handleCameraClose}
|
||||
@@ -262,7 +267,11 @@
|
||||
</div>
|
||||
</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
|
||||
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"
|
||||
@@ -294,6 +303,7 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- The in-app-browser escape hatch.
|
||||
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. -->
|
||||
<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. Ä.
|
||||
Ö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“.)
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
36
frontend/src/lib/feature-flags.ts
Normal file
36
frontend/src/lib/feature-flags.ts
Normal 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);
|
||||
@@ -1252,6 +1252,28 @@ async function uploadItem(id: string): Promise<void> {
|
||||
);
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user