Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b57f1728e | |||
|
|
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);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
classifyUploadStatus,
|
||||
isIncompleteBody,
|
||||
isReversibleLock,
|
||||
entryToQueueItem,
|
||||
shouldAbortForStall,
|
||||
@@ -54,6 +55,59 @@ describe('classifyUploadStatus', () => {
|
||||
* reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
|
||||
* loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
|
||||
*/
|
||||
/**
|
||||
* Regression guard for the data loss this was written for: an iPhone guest on the live event
|
||||
* got "Error parsing `multipart/form-data` request", the item went terminal, and the ONLY copy
|
||||
* of the photo was purged from IndexedDB with no retry offered.
|
||||
*
|
||||
* The first attempt at the fix keyed on the envelope (`body.error !== 'bad_request'`) and was
|
||||
* inert, because the backend wraps the multipart error in its own `bad_request` envelope. These
|
||||
* cases are transcribed from real responses captured against the running backend, so they fail
|
||||
* if that reasoning is ever reverted.
|
||||
*/
|
||||
describe('isIncompleteBody', () => {
|
||||
const parseError = 'Error parsing `multipart/form-data` request';
|
||||
|
||||
it('the exact live failure: bad_request envelope carrying the parse error → incomplete', () => {
|
||||
expect(isIncompleteBody(400, { error: 'bad_request', message: parseError, status: 400 })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('the same error raised mid-file, with the German prefix → incomplete', () => {
|
||||
expect(
|
||||
isIncompleteBody(400, {
|
||||
error: 'bad_request',
|
||||
message: `Datei konnte nicht gelesen werden: ${parseError}`,
|
||||
status: 400
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('an unparseable body (plain-text rejection, proxy, WAF) → incomplete', () => {
|
||||
expect(isIncompleteBody(400, null)).toBe(true);
|
||||
expect(isIncompleteBody(400, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('a real verdict on the file → NOT incomplete, so it still purges', () => {
|
||||
expect(
|
||||
isIncompleteBody(400, { error: 'bad_request', message: 'Datei ist zu groß. Maximum: 500 MB.' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isIncompleteBody(400, { error: 'bad_request', message: 'Keine Datei hochgeladen.' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isIncompleteBody(400, { error: 'bad_request', message: 'Dateityp wird nicht unterstützt.' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('only applies to 400 — other statuses keep their own rules', () => {
|
||||
expect(isIncompleteBody(413, { error: 'quota_exceeded' })).toBe(false);
|
||||
expect(isIncompleteBody(403, null)).toBe(false);
|
||||
expect(isIncompleteBody(500, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReversibleLock', () => {
|
||||
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
||||
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
||||
|
||||
@@ -701,6 +701,48 @@ export function classifyUploadStatus(status: number): UploadOutcome {
|
||||
return 'transient';
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the `terminal` bucket, is this 400 a TRUNCATED REQUEST rather than a verdict on the
|
||||
* file? Keep the blob and retry if so. Pure + exported for the same reason as
|
||||
* `isReversibleLock`: it decides whether a guest keeps their photo.
|
||||
*
|
||||
* Keyed on the MESSAGE, not the envelope. The obvious rule — "an app-raised 400 carries
|
||||
* `bad_request`, so a 400 without it is Axum's plain-text rejection" — does not hold, and was
|
||||
* verified against the running backend rather than reasoned about:
|
||||
*
|
||||
* stream breaks between parts → 400 application/json
|
||||
* {"error":"bad_request","message":"Error parsing `multipart/…"}
|
||||
* stream breaks mid-file → 400 application/json, same code, message prefixed
|
||||
* "Datei konnte nicht gelesen werden: …"
|
||||
* no boundary in Content-Type → 400 text/plain "Invalid `boundary` for `multipart/…"
|
||||
*
|
||||
* Only the third is Axum's own extractor rejection. The first two — the ones a webview or a
|
||||
* dropping mobile link actually produce — never reach it: the handler pulls the fields itself
|
||||
* and wraps `MultipartError` in `AppError::BadRequest` (`upload.rs` field loop and chunk loop),
|
||||
* so they arrive as an ordinary `bad_request` envelope, indistinguishable by code from "file too
|
||||
* large" or "caption too long". An envelope check therefore never fires for the case this
|
||||
* exists to catch. Confirmed live: the log line for a real guest failure and for a synthetic
|
||||
* truncation are byte-identical.
|
||||
*
|
||||
* Retrying is safe: 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.
|
||||
*
|
||||
* The substring is Axum's `MultipartError` Display text and is therefore an UPSTREAM contract
|
||||
* this file does not own — an axum upgrade could reword it and silently re-open the data loss.
|
||||
* The durable fix is a distinct backend code (e.g. `body_incomplete`) that this can prefer once
|
||||
* it exists; the match is kept as the fallback because it needs no app-image release.
|
||||
*/
|
||||
export function isIncompleteBody(status: number, body: unknown): boolean {
|
||||
if (status !== 400) return false;
|
||||
const envelope = body as { error?: unknown; message?: unknown } | null | undefined;
|
||||
// An unparseable body (proxy, WAF, captive portal) cannot be a considered rejection either.
|
||||
if (!envelope || envelope.error !== 'bad_request') return true;
|
||||
return (
|
||||
typeof envelope.message === 'string' &&
|
||||
envelope.message.includes('Error parsing `multipart/form-data` request')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
|
||||
* park retryable for a host reopen) rather than a permanent rejection (purge the blob).
|
||||
@@ -1252,6 +1294,15 @@ async function uploadItem(id: string): Promise<void> {
|
||||
);
|
||||
break;
|
||||
case 'terminal': {
|
||||
// A truncated body is a transport failure, not a verdict on the file, so it
|
||||
// must not purge the blob. See `isIncompleteBody` for why the envelope alone
|
||||
// cannot decide this.
|
||||
if (isIncompleteBody(xhr.status, body)) {
|
||||
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