Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd563b3ee | ||
|
|
0aaaa75128 | ||
|
|
0a33189e7e | ||
|
|
5a33ab460f |
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
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ services:
|
|||||||
COMPRESSION_WORKER_CONCURRENCY: '2'
|
COMPRESSION_WORKER_CONCURRENCY: '2'
|
||||||
# Production disables comments for this event (docker-compose.yml, product decision).
|
# Production disables comments for this event (docker-compose.yml, product decision).
|
||||||
COMMENTS_ENABLED: 'false'
|
COMMENTS_ENABLED: 'false'
|
||||||
|
# Mirrors the deployed setting so the harness can audit against the real gate.
|
||||||
|
KEEPSAKE_ENABLED: ${SIM_KEEPSAKE:-true}
|
||||||
# The ONE deviation from production: enables /admin/__truncate so the harness can
|
# The ONE deviation from production: enables /admin/__truncate so the harness can
|
||||||
# reset between runs. Never set on the real box.
|
# reset between runs. Never set on the real box.
|
||||||
EVENTSNAP_TEST_MODE: '1'
|
EVENTSNAP_TEST_MODE: '1'
|
||||||
@@ -102,7 +104,7 @@ services:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
# Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
|
# Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
|
||||||
image: registry.mc02.dev/eventsnap/frontend:v0.17.6
|
image: ${SIM_FE_IMAGE:-registry.mc02.dev/eventsnap/frontend:v0.17.6}
|
||||||
cpuset: *cpuset
|
cpuset: *cpuset
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
@@ -110,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:
|
||||||
|
|||||||
178
e2e/loadtest/acceptance-audit.mjs
Normal file
178
e2e/loadtest/acceptance-audit.mjs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Acceptance audit: push EVERY file in the pool through the real upload validator
|
||||||
|
* and report exactly what the server refuses, and why.
|
||||||
|
*
|
||||||
|
* Answers one question — "with these settings, which of my photos would be turned
|
||||||
|
* away?" — and answers it empirically rather than by reasoning about limits. Size
|
||||||
|
* caps are only one of the paths that can refuse a file: the magic-byte allowlist,
|
||||||
|
* the decode budget (12000 px axis / 256 MiB alloc, both code constants), the disk
|
||||||
|
* gate and the per-user quota all reject too, and only the running server knows the
|
||||||
|
* interaction between them.
|
||||||
|
*
|
||||||
|
* Deliberately NOT a load test: no personas, no viewers, no think-time. It measures
|
||||||
|
* admission, so it does not wait for the compression backlog to drain.
|
||||||
|
*
|
||||||
|
* SIM_UPLOADERS=8 node e2e/loadtest/acceptance-audit.mjs
|
||||||
|
*/
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
|
||||||
|
const API = `${BASE}/api/v1`;
|
||||||
|
const POOL = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool';
|
||||||
|
const META = process.env.SIM_POOL_META ?? '/tmp/eventsnap-pool.json';
|
||||||
|
const ADMIN_PW = process.env.SIM_ADMIN_PW ?? 'admin-test-pw';
|
||||||
|
const CONC = parseInt(process.env.AUDIT_CONC ?? '4', 10);
|
||||||
|
// Spread across a few accounts, as a real event does — a single uploader would hit
|
||||||
|
// the per-user quota and hourly limit for reasons unrelated to the files themselves.
|
||||||
|
const UPLOADERS = parseInt(process.env.SIM_UPLOADERS ?? '8', 10);
|
||||||
|
|
||||||
|
const j = async (path, opts = {}) => {
|
||||||
|
const res = await fetch(`${API}${path}`, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, body };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const meta = JSON.parse(await readFile(META, 'utf8'));
|
||||||
|
console.log(`[pool] ${meta.length} files, ${(meta.reduce((a, f) => a + f.bytes, 0) / 1e9).toFixed(2)} GB`);
|
||||||
|
|
||||||
|
const admin = (
|
||||||
|
await j('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: ADMIN_PW }),
|
||||||
|
})
|
||||||
|
).body.jwt;
|
||||||
|
|
||||||
|
const cfg = (await j('/admin/config', { headers: { Authorization: `Bearer ${admin}` } })).body;
|
||||||
|
const shown = [
|
||||||
|
'max_image_size_mb',
|
||||||
|
'max_video_size_mb',
|
||||||
|
'upload_rate_per_hour',
|
||||||
|
'storage_quota_enabled',
|
||||||
|
'quota_enabled',
|
||||||
|
];
|
||||||
|
console.log(`[config] ${shown.map((k) => `${k}=${cfg[k]}`).join(' ')}`);
|
||||||
|
const stats = (await j('/admin/stats', { headers: { Authorization: `Bearer ${admin}` } })).body;
|
||||||
|
console.log(
|
||||||
|
`[disk] ${(stats.disk_free_bytes / 1e9).toFixed(1)} GB free of ${(stats.disk_total_bytes / 1e9).toFixed(1)} GB`
|
||||||
|
);
|
||||||
|
|
||||||
|
const guests = [];
|
||||||
|
for (let i = 0; i < UPLOADERS; i++) {
|
||||||
|
const r = await j('/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: `Audit ${i} ${randomUUID().slice(0, 4)}` }),
|
||||||
|
});
|
||||||
|
guests.push(r.body.jwt);
|
||||||
|
}
|
||||||
|
console.log(`[join] ${guests.length} uploaders\n`);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let done = 0;
|
||||||
|
const queue = [...meta];
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const worker = async (slot) => {
|
||||||
|
while (queue.length) {
|
||||||
|
const f = queue.shift();
|
||||||
|
if (!f) break;
|
||||||
|
const jwt = guests[slot % guests.length];
|
||||||
|
let buf;
|
||||||
|
try {
|
||||||
|
buf = await readFile(join(POOL, f.name));
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ ...f, status: -1, msg: `read error: ${e}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([buf], { type: f.magic }), f.name);
|
||||||
|
form.append('client_upload_id', randomUUID());
|
||||||
|
let status, body;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
const t = await res.text();
|
||||||
|
try {
|
||||||
|
body = JSON.parse(t);
|
||||||
|
} catch {
|
||||||
|
body = t;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status = 0;
|
||||||
|
body = { message: String(e).slice(0, 80) };
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
name: f.name,
|
||||||
|
bytes: f.bytes,
|
||||||
|
magic: f.magic,
|
||||||
|
w: f.w,
|
||||||
|
h: f.h,
|
||||||
|
status,
|
||||||
|
code: body?.code ?? body?.error,
|
||||||
|
msg: status >= 400 ? String(body?.message ?? '').slice(0, 110) : undefined,
|
||||||
|
});
|
||||||
|
if (++done % 100 === 0) {
|
||||||
|
const ok = results.filter((r) => r.status === 201).length;
|
||||||
|
console.log(
|
||||||
|
` ${done}/${meta.length} accepted ${ok} refused ${done - ok} (${((Date.now() - t0) / 1000).toFixed(0)}s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: CONC }, (_, i) => worker(i)));
|
||||||
|
|
||||||
|
// ── Report ────────────────────────────────────────────────────────────────
|
||||||
|
const ok = results.filter((r) => r.status === 201);
|
||||||
|
const bad = results.filter((r) => r.status !== 201);
|
||||||
|
const byReason = {};
|
||||||
|
for (const r of bad) {
|
||||||
|
const key = `${r.status} ${r.msg ?? r.code ?? '?'}`;
|
||||||
|
(byReason[key] ??= []).push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '═'.repeat(74));
|
||||||
|
console.log('ACCEPTANCE AUDIT');
|
||||||
|
console.log('═'.repeat(74));
|
||||||
|
console.log(
|
||||||
|
`accepted ${ok.length}/${results.length} (${(ok.reduce((a, r) => a + r.bytes, 0) / 1e9).toFixed(2)} GB)`
|
||||||
|
);
|
||||||
|
console.log(`refused ${bad.length}\n`);
|
||||||
|
for (const [reason, rows] of Object.entries(byReason).sort((a, b) => b[1].length - a[1].length)) {
|
||||||
|
const sizes = rows.map((r) => r.bytes / 1024 / 1024);
|
||||||
|
console.log(` ${rows.length} x ${reason}`);
|
||||||
|
console.log(
|
||||||
|
` sizes ${Math.min(...sizes).toFixed(1)}–${Math.max(...sizes).toFixed(1)} MB · types ${[...new Set(rows.map((r) => r.magic))].join(', ')}`
|
||||||
|
);
|
||||||
|
console.log(` e.g. ${rows.slice(0, 3).map((r) => r.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
if (!bad.length) console.log(' ✓ nothing was refused');
|
||||||
|
|
||||||
|
const outDir = join(__dirname, 'results');
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
const out = join(outDir, `acceptance-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
||||||
|
await writeFile(out, JSON.stringify({ config: cfg, stats, results }, null, 2));
|
||||||
|
console.log(`\nfull detail → ${out}`);
|
||||||
|
console.log('═'.repeat(74));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('audit failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
@@ -41,8 +48,19 @@
|
|||||||
{
|
{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
icon: '👆',
|
icon: '👆',
|
||||||
title: 'Lange tippen für mehr',
|
title: 'Mehr zu einem Foto',
|
||||||
body: 'Tippe lange auf ein Bild im Feed, um zusätzliche Aktionen zu öffnen — zum Beispiel das Original anzeigen oder eigene Beiträge löschen.'
|
// Names the VISIBLE control first. Long-press was the only affordance mentioned here,
|
||||||
|
// and it is invisible — a guest has to already know it exists. The three-dot button
|
||||||
|
// is on every card in list view and is what most people will find.
|
||||||
|
//
|
||||||
|
// Both views are named on purpose: the button exists only in the LIST view
|
||||||
|
// (FeedListCard). The grid tiles have no button at all, only `use:longpress`, so a
|
||||||
|
// guest browsing in grid mode would be stranded by a menu-only instruction.
|
||||||
|
//
|
||||||
|
// "anzeigen und speichern", not "herunterladen": the action opens the original in a
|
||||||
|
// new tab (`Content-Disposition: inline`, deliberate — it is the only playable video
|
||||||
|
// source), so the guest saves it from there rather than getting a download.
|
||||||
|
body: 'Tippe oben rechts am Beitrag auf die drei Punkte (⋯), um weitere Aktionen zu öffnen: das Original in voller Auflösung anzeigen und speichern oder eigene Beiträge löschen. In der Kachel-Ansicht tippst du stattdessen lange auf ein Bild.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'theme',
|
kind: 'theme',
|
||||||
@@ -52,9 +70,18 @@
|
|||||||
{
|
{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
icon: '🔑',
|
icon: '🔑',
|
||||||
title: 'Deinen PIN merken!',
|
title: 'Name und PIN merken!',
|
||||||
|
// Recovery needs BOTH the display name and the PIN (`POST /recover` takes
|
||||||
|
// {display_name, pin}), but this step only ever mentioned the PIN — so a guest who
|
||||||
|
// memorised four digits and forgot whether they typed "Anna" or "Anna M." still
|
||||||
|
// could not get back in.
|
||||||
|
//
|
||||||
|
// "solange du auf diesem Gerät angemeldet bleibst" replaces "ist immer … zu finden",
|
||||||
|
// which was false in exactly the case that matters: /account renders the PIN from
|
||||||
|
// local storage and falls back to "PIN nicht gespeichert", so it is NOT there on a
|
||||||
|
// new device — which is the only reason anyone needs it.
|
||||||
body:
|
body:
|
||||||
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto“ zu finden.' +
|
'Beim Registrieren hast du einen 4-stelligen PIN bekommen. Für die Anmeldung auf einem anderen Gerät brauchst du beides: genau den Namen, den du hier eingegeben hast, und diesen PIN. Notiere dir am besten beides — solange du auf diesem Gerät angemeldet bleibst, findest du sie unter „Mein Konto“.' +
|
||||||
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
|
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -305,8 +315,14 @@
|
|||||||
line of text and is never wrong. It lives here rather than in the root layout
|
line of text and is never wrong. It lives here rather than in the root layout
|
||||||
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
|
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
|
||||||
off — a banner there would never render on the composer. -->
|
off — a banner there would never render on the composer. -->
|
||||||
|
<!-- "diese Seite", not "den Link": the guest is already inside the app when they read
|
||||||
|
this, so there is no link on screen for "den Link" to refer to. Naming the app
|
||||||
|
they most likely arrived from, and the menu entry that gets them out, turns a
|
||||||
|
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? Öffne den Link in Safari oder Chrome.
|
Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä.
|
||||||
|
Ö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>
|
</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.
|
||||||
|
|||||||
@@ -309,8 +309,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Normal join form -->
|
<!-- Normal join form -->
|
||||||
|
<!-- The article is HARDCODED to match this event's name ("Hochzeit von …"), so the
|
||||||
|
lead-in and the <h1> below read as one sentence: "Willkommen bei der Hochzeit
|
||||||
|
von …". That couples this string to EVENT_NAME's grammatical gender — it is
|
||||||
|
wrong for "Willkommen bei der Sommerfest". Deliberate for a single event; if
|
||||||
|
this app is reused, either make the article configurable alongside EVENT_NAME
|
||||||
|
or drop back to an article-free lead-in ("Herzlich willkommen!"), which works
|
||||||
|
with any name. The fallback below must keep agreeing with whatever is chosen. -->
|
||||||
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
Willkommen bei
|
Willkommen bei der
|
||||||
</p>
|
</p>
|
||||||
{#if eventName}
|
{#if eventName}
|
||||||
<h1
|
<h1
|
||||||
@@ -320,8 +327,10 @@
|
|||||||
{eventName}
|
{eventName}
|
||||||
</h1>
|
</h1>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- "Feier", not "dem Event": the lead-in above now carries the article, so the
|
||||||
|
old fallback would render "Willkommen bei der dem Event". -->
|
||||||
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
|
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
dem Event
|
Feier
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
|
||||||
|
|||||||
@@ -308,6 +308,18 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
class="input resize-none text-sm"
|
class="input resize-none text-sm"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<!-- Persistent helper, NOT a second placeholder line. Two reasons: a `placeholder`
|
||||||
|
attribute may not contain line breaks (Safari collapses them outright), and a
|
||||||
|
placeholder disappears the moment the guest starts typing — which is exactly when
|
||||||
|
they need to read what to write. This stays visible while they type.
|
||||||
|
The hashtag is written plain on purpose: `#fotoaufgabe3` would be a DIFFERENT tag
|
||||||
|
from `#fotoaufgabe`, so twelve tasks would produce twelve unfilterable tags. The
|
||||||
|
number belongs in the prose, the hashtag stays one word. -->
|
||||||
|
<p class="mt-1.5 text-xs leading-snug text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-gray-600 dark:text-gray-300">Fotoaufgabe?</span> Schreib
|
||||||
|
die Aufgabe dazu — die Nummer steht unten rechts auf dem Kärtchen — und setze den Hashtag
|
||||||
|
#fotoaufgabe.
|
||||||
|
</p>
|
||||||
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
|
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
|
||||||
{caption.length} / {MAX_CAPTION_LENGTH}
|
{caption.length} / {MAX_CAPTION_LENGTH}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user