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.
|
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
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;
|
break;
|
||||||
case 'terminal': {
|
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
|
// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user