14 Commits

Author SHA1 Message Date
MechaCat02
164c7d2aa3 test(upload): prove the stored bytes are the guest's bytes, not just that a 201 came back
Some checks failed
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m25s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m1s
Checks / E2E — typecheck + lint (push) Failing after 52s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m35s
E2E / Cross-UA smoke matrix (push) Failing after 4m17s
Audit / cargo audit (backend) (push) Failing after 9m0s
Audit / npm audit (frontend) (push) Successful in 1m28s
v0.18.3 through v0.18.6 were one failure in four disguises: the browser handing
the queue something that was not the file. An empty body first (WebKit stores a
picked `File` as a reference to an OS file iOS then deletes), and once the bytes
were copied, the risk of a SHORT one — that copy is a multi-second chunked loop
for a video, and the same purge can land partway through it.

Every check that missed them shared one shortcut: asserting the upload was
ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size
cap and the decode budget, is stored, gets a preview, appears in the gallery —
and is still not the guest's video. Acceptance was never the question.

So this drives the real composer through the real file chooser, then fetches the
stored original back and compares its SHA-256 to the file on disk. It also names
the two copy paths, because they are different code and only one of them can
produce a short blob: at or below 4 MB a single `arrayBuffer()`, above it the
chunked loop. A run with no file over 4 MB says so rather than reporting a pass
it did not earn.

Replaces the two throwaway scripts these hotfixes were verified with. Reports
which entries the upload sheet offers, so a run also records whether
PUBLIC_CAMERA_ENABLED was in effect.

Verified against v0.18.6: 32 KB single-read and 14.8 MB chunked, both
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:09:21 +02:00
c8795ddfac fix(upload): verify the copied bytes, so a purge mid-read cannot store a short file
Some checks failed
Audit / cargo audit (backend) (push) Failing after 8m43s
Audit / npm audit (frontend) (push) Successful in 42s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m7s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m35s
Checks / E2E — typecheck + lint (push) Failing after 32s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m46s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m4s
E2E / Cross-UA smoke matrix (push) Failing after 4m55s
v0.18.5 copies a picked file's bytes into a Blob the origin owns, because WebKit
stores a `File` as a reference to an OS file that iOS later deletes. For a photo
that is one `arrayBuffer()` and effectively atomic. For a video it is a 4 MB-at-
a-time loop over as much as 500 MB, which takes many seconds -- and the purge
that motivated the whole function can land in the MIDDLE of it.

Once the OS file is gone the remaining slices read as nothing, `new Blob` builds
a short blob out of what it got, and nothing notices. That is strictly worse than
the bug it replaces: an empty body is refused with a 400, but a short body
uploads, passes the server's checks, and leaves the guest with a truncated video
that appears to have worked. Silent corruption beats loud failure only in the
sense that nobody finds out until the album is the only copy left.

So the copy is now checked against `file.size` and a short read is refused. It is
raised at PICK time, while the guest still has the file in front of them and can
select it again -- not at send time, minutes later, when the moment has passed.

`addToQueue` returns 'unreadable' rather than throwing, so the composer's
per-file loop survives it: one bad photo out of five must not abandon the other
four. The composer names the file in the toast instead of counting it the way it
counts 'full', because the guest has to locate and re-pick that specific one.

Scope: only files above the 4 MB chunk threshold can hit the partial case, so
this does not touch the photo path that v0.18.5 fixed. It closes the hole that
fix opened for videos.
2026-08-22 16:05:22 +00:00
0fba8defc2 fix(upload): iPhone sent an EMPTY body -- the queue stored a file reference, not bytes
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m1s
Audit / npm audit (frontend) (push) Successful in 1m10s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 55s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
Measured at the reverse proxy during the live event, not inferred:

  status=201 content_length=6449056 dur=3.473s dev=Android
  status=400 content_length=0       dur=0.022s dev=iPhone
  status=400 content_length=0       dur=0.008s dev=iPhone
  status=400 content_length=0       dur=0.009s dev=iPhone

Content-Length ZERO, in 7-22 ms. Nothing was ever put on the wire, which is why
this was never a transport problem -- HTTP/3 was disabled first on the theory
that QUIC was truncating large POSTs, and it changed nothing.

`addToQueue` stored the picked `File` itself: `blob: file`. WebKit persists a
File in IndexedDB as a REFERENCE to the OS backing file rather than a copy, and
iOS deletes that file soon after the picker closes. What is left is a neutered
File: `.name` and `.size` still read correctly, so nothing downstream looks
wrong, and `xhr.send()` does NOT throw -- the note at the send site assumed it
would -- it sends an empty body. The server cannot parse a multipart with no
parts and answers 400 "Error parsing `multipart/form-data` request", which is
the same 400 a genuinely truncated upload produces.

That collision is what made v0.18.4 make things worse: it reclassified that 400
as retryable, so each dead photo re-sent an empty body five times. 79 failed
requests, 1 success, four guests with nothing uploaded.

TWO FIXES.

Bytes are copied at pick time, so IndexedDB owns data no OS purge can reach.
Chunked at 4 MB rather than one `arrayBuffer()`: this queue accepts videos up to
500 MB and pulling that into the JS heap would get the tab killed, trading a
failed upload for a crash. Each chunk becomes its own Blob, so peak heap is one
chunk and the browser's blob store holds the rest, spilling to disk as it sees
fit.

An unreadable blob is TERMINAL, never retried. `entry.blob.size` cannot detect
it -- a neutered File reports the original size -- so the check probes one real
byte before send. Items queued before this fix are still sitting in IndexedDB
holding dead references, and this is what stops them retrying forever. The
message asks the guest to re-pick the photo, because the photo is fine: it is
still in the camera roll, only the browser's copy is gone.

The batch keeps draining past one of these: other queued photos may be readable.
2026-08-22 15:54:06 +00:00
2b57f1728e fix(upload): the truncation guard never fired -- it checked the wrong field
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m29s
Audit / npm audit (frontend) (push) Successful in 59s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 59s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m40s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m18s
Checks / E2E — typecheck + lint (push) Failing after 37s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m55s
E2E / Cross-UA smoke matrix (push) Failing after 4m2s
v0.18.3 added a 400 branch to keep a guest's photo when the request body arrives
truncated, gated on `body.error !== 'bad_request'`. Its premise was that axum's
multipart rejection is a plain-text 400 with no envelope, so an envelope with
`bad_request` in it must be a considered verdict on the file.

That is false for the path that actually fails, and the branch was inert against
the exact incident it was written for. A guest on the live event lost a photo at
07:22; replaying the same truncation against the running backend at 07:50
produced a byte-identical log line:

  WARN request rejected status=400 code="bad_request"
       detail="Error parsing `multipart/form-data` request"

and this response body:

  400 application/json
  {"error":"bad_request","message":"Error parsing `multipart/form-data` request"}

A `bad_request` envelope. The guard evaluates false, the item still goes
terminal, and the blob is still purged.

The handler never reaches axum's extractor rejection: it pulls the fields itself
and wraps `MultipartError` in `AppError::BadRequest` -- bare from the field loop,
prefixed with "Datei konnte nicht gelesen werden: " from the chunk loop. Both
arrive indistinguishable BY CODE from "file too large". Axum's own plain-text
rejection does exist (no boundary in Content-Type -> "Invalid `boundary` ...")
but is a different error and not one a webview produces.

So the rule keys on the MESSAGE, and moves into an exported `isIncompleteBody`
beside `isReversibleLock`, matching how the other data-loss-critical rules in
this file are made testable. Tests transcribe the four responses captured from
the running backend, so reverting to an envelope check fails them.

Matching an upstream Display string is the weakness here and is called out in
the doc comment: an axum upgrade could reword it and silently re-open the data
loss. The durable fix is a distinct backend code (`body_incomplete`) this can
prefer once it exists -- deliberately not done now, because it means an app image
release and the backend has not needed one since v0.18.0.
2026-08-22 08:13:40 +00:00
MechaCat02
2dd563b3ee fix(upload): a truncated body no longer destroys the guest's photo, and the in-app camera can be switched off
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m31s
Audit / npm audit (frontend) (push) Successful in 1m2s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m7s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m37s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m13s
Checks / E2E — typecheck + lint (push) Failing after 36s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m8s
E2E / Cross-UA smoke matrix (push) Failing after 4m17s
Two event-day failures, both frontend-only.

TRUNCATED UPLOADS PURGED THE PHOTO. An iPhone guest uploading from the gallery
inside WhatsApp's browser got "Error parsing `multipart/form-data` request" and
the item went to "Gesperrt" with no retry. That message is AXUM's own multipart
rejection — a plain-text 400, no JSON envelope — which means the request body
never arrived intact. It is a transport failure, not a verdict on the file.

`classifyUploadStatus` maps every 4xx to `terminal`, and terminal PURGES the blob
from IndexedDB and offers no retry. So a webview hiccup deleted the only copy the
guest had, and told them the photo was rejected.

Every 400 the app itself raises carries `bad_request` in a JSON envelope (too
large, wrong type, caption too long, NUL byte), so an unparseable 400 is
distinguishable and is now a NetworkError: blob kept, retry offered. This is the
same rule the 403 branch already applies — "an unparseable body must NOT purge
the blob, losing a photo is the worst outcome" — extended to the status that was
actually hit. Retrying is safe because nothing was parsed, so nothing was stored
and no quota was charged, and `X-Client-Upload-Id` makes a duplicate impossible.

IN-APP CAMERA SWITCH. `PUBLIC_CAMERA_ENABLED=false` removes the "Kamera — Jetzt
aufnehmen" entry from the upload sheet. On some phones `getUserMedia` fails when
switching front/back ("Kamera konnte nicht gestartet werden") or when asked for
video, and those failures are per-device and undiagnosable mid-event; the switch
removes the broken path rather than leaving guests to find it. Nothing is lost:
the gallery picker reaches the phone's own camera app and handles video.

Read at RUNTIME via `$env/dynamic/public`, so flipping it is a compose variable
and `up -d frontend`, not a rebuild. Deliberately NOT routed through the
backend's event payload like `comments_enabled`: that flag describes the event,
this one describes what the client can do — the backend cannot tell a camera
upload from a gallery upload and has no stake in it. Keeping it off the app image
also means no backend release on the day of the event.

The onboarding step and the in-app-browser hint drop their camera wording when it
is off, so no text promises a button that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 09:30:59 +02:00
MechaCat02
0aaaa75128 fix(copy): the task cards are handed out, not left on the table
Some checks failed
Audit / cargo audit (backend) (push) Failing after 8m38s
Audit / npm audit (frontend) (push) Successful in 1m16s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 35s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m47s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m1s
Checks / E2E — typecheck + lint (push) Failing after 57s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m31s
E2E / Cross-UA smoke matrix (push) Failing after 4m58s
"auf dem Kärtchen am Tisch" told guests to look somewhere the card is not. Each
guest gets their own, so the location is both wrong and unnecessary — the card
is already in their hand when they read this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:58:07 +02:00
MechaCat02
0a33189e7e test(loadtest): audit which real files the upload validator actually refuses
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m9s
Audit / npm audit (frontend) (push) Successful in 41s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m11s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
"Will my photos be accepted?" was being answered by reasoning about the size
caps, which is the wrong method — size is only one of the paths that refuses a
file. The magic-byte allowlist, the decode budget (12000 px axis / 256 MiB
alloc, both code constants no setting can relax), the disk gate and the per-user
quota all reject too, and only the running server knows how they interact.

`acceptance-audit.mjs` pushes every file in the pool through the real endpoint
and groups the refusals by the server's own German message. Against the 945-file
wedding set with the raised limits it answered the question exactly: 935
accepted, and the only 10 refusals are the HEIC files.

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.

The sim stack gains two overridable vars, so the harness can be pointed at the
configuration actually deployed rather than the defaults: SIM_APP_IMAGE /
SIM_FE_IMAGE (audit a release image, not a local build) and KEEPSAKE_ENABLED,
which changes the disk gate and therefore changes what gets refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:42:28 +02:00
MechaCat02
5a33ab460f fix(copy): five texts that told guests the wrong thing
None of these are cosmetic — each one either misdescribes the UI or omits
something a guest needs to get back into their account.

JOIN. "Willkommen bei" + "<Event>" read as "Willkommen bei Hochzeit von …".
Adds the article. This deliberately couples the lead-in to EVENT_NAME's
grammatical gender and is wrong for "Willkommen bei der Sommerfest" — noted in
the markup, with the article-free alternative, for whoever reuses this. The
no-name fallback moves from "dem Event" to "Feier", or it would now render
"Willkommen bei der dem Event".

GUIDE / post actions. Named only the long-press, which is invisible: a guest has
to already know it exists to find it. Now names the three-dot button that is on
every card in list view — described as "die drei Punkte (⋯)" rather than a
glyph, because the icon renders HORIZONTALLY (three circles at cy=12) despite
the code comment calling it a kebab. Long-press stays in the text, because the
button exists ONLY in the list view: the grid tiles have `use:longpress` and no
button at all, so a menu-only instruction would strand anyone browsing in grid
mode. "anzeigen und speichern", not "herunterladen" — the action opens the
original inline in a new tab (Content-Disposition: inline, deliberate, it is the
only playable video source), so the guest saves it from there.

GUIDE / recovery. Said "Deinen PIN merken!", but `POST /recover` takes
{display_name, pin} — a guest who memorised four digits and forgot whether they
typed "Anna" or "Anna M." still cannot get in. Now names both. It also claimed
the PIN is "immer unter Mein Konto zu finden", which is false in the only case
that matters: /account renders it from local storage and falls back to "PIN
nicht gespeichert", so it is NOT on the new device you are trying to reach.

UPLOAD / photo-task game. Adds a persistent helper line under the caption box
rather than a second placeholder line: a `placeholder` attribute may not contain
line breaks (Safari collapses them), and a placeholder disappears exactly when
the guest starts typing and needs it. The hashtag is written plain on purpose —
`#fotoaufgabe3` is a DIFFERENT tag from `#fotoaufgabe`, so putting the number
inside it would turn twelve tasks into twelve unfilterable tags.

UPLOAD SHEET. "Öffne den Link in Safari oder Chrome" named a link that is not on
screen — the guest is already inside the app. Now names the situation (the
WhatsApp in-app browser the join link opens in) and the menu entry that escapes
it. Kept rather than removed: in that webview the camera and file picker can
silently do nothing, with no error and no operator to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:42:13 +02:00
MechaCat02
e3159299c0 test(loadtest): an event simulation on a real 2 vCPU / 4 GB / 30 GB box
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m2s
Audit / npm audit (frontend) (push) Successful in 51s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 52s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m5s
Checks / E2E — typecheck + lint (push) Failing after 39s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m13s
E2E / Cross-UA smoke matrix (push) Failing after 4m20s
`driver.mjs` is a pipeline benchmark: synthetic images, uniform load, rate limits
off, and — the part that mattered — an unconstrained host, so the 1 GB app cap
was never exercised and the disk gate never fired. It could not have found
either of the two defects fixed in the preceding commits.

This harness differs in three ways that earn their keep:

REAL CONTENT. Uploads come from a pool of actual wedding photos and videos,
unedited, including the HEIC files and 25 MB frames the app is supposed to
REFUSE. Those refusals are the test, not noise to filter out — 152 of 932
attempts were refused, and the breakdown of WHY is the most actionable output.

PERSONAS. ~100 viewers and ~50 uploaders across nine behaviour profiles, six
device profiles, each with a join time and a session length. A casual guest who
posts four photos generates a completely different request mix than a
photographer dumping 130, and both differ from a kiosk holding one SSE stream all
night. A 37-case abuse suite covers malicious payloads, injection, cross-user
tampering, enumeration and the rate limiters.

RATE LIMITS STAY ON. `driver.mjs` disabled them because it ran every guest from
one IP. Almost every limit that matters is per USER, not per IP, and those are as
real for 150 synthetic sessions as for 150 phones — leaving them on is what lets
the abuse personas prove the defences work. The per-IP limits ARE distorted by
the single source address; that distortion is measured and reported rather than
configured away.

`docker-compose.sim.yml` reproduces the CX22 rather than asserting it: production's
per-service cpus/memory/cpu_shares verbatim, every container pinned to the SAME two
cores with `cpuset` so they genuinely contend, and a real loopback ext4 volume so
the app's own statvfs returns true numbers. Run the driver under `taskset` onto
other cores, or the load generator competes with the thing it measures.

`browser-check.mjs` covers what an HTTP driver structurally cannot: the SvelteKit
container, and whether the frontend ESCAPES the XSS caption the backend stores
verbatim. The backend stores captions raw by design, so the renderer is the entire
defence — and only a browser can prove the payload is inert. It reports
INCONCLUSIVE rather than PASS when the payload never reached the DOM, because a
check that renders nothing proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:58:17 +02:00
MechaCat02
464b270716 feat(disk): KEEPSAKE_ENABLED trades the archive for ~3x the usable media
The upload gate refuses any photo that would leave too little room to build the
keepsake, and the archive needs both halves at once — so it demands
`2.2 x media + 10 GB reserve + 1.5 GB headroom`. Every gigabyte of photos
therefore costs about 3.2 GB of disk budget, and usable media works out at
`(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB of photos.

Measured, not theorised: a 945-photo wedding (8.74 GB of real files) stopped
after ~780 photos with 24.98 GB free — the gate wanted 25.91 GB on a disk that
was 79% empty. Uploads ended for want of an archive nobody had asked for yet.

`KEEPSAKE_ENABLED=false` drops the archive term, leaving only `DISK_RESERVE_BYTES`
— the one question still live without an archive, which is whether Postgres can
still write. That turns the same 30 GB volume into ~20 GB of usable media.
Verified against the exact disk state that ended the run: the photo that returned
413 with the keepsake armed is accepted with it off.

The headroom term goes with it, deliberately. It exists only to keep this gate
strictly ahead of the EXPORT PREFLIGHT, and with no keepsake there is no
preflight to stay ahead of.

Boot-time immutable, like COMMENTS_ENABLED: flipping it mid-event would move the
gate under uploads already accepted against the old one. Releasing the gallery
and minting download tickets are refused while it is off, and the host dashboard
hides the release control rather than offering a button that answers 403 — a new
`keepsake_enabled` field on `GET /host/event` carries that.

The gate and the host's low-disk banner now share `upload_gate_required_free`.
They were already two expressions of one threshold, and the banner exists to fire
BEFORE the gate closes; applying the switch to only one of them would warn the
host about a limit that can no longer fire, or stay silent past one that can.
Tests pin the lead in both modes.

The cost is real and worth stating plainly: there is no downloadable gallery at
the end, so guests keep only what they save from the feed. Prefer a bigger disk
where you can — ~45 GB holds this library with the keepsake intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:57:25 +02:00
MechaCat02
cacf616c2d fix(upload): a client vanishing mid-commit lost the photo's preview forever
The compression job is handed to the worker by a bare `tokio::spawn` that runs
AFTER `tx.commit()`, and the commit is a suspension point. If the guest walks
out of range inside it, Postgres applies the COMMIT and axum drops the future
before the spawn is reached: the row lands durably at
`compression_status = 'pending'` with no job behind it.

Nothing looked at it again. `startup_recovery` rescues only `'processing'`, and
`backfill_stale_derivatives` — whose predicate WOULD match — runs once at boot
and never on a timer. The photo keeps its feed entry, never gets a derivative,
forces every viewer to pull the full original instead, and is skipped by the
diashow for the rest of the event. Silent, and permanent.

This is the same hazard `TempFileGuard` already covers for the file, reached one
line later, so it gets the same answer: an `EnqueueGuard` armed before the commit
and stood down only once the job is with the worker. `Drop` runs on cancellation,
which closes the window — and it covers the indeterminate-commit error path too,
which returns Err on a row that may well be live. Queueing a job for a row that
did not commit is harmless: the status update matches zero rows and the task
retires.

A guard cannot survive a SIGKILL, so `requeue_stuck_pending` sweeps for the same
state at boot and every ten minutes. The grace window is what makes it safe to
re-enter the live path: a task flips the row to `'processing'` as its FIRST act
after taking a permit, so a row still `'pending'` ten minutes on is lost rather
than merely queued. The event simulation peaked at 120 queued with a p99 of 97s
while being fed thirty times a real event's arrival rate, so the margin is wide.

Hit 1 upload in 932 during that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:57:02 +02:00
MechaCat02
94d279fa69 fix(upload): a NUL byte in a caption returned 500 and cost the guest the photo
Postgres refuses a NUL in a TEXT column outright — `invalid byte sequence for
encoding "UTF8": 0x00` — and that arrived here as an anonymous `sqlx::Error`,
became `AppError::Internal`, and returned a 500. The photo went with it: the
transaction rolls back with the file already streamed to disk, so the guest
loses the upload as well as the caption, with nothing in the message telling
them why.

Only `display_name` was validated for control characters; `caption`, the hashtag
CSV and `comment.body` were length-checked alone.

Rejected at the edge with a 400 instead, and deliberately narrower than
`validate_display_name`: a caption legitimately carries newlines and emoji, and
every control character except NUL stores and renders harmlessly, so this
refuses exactly the byte that cannot work. Rejected rather than stripped —
silently rewriting what a guest wrote is the worse failure, and no real client
emits a NUL by accident.

Found by the event simulation's abuse suite; it was the only input of 37 that
produced a 5xx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:56:45 +02:00
MechaCat02
a7d2df6e9e fix(icon): tilt the rings so the right band sits above the left
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m4s
Audit / npm audit (frontend) (push) Successful in 51s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m0s
Checks / Frontend — vitest + svelte-check (push) Successful in 10m21s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m40s
E2E / Cross-UA smoke matrix (push) Failing after 3m54s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 4m54s
Checks / E2E — typecheck + lint (push) Successful in 40s
Rotated -45 degrees about the icon's centre. Negative because SVG's y axis points down, so
a positive angle would have dropped the right band instead of lifting it.

The transform sits on the group rather than on the individual shapes. The two circles and
the arc that redraws the upper crossing are all still authored on the horizontal axis and
rotate together, so the interlock geometry did not have to be recomputed and the bands
still alternate -- one over at the first crossing, the other over at the second.

Re-checked the maskable safe zone, because the bounds recorded for the horizontal artwork
no longer describe it: the box is now 102.9-409.1 on both axes, not x 88-424 and y 139-373.
The circular crop needed no arithmetic at all -- the farthest painted point stays 168 from
the centre (the 51 centre offset plus radius 104 plus half the 26 stroke), inside the 204.8
safe radius, since rotating about that same centre cannot change any point's distance
from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:11:33 +02:00
da2d4f67e7 fix(icon): wedding rings, and delete the skeleton favicon behind them
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m40s
E2E / Cross-UA smoke matrix (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
Audit / npm audit (frontend) (push) Has been cancelled
Checks / Backend — cargo test + clippy + fmt (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
The event is a wedding, so the camera icon becomes two interlocking bands. Drawn rather
than sourced: at 16px in a tab anything finer than the stroke turns to smudge, which is
why the camera it replaces was three shapes and no more. White on #8a6a2b, the event's
own theme_primary, so the icon, the site and the PWA chrome finally agree -- the
manifest's theme_color was still the #2563eb from the camera branding.

The rings span x 88-424 and y 139-373 including stroke, inside the centre-80% safe zone,
so an Android circle crop cannot clip them.

Also removes a second, competing icon declaration. `+layout.svelte` imported
`$lib/assets/favicon.svg` -- the orange Svelte logo from the project skeleton, which Vite
inlined as a data URI into the layout bundle -- and applied it via `<svelte:head>`. Which
of the two won was left to the browser: the link in app.html is parsed from the initial
HTML, this one is added at hydration, and Chrome keeps the former. So the skeleton logo
did not usually show, but nothing guaranteed that, and `ssr = false` means the branded
link is the only one present at first paint anyway. app.html is now the single source and
the skeleton asset is gone.
2026-08-18 18:03:31 +00:00
29 changed files with 3170 additions and 70 deletions

View File

@@ -207,6 +207,45 @@ COMPRESSION_WORKER_CONCURRENCY=2
# production. Remove that line from the compose file first if you want comments back.
COMMENTS_ENABLED=true
# ── Keepsake archive (and the disk ceiling it imposes) ────────────────────────
# Master switch for the downloadable gallery. Boot-time only, like COMMENTS_ENABLED.
#
# This is really a DISK-SIZING knob. The keepsake needs room for both halves at once (a ZIP
# and an HTML viewer, each gallery-sized), and the upload gate refuses any photo that would
# leave too little space to build it. The gate demands:
#
# free >= 2.2 x media + DISK_RESERVE (10 GB) + UPLOAD_GATE_HEADROOM (1.5 GB)
#
# so usable media is roughly `(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB
# of photos — uploads stop with most of the disk still free. Measured: a 945-photo wedding
# (8.74 GB) hit the wall after ~780 photos with 25 GB free.
#
# KEEPSAKE_ENABLED=true (default) archive available; ~5.8 GB of photos on a 30 GB disk
# KEEPSAKE_ENABLED=false no archive; ~20 GB of photos on a 30 GB disk
#
# Turning it OFF is irreversible for the event in the way that matters: there is no download
# at the end, so guests keep only what they save from the feed themselves. Releasing the
# gallery and minting download tickets are refused while it is off. The 10 GB database
# reserve still applies — that one protects Postgres, not the archive.
#
# 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

6
.gitignore vendored
View File

@@ -34,6 +34,12 @@ e2e/.env.test
/test-results/
/playwright-report/
# Load-test sample media. The wedding sample set is ~8.7 GB of real photos and
# videos; it is input to e2e/loadtest/event-sim.mjs, not source. Ignored by name
# AND by extension so a stray archive can never be committed by accident.
/wedding_sample_images.zip
*.zip
# OS
.DS_Store
Thumbs.db

View File

@@ -136,6 +136,23 @@ pub struct AppConfig {
/// comment UI. Existing comments stay in the DB (hidden), so flipping it back
/// restores them. Boot-time immutable, like `compression_concurrency`.
pub comments_enabled: bool,
/// Master switch for the keepsake archive (env `KEEPSAKE_ENABLED`, default true).
///
/// This is a DISK-SIZING switch as much as a feature switch. The upload gate refuses
/// any photo that would leave too little room to build the archive, and the archive
/// needs both halves at once — so the gate demands `2.2 x media` on top of the database
/// reserve, and every GB of photos costs roughly 3.2 GB of disk budget. On a small
/// volume that is what stops an event: uploads close with most of the disk still free.
///
/// With this off, the gate falls back to protecting the database alone
/// (`DISK_RESERVE_BYTES`), which is what turns a 30 GB box from ~5.8 GB of usable media
/// into ~20 GB. The cost is real and irreversible for the event: there is no downloadable
/// gallery at the end, so guests must save what they want from the feed. Releasing the
/// gallery and minting export tickets are refused while it is off.
///
/// Boot-time immutable, like `comments_enabled` — an operator flipping this mid-event
/// would move the gate under uploads that had already been accepted against the old one.
pub keepsake_enabled: bool,
/// Default colour theme, used as the fallback when the DB config keys are unset.
/// Runtime overrides live in the `config` table (admin UI); these env vars only
/// seed the initial default. `preset` is an id the frontend knows (e.g.
@@ -236,6 +253,14 @@ impl AppConfig {
)
})
.unwrap_or(true),
keepsake_enabled: std::env::var("KEEPSAKE_ENABLED")
.map(|v| {
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"false" | "0" | "no" | "off"
)
})
.unwrap_or(true),
default_theme_preset: std::env::var("THEME_PRESET")
.unwrap_or_else(|_| "champagne-gold".to_string()),
default_theme_primary: std::env::var("THEME_PRIMARY")

View File

@@ -391,6 +391,13 @@ pub async fn export_ticket(
axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> {
// No archive is ever built when the keepsake is off, so a ticket could only ever redeem
// into a 404. Refuse at the mint, where the guest sees a real message.
if !state.config.keepsake_enabled {
return Err(AppError::Forbidden(
"Der Galerie-Download ist für dieses Event deaktiviert.".into(),
));
}
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
// released — Spec design choice"). The export is read-only, so it stays available

View File

@@ -38,8 +38,15 @@ pub struct EventStatus {
/// Free space on the volume the keepsake is written to. `None` when the mount can't be
/// resolved — the UI hides the widget rather than rendering a confident zero.
pub disk_free_bytes: Option<u64>,
/// What a full keepsake build would need right now (both halves).
/// What a full keepsake build would need right now (both halves). Always 0 when the
/// keepsake is switched off — there is nothing to reserve for.
pub keepsake_required_bytes: u64,
/// Whether this event has a downloadable gallery at all (`KEEPSAKE_ENABLED`).
///
/// Sent so the dashboard can HIDE the release control rather than offer a button that
/// answers 403. A host tapping "Galerie freigeben" and being refused would reasonably
/// conclude the app is broken, on the one screen where they have no way to check.
pub keepsake_enabled: bool,
/// Whether the host should be warned. See [`disk_is_low`].
pub disk_low: bool,
}
@@ -66,15 +73,22 @@ pub struct EventStatus {
///
/// The 25% margin makes it a warning rather than an obituary: the host sees it while there is
/// still room to act (delete a few large videos, which refunds immediately and reopens the gate).
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
///
/// `keepsake_required` is `None` when `KEEPSAKE_ENABLED=false`. The banner then tracks the
/// database reserve alone, because that is the only threshold the gate still has.
fn disk_is_low(free: u64, keepsake_required: Option<u64>) -> bool {
// Mirrors the gate EXACTLY, headroom included. The gate now demands
// `UPLOAD_GATE_HEADROOM_BYTES` more than the export preflight does, so that ordinary
// end-of-night writes cannot flip the preflight after uploads have already stopped. Leaving
// that term out here would shrink the warning's lead by 1.5 GB — and the whole point of this
// function is that the banner must appear while the host can still act.
let gate_closes_at = keepsake_required
.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64)
.saturating_add(crate::handlers::upload::UPLOAD_GATE_HEADROOM_BYTES as u64);
//
// Both extra terms fall away together when the keepsake is off, because the gate drops them
// together. Computing this any other way would put the banner on a threshold the gate no
// longer uses — warning about a limit that cannot fire, or staying silent past one that can.
let gate_closes_at =
crate::handlers::upload::upload_gate_required_free(keepsake_required.map(|r| r as i64))
as u64;
let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4);
// No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here,
// and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at
@@ -159,10 +173,17 @@ pub async fn get_event_status(
.disk_cache
.snapshot(&state.config.export_path)
.map(|d| d.free);
let keepsake_required_bytes =
crate::services::export::keepsake_space_required(&state.pool, event.id)
.await
.unwrap_or(0);
// `None` when the keepsake is off — there is no archive to reserve room for, so the
// dashboard reports 0 required and the banner uses the same reduced threshold the gate does.
let keepsake_required = if state.config.keepsake_enabled {
Some(
crate::services::export::keepsake_space_required(&state.pool, event.id)
.await
.unwrap_or(0),
)
} else {
None
};
Ok(Json(EventStatus {
name: event.name,
@@ -170,10 +191,11 @@ pub async fn get_event_status(
uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_at.is_some(),
disk_free_bytes: free,
keepsake_required_bytes,
keepsake_required_bytes: keepsake_required.unwrap_or(0),
keepsake_enabled: state.config.keepsake_enabled,
// Unknown free space is NOT low. Fails open, exactly as the upload quota and the export
// preflight do: a scary banner on an unreadable mount would train the host to ignore it.
disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)),
disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required)),
}))
}
@@ -936,6 +958,14 @@ pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Refused outright when the keepsake is switched off. Releasing is irreversible for the
// event — it locks uploads and bumps the epoch — so letting it proceed with no archive to
// build would end the party's uploads in exchange for nothing.
if !state.config.keepsake_enabled {
return Err(AppError::Forbidden(
"Die Galerie-Freigabe ist für dieses Event deaktiviert (KEEPSAKE_ENABLED=false).".into(),
));
}
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
// transaction. Two reasons, both of which were live bugs:
//
@@ -1041,7 +1071,9 @@ pub async fn release_gallery(
#[cfg(test)]
mod tests {
use super::disk_is_low;
use crate::handlers::upload::{DISK_RESERVE_BYTES, UPLOAD_GATE_HEADROOM_BYTES};
use crate::handlers::upload::{
DISK_RESERVE_BYTES, UPLOAD_GATE_HEADROOM_BYTES, upload_gate_required_free,
};
use crate::services::export::required_free_bytes;
const GB: u64 = 1_000_000_000;
@@ -1049,7 +1081,7 @@ mod tests {
#[test]
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
// Room for the keepsake AND the reserve the upload gate holds back, with margin.
assert!(!disk_is_low(60 * GB, 25 * GB));
assert!(!disk_is_low(60 * GB, Some(25 * GB)));
}
/// Renamed from `the_absolute_floor_fires_...`: there is no separate floor clause any more
@@ -1062,9 +1094,9 @@ mod tests {
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
// the keepsake test comfortably; the floor is what catches this.
assert!(disk_is_low(5 * GB, GB));
assert!(disk_is_low(9 * GB, 0));
assert!(!disk_is_low(20 * GB, 0), "a roomy empty disk is not low");
assert!(disk_is_low(5 * GB, Some(GB)));
assert!(disk_is_low(9 * GB, Some(0)));
assert!(!disk_is_low(20 * GB, Some(0)), "a roomy empty disk is not low");
}
/// THE PROPERTY THIS EXISTS FOR: the host must be warned BEFORE guests are blocked.
@@ -1089,13 +1121,13 @@ mod tests {
// Just above the gate: guests can still upload, and the host must already be warned.
assert!(
disk_is_low(gate_closes_at + 1, required),
disk_is_low(gate_closes_at + 1, Some(required)),
"at media={media_gb}GB the banner is not yet showing while the gate still allows uploads"
);
// The warning must lead by a margin the host can act inside, not by one byte.
let mut warn_starts_at = gate_closes_at;
while disk_is_low(warn_starts_at, required) {
while disk_is_low(warn_starts_at, Some(required)) {
warn_starts_at += GB / 10;
}
assert!(
@@ -1148,7 +1180,7 @@ mod tests {
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
// any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this
// before releasing; after releasing, they cannot.
assert!(disk_is_low(30 * GB, 66 * GB));
assert!(disk_is_low(30 * GB, Some(66 * GB)));
}
#[test]
@@ -1159,8 +1191,8 @@ mod tests {
let required = 20 * GB;
let gate = required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64;
let warn_at = gate + gate / 4;
assert!(!disk_is_low(warn_at, required), "exactly enough is enough");
assert!(disk_is_low(warn_at - 1, required));
assert!(!disk_is_low(warn_at, Some(required)), "exactly enough is enough");
assert!(disk_is_low(warn_at - 1, Some(required)));
}
#[test]
@@ -1168,7 +1200,50 @@ mod tests {
// With no gallery the keepsake term is 0, so the warn threshold collapses to
// 1.25 x (DISK_RESERVE_BYTES + UPLOAD_GATE_HEADROOM_BYTES) = 1.25 x 11.5 GB = 14.375 GB,
// which dominates the 10 GB absolute floor.
assert!(!disk_is_low(15 * GB, 0));
assert!(disk_is_low(9 * GB, 0));
assert!(!disk_is_low(15 * GB, Some(0)));
assert!(disk_is_low(9 * GB, Some(0)));
}
/// The point of `KEEPSAKE_ENABLED=false`: the `2.2 x media` term disappears, so a volume
/// that could not accept another photo with the keepsake armed has plenty of room without it.
#[test]
fn switching_the_keepsake_off_reopens_the_disk() {
// 6.5 GB of media on a 30 GB box — the exact point the event simulation stopped, with
// 25 GB free and the gate demanding 25.9 GB.
let media = 13 * GB / 2;
let required = crate::services::export::required_free_bytes(media, 2);
let free = 25 * GB;
assert!(
free < upload_gate_required_free(Some(required as i64)) as u64,
"with the keepsake armed this disk is closed — that is the state being fixed"
);
assert!(
free > upload_gate_required_free(None) as u64,
"with it off, only the database reserve applies and uploads continue"
);
assert!(disk_is_low(free, Some(required)), "armed: the host is warned");
assert!(!disk_is_low(free, None), "off: nothing to warn about");
}
/// The banner must lead the gate in BOTH modes. With the keepsake off both thresholds shrink,
/// and a warning that arrives after uploads have already stopped is the bug this guards.
#[test]
fn the_banner_still_leads_the_gate_with_the_keepsake_off() {
let gate = upload_gate_required_free(None) as u64;
assert_eq!(gate, DISK_RESERVE_BYTES as u64, "only the DB reserve remains");
assert!(
disk_is_low(gate + 1, None),
"the host must already be warned while guests can still upload"
);
let mut warn_starts_at = gate;
while disk_is_low(warn_starts_at, None) {
warn_starts_at += GB / 10;
}
assert!(
warn_starts_at >= gate + gate / 5,
"the warning leads the gate by only {} bytes",
warn_starts_at - gate
);
}
}

View File

@@ -201,6 +201,9 @@ pub async fn add_comment(
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
));
}
// Same reason as the caption path: a NUL is the one character Postgres will not store,
// and without this the INSERT below fails as an anonymous 500.
crate::handlers::upload::reject_nul(text, "Kommentar")?;
// Insert the comment and link its hashtags atomically, so a crash mid-loop
// can't leave a committed comment with only some of its tags indexed.

View File

@@ -17,6 +17,29 @@ use crate::state::AppState;
const MAX_CAPTION_LENGTH: usize = 2000;
/// Reject free text carrying a NUL byte, before it reaches Postgres.
///
/// A NUL is the one character a `TEXT` column refuses outright: the driver forwards it and
/// Postgres answers `invalid byte sequence for encoding "UTF8": 0x00`, which arrives here as an
/// anonymous `sqlx::Error`, becomes `AppError::Internal`, and costs the guest a 500 AND the
/// photo they just spent a minute uploading — the transaction rolls back with the file already
/// streamed to disk.
///
/// Deliberately narrower than `validate_display_name`, which refuses control characters
/// wholesale. A caption legitimately contains newlines and emoji, and every other control
/// character stores and renders harmlessly; this rejects exactly the byte that cannot work.
///
/// Rejected rather than stripped. Silently rewriting what a guest wrote is the worse failure,
/// and no real client emits a NUL by accident — it only arrives from a broken or hostile one.
pub(crate) fn reject_nul(value: &str, field_de: &str) -> Result<(), AppError> {
if value.contains('\0') {
return Err(AppError::BadRequest(format!(
"{field_de} enthält ein ungültiges Zeichen und wurde nicht gespeichert."
)));
}
Ok(())
}
/// Byte ceiling for the caption field, enforced WHILE reading it.
///
/// `Field::text()` buffers the entire field before returning, and this is the one route whose
@@ -149,6 +172,74 @@ impl Drop for TempFileGuard {
}
}
/// Guarantees the compression job is queued even if this request never finishes.
///
/// The enqueue is a bare `tokio::spawn` that happens AFTER `tx.commit()`, and the commit is a
/// suspension point. If the guest walks out of range inside it, Postgres applies the COMMIT and
/// axum drops this future before the spawn is reached: the row lands durably at
/// `compression_status = 'pending'` with no job behind it, and nothing in the app ever looks at
/// it again — `startup_recovery` only rescues `'processing'`, and the derivative backfill runs
/// once at boot. The photo then shows in the feed with no preview, forces every viewer to pull
/// the full original, and is skipped by the diashow forever.
///
/// This is the same hazard `TempFileGuard` covers for the file, reached one line later, and it
/// gets the same answer: `Drop` runs on cancellation, so arming before the commit and disarming
/// after the spawn closes the window. It also covers the indeterminate-commit error path, which
/// returns `Err` on a row that may well be live.
///
/// Enqueuing a job for a row that did NOT commit is harmless: `set_compression_status` matches
/// zero rows and `begin_derivative_attempt` returns `None`, so the task retires immediately.
/// A guard is still not sufficient on its own — nothing in-process survives a SIGKILL — which is
/// why `CompressionWorker::requeue_stuck_pending` sweeps for the same state on a timer.
struct EnqueueGuard {
/// `None` once the job has actually been handed to the worker.
armed: Option<(crate::services::compression::CompressionWorker, Uuid, String, String)>,
}
impl EnqueueGuard {
fn new(
worker: crate::services::compression::CompressionWorker,
upload_id: Uuid,
original_path: String,
mime_type: String,
) -> Self {
Self {
armed: Some((worker, upload_id, original_path, mime_type)),
}
}
/// The job is queued; stand down.
fn disarm(&mut self) {
self.armed = None;
}
}
impl Drop for EnqueueGuard {
fn drop(&mut self) {
let Some((worker, upload_id, path, mime)) = self.armed.take() else {
return;
};
// `process` calls `tokio::spawn`, which panics without a runtime. During a normal
// cancellation we are still on the runtime that dropped us; during shutdown we may not
// be. Log loudly rather than panicking in a destructor — the timed sweeper is the
// backstop for exactly this case.
if tokio::runtime::Handle::try_current().is_err() {
tracing::error!(
%upload_id,
"upload committed but the compression job could not be queued (no runtime in \
Drop); requeue_stuck_pending will pick it up"
);
return;
}
tracing::warn!(
%upload_id,
"request ended before the compression job was queued — queueing it from the drop \
guard (client most likely disconnected during COMMIT)"
);
worker.process(upload_id, path, mime);
}
}
/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from
/// the file's magic bytes. The detected MIME (not the client-declared one) is what
/// we trust, store, and hand to the compression pipeline — so a text-based payload
@@ -458,6 +549,12 @@ pub async fn upload(
MAX_CAPTION_LENGTH
)));
}
if let Some(ref cap) = caption {
reject_nul(cap, "Beschreibung")?;
}
if let Some(ref csv) = hashtags_csv {
reject_nul(csv, "Hashtags")?;
}
// Determine the file type from its magic bytes and require it to be on the
// allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so
@@ -593,29 +690,35 @@ pub async fn upload(
.get(&state.pool, &state.config.event_slug)
.await
.saturating_add(size);
let keepsake_needs =
crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64;
// `None` when the keepsake is switched off: there is no archive to keep buildable, so
// the gate protects the database alone. See `upload_gate_required_free`.
let keepsake_needs = state.config.keepsake_enabled.then(|| {
crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64
});
// Strictly more than the export preflight requires — see `UPLOAD_GATE_HEADROOM_BYTES`.
// Matching it exactly meant the preflight was already at its limit the moment uploads
// stopped, so the night's remaining writes decided whether the keepsake could be built.
let required = keepsake_needs
.saturating_add(DISK_RESERVE_BYTES)
.saturating_add(UPLOAD_GATE_HEADROOM_BYTES);
let required = upload_gate_required_free(keepsake_needs);
if free < required {
tracing::error!(
free_bytes = free,
upload_size = size,
media_after,
keepsake_needs,
keepsake_needs = keepsake_needs.unwrap_or(0),
keepsake_enabled = state.config.keepsake_enabled,
reserve = DISK_RESERVE_BYTES,
headroom = UPLOAD_GATE_HEADROOM_BYTES,
"refusing upload: it would leave too little room to build the keepsake"
"refusing upload: not enough free disk"
);
return Err(AppError::QuotaExceeded(
"Der Speicher des Events ist fast voll — damit die Galerie am Ende noch als \
Download erstellt werden kann, sind neue Uploads jetzt gesperrt. Bitte sag \
einem Host Bescheid."
.into(),
if state.config.keepsake_enabled {
"Der Speicher des Events ist fast voll — damit die Galerie am Ende noch als \
Download erstellt werden kann, sind neue Uploads jetzt gesperrt. Bitte sag \
einem Host Bescheid."
} else {
"Der Speicher des Events ist voll — neue Uploads sind gesperrt. Bitte sag \
einem Host Bescheid."
}
.into(),
));
}
} else {
@@ -690,7 +793,9 @@ pub async fn upload(
// Quota accounting, the upload row, and its hashtag links must be atomic: a
// crash between the bytes increment and the insert would permanently charge
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
let tx_result: Result<Upload, AppError> = async {
// Carries the enqueue guard out with the row: it is armed inside the block (before the
// commit) and can only be stood down after `process()` runs, which happens out here.
let tx_result: Result<(Upload, EnqueueGuard), AppError> = async {
let mut tx = state.pool.begin().await?;
// RE-CHECK THE LOCK, UNDER A ROW LOCK, INSIDE THE COMMIT TX.
@@ -807,6 +912,15 @@ pub async fn upload(
// the failure to the recoverable side: if we are dropped mid-commit the bytes leak,
// and leaked bytes under a final name are exactly what the orphan sweeper reclaims.
// A committed row whose file we deleted is unrecoverable. Prefer the leak.
// Armed BEFORE the commit, for the mirror-image of the reason the file guard is
// disarmed before it: from here on, a cancellation can leave a live row behind, and a
// live row with no compression job is silent, permanent loss. See `EnqueueGuard`.
let enqueue_guard = EnqueueGuard::new(
state.compression.clone(),
upload.id,
relative_path.clone(),
mime.clone(),
);
file_guard.disarm();
if let Err(e) = tx.commit().await {
// Deliberately do NOT re-arm the guard here.
@@ -832,7 +946,7 @@ pub async fn upload(
);
return Err(e.into());
}
Ok(upload)
Ok((upload, enqueue_guard))
}
.await;
@@ -844,8 +958,8 @@ pub async fn upload(
//
// The successful-commit case disarmed the guard inside the block, immediately before
// `tx.commit()` — see the comment there for why it cannot be done out here.
let upload = match tx_result {
Ok(u) => u,
let (upload, mut enqueue_guard) = match tx_result {
Ok(v) => v,
// The concurrent duplicate resolved inside the transaction. The winner's row is committed;
// answer with it so both retries of the same photo get the same successful reply. The
// loser's bytes are reclaimed by the guard when this return drops it.
@@ -888,10 +1002,12 @@ pub async fn upload(
Err(e) => return Err(e),
};
// Spawn compression task
// Spawn compression task. The guard above has covered this call since before the commit;
// stand it down only once the job is genuinely with the worker.
state
.compression
.process(upload.id, relative_path, mime.clone());
enqueue_guard.disarm();
// Broadcast SSE event
let dto = UploadDto {
@@ -970,6 +1086,14 @@ pub async fn edit_upload(
"Beschreibung ist zu lang. Maximum: {MAX_CAPTION_LENGTH} Zeichen."
)));
}
if let Some(ref caption) = body.caption {
reject_nul(caption, "Beschreibung")?;
}
if let Some(ref tags) = body.hashtags {
for tag in tags {
reject_nul(tag, "Hashtags")?;
}
}
let normalized_tags = body
.hashtags
.as_ref()
@@ -1308,6 +1432,25 @@ pub const DISK_RESERVE_BYTES: i64 = 10_000_000_000;
/// that the archive can never be built.
pub const UPLOAD_GATE_HEADROOM_BYTES: i64 = 1_500_000_000;
/// Free bytes the upload gate demands, and the single definition the host's low-disk
/// banner shares so the two cannot drift into disagreeing about the same question.
///
/// `keepsake_needs` is `None` when `KEEPSAKE_ENABLED=false`. Both extra terms fall away
/// together in that case, and deliberately so: `UPLOAD_GATE_HEADROOM_BYTES` exists only to
/// keep this gate strictly ahead of the EXPORT PREFLIGHT, and with no keepsake there is no
/// preflight to stay ahead of. What remains is `DISK_RESERVE_BYTES`, which answers the one
/// question that is still live without an archive: can Postgres still write? That is the
/// whole point of the switch — on a 30 GB volume it moves the ceiling from ~5.8 GB of media
/// to ~20 GB.
pub fn upload_gate_required_free(keepsake_needs: Option<i64>) -> i64 {
match keepsake_needs {
Some(needs) => needs
.saturating_add(DISK_RESERVE_BYTES)
.saturating_add(UPLOAD_GATE_HEADROOM_BYTES),
None => DISK_RESERVE_BYTES,
}
}
/// Pure per-user quota formula: `max(floor((free_disk * tolerance) / divisor), MIN)`.
///
/// `divisor` is the LARGER of the observed uploader count and the operator's
@@ -1721,8 +1864,42 @@ pub async fn get_thumbnail(
mod tests {
use super::{
DISK_RESERVE_BYTES, MIN_QUOTA_LIMIT_BYTES, RangeSpec, parse_range, quota_limit_bytes,
reject_nul, upload_gate_required_free,
};
/// A NUL in a caption used to reach Postgres, which refuses it with
/// `invalid byte sequence for encoding "UTF8": 0x00` — surfacing as a 500 that also cost
/// the guest the photo, because the transaction rolled back after the file was written.
#[test]
fn a_nul_byte_is_refused_before_it_reaches_postgres() {
assert!(reject_nul("Sch\u{00f6}n\u{0000} boom", "Beschreibung").is_err());
assert!(reject_nul("\u{0000}", "Kommentar").is_err());
}
/// Deliberately narrower than `validate_display_name`: captions carry newlines and emoji,
/// and every control character other than NUL stores and renders harmlessly. Widening this
/// to all control characters would start refusing captions that work today.
#[test]
fn other_control_characters_and_ordinary_text_still_pass() {
assert!(reject_nul("Der erste Tanz \u{1f57a}", "Beschreibung").is_ok());
assert!(reject_nul("zwei\nZeilen\tmit Tab", "Beschreibung").is_ok());
assert!(reject_nul("bell\u{0007}here", "Beschreibung").is_ok());
assert!(reject_nul("", "Beschreibung").is_ok());
}
/// The keepsake switch is a disk-sizing switch: with it off the `2.2 x media` term and the
/// preflight headroom both fall away, leaving only the database reserve.
#[test]
fn switching_the_keepsake_off_leaves_only_the_database_reserve() {
let keepsake = 14_000_000_000_i64; // ~6.4 GB of media, both archive halves
assert_eq!(
upload_gate_required_free(Some(keepsake)),
keepsake + DISK_RESERVE_BYTES + super::UPLOAD_GATE_HEADROOM_BYTES
);
assert_eq!(upload_gate_required_free(None), DISK_RESERVE_BYTES);
assert!(upload_gate_required_free(None) < upload_gate_required_free(Some(keepsake)));
}
// `Range` handling exists because iOS Safari probes every `<video>` with
// `Range: bytes=0-1` and abandons the load without a 206. These pin the forms a
// media element actually sends, plus the edges that decide 206 vs 200 vs 416.

View File

@@ -127,6 +127,7 @@ async fn main() -> Result<()> {
state.rate_limiter.clone(),
state.sse_tickets.clone(),
config.media_path.clone(),
state.compression.clone(),
);
let api = Router::new()

View File

@@ -411,6 +411,66 @@ impl CompressionWorker {
/// `derivative_attempts` stops a fatal row being replayed on every boot, `BACKFILL_BATCH`
/// stops one start queueing unbounded work, and the whole thing runs as ONE task walking
/// the rows sequentially rather than N tasks racing for the same semaphore.
/// How long an upload may sit at `'pending'` before the sweeper assumes its job was lost.
///
/// Must comfortably exceed the deepest real queue wait, or the sweeper re-enqueues photos
/// that are merely waiting their turn. The live path makes that easy to bound: a task's
/// FIRST action after taking a permit is to flip the row to `'processing'`, so a row still
/// `'pending'` after this long is either behind that much work or genuinely lost. The
/// event simulation peaked at 120 queued with a p99 of 97s while being fed thirty times a
/// real event's arrival rate, so ten minutes is a wide margin rather than a tight one.
const PENDING_GRACE: chrono::Duration = chrono::Duration::minutes(10);
/// Re-queue uploads whose compression job was lost between the commit and the spawn.
///
/// `EnqueueGuard` closes that window for a cancelled request, but nothing in-process
/// survives a SIGKILL or an OOM kill, and `startup_recovery` only rescues `'processing'`
/// — so a row orphaned at `'pending'` had no path back at all. Without this, the failure is
/// silent and permanent: the photo keeps its feed entry, never gets a derivative, and is
/// dropped from the diashow for the rest of the event.
///
/// Re-entering the live `process` path (rather than regenerating derivatives inline, as
/// `backfill_stale_derivatives` does) is deliberate: it reuses the status transitions, the
/// retry ladder and the SSE broadcast, so a rescued photo behaves exactly like one that was
/// never lost.
pub async fn requeue_stuck_pending(&self) {
let cutoff = chrono::Utc::now() - Self::PENDING_GRACE;
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
"SELECT id, original_path, mime_type FROM upload
WHERE deleted_at IS NULL
AND compression_status = 'pending'
AND original_path <> ''
AND created_at < $1
AND derivative_attempts < $2
ORDER BY created_at
LIMIT $3",
)
.bind(cutoff)
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
.bind(Self::BACKFILL_BATCH)
.fetch_all(&self.pool)
.await;
let rows = match rows {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = ?e, "stuck-pending sweep query failed");
return;
}
};
if rows.is_empty() {
return;
}
// WARN, not INFO: reaching this means the guard did not run, which is worth noticing
// even though the photo is being rescued.
tracing::warn!(
count = rows.len(),
"found upload(s) stuck at 'pending' with no compression job; re-queueing"
);
for (id, original_path, mime_type) in rows {
self.process(id, original_path, mime_type);
}
}
pub async fn backfill_stale_derivatives(&self) {
// `original_path IS NOT NULL` was dead — the column is NOT NULL. What actually needs
// excluding is the blanked path `cleanup_deleted_media` leaves behind.

View File

@@ -144,7 +144,36 @@ pub fn spawn_periodic_tasks(
rate_limiter: RateLimiter,
sse_tickets: SseTicketStore,
media_path: PathBuf,
compression: crate::services::compression::CompressionWorker,
) {
// Rescuing a lost compression job gets its OWN loop rather than a line in the hourly one.
// Cadence is the whole point: an upload stuck at 'pending' is a photo missing from the
// diashow and showing without a preview, so an hour of that during a five-hour party is a
// guest-visible hole. Ten minutes bounds it while still sitting far above the deepest
// observed queue wait (see `PENDING_GRACE`). Supervised for the same reason as the loop
// below: silently dying is how this class of safety net stops existing.
tokio::spawn(async move {
loop {
let worker = compression.clone();
let inner = tokio::spawn(async move {
// `interval` fires its first tick immediately, and that is deliberate here:
// a restart is exactly when stranded rows exist (the drop guard cannot
// survive a SIGKILL), and `startup_recovery` rescues only 'processing'. The
// grace window makes the boot pass safe — nothing uploaded in the last ten
// minutes can match, so this can never race a job that is merely queued.
let mut tick = tokio::time::interval(Duration::from_secs(600));
loop {
tick.tick().await;
worker.requeue_stuck_pending().await;
}
});
match inner.await {
Ok(()) => tracing::error!("stuck-pending sweeper returned; restarting it"),
Err(e) => tracing::error!(error = ?e, "stuck-pending sweeper died; restarting it"),
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
// Supervised, because this one task carries EVERY piece of recurring hygiene in the app:
// session pruning, media reclamation, the orphan-temp sweep, and the rate-limiter and
// SSE-ticket maps. As a bare `tokio::spawn` with no retained handle, a single panic anywhere

View File

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

27
e2e/Caddyfile.sim Normal file
View File

@@ -0,0 +1,27 @@
# Caddyfile for the EVENT SIMULATION stack (e2e/docker-compose.sim.yml).
# Identical in behaviour to Caddyfile.test — same compression carve-out for SSE,
# same security headers, same export framing rules — but on :3102 so the
# simulation can run without colliding with the :3101 Playwright stack.
:3102 {
# Mirror prod: exclude the SSE stream from compression so buffering doesn't
# delay real-time events.
@compressible not path /api/v1/stream
encode @compressible zstd gzip
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
@framable path /api/v1/export/zip /api/v1/export/html
@not_framable not path /api/v1/export/zip /api/v1/export/html
header @framable X-Frame-Options "SAMEORIGIN"
header @not_framable X-Frame-Options "DENY"
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000
reverse_proxy /health app:3000
reverse_proxy frontend:3001
}

156
e2e/docker-compose.sim.yml Normal file
View File

@@ -0,0 +1,156 @@
# EventSnap EVENT SIMULATION stack — models the real production box, not CI.
#
# Difference from docker-compose.test.yml (which is tuned for fast, unconstrained
# Playwright runs): this file reproduces the CX22 the event actually runs on —
# * 2 vCPU total, shared by all four services
# * 4 GB RAM, split by the same per-service limits production ships
# * a REAL 30 GB filesystem for media + exports (loopback ext4), so statvfs
# inside the container returns true numbers and the disk gate / 507 path is
# exercised for real rather than simulated.
#
# Why cpuset on every service: production's per-service `cpus` ceilings sum to
# 1.5 + 1.2 + 0.6 + 0.5 = 3.8 on a box that has 2. That oversubscription is the
# point — the ceilings only bind when something else is competing, and cpu_shares
# decides who wins. Reproducing that on a 12-core workstation requires confining
# every container to the SAME two cores; without cpuset each service would get its
# ceiling simultaneously and the contention under test would never happen.
#
# Bring up: docker compose -f docker-compose.sim.yml up -d --build
# Tear down: docker compose -f docker-compose.sim.yml down -v
#
# The 30 GB volume is created out-of-band (see e2e/loadtest/sim-disk.sh) because a
# loopback device must be attached by root; it is declared `external` here.
x-cpuset: &cpuset '0,1'
services:
db:
image: postgres:16-alpine
cpuset: *cpuset
environment:
POSTGRES_USER: eventsnap_test
POSTGRES_PASSWORD: eventsnap_test
POSTGRES_DB: eventsnap_test
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
interval: 3s
timeout: 3s
retries: 30
ports:
- '55433:5432'
volumes:
# Named (not anonymous) so a `down -v` really wipes it and so its on-disk size
# can be measured against the 10 GB DISK_RESERVE that is meant to cover it.
- sim_pgdata:/var/lib/postgresql/data
# Production values, verbatim (docker-compose.yml db service).
deploy:
resources:
limits:
memory: 1G
cpus: '1.5'
reservations:
memory: 256M
cpu_shares: 2048
memswap_limit: 1152m
app:
# The SHIPPED release image, not a local build. Verified identical to HEAD:
# `git diff v0.17.5 HEAD -- backend/` is empty, and v0.17.5/v0.17.6 share one
# app digest on purpose (the v0.17.6 bump was frontend-only). Running the real
# artifact means the simulation tests what the event will actually run.
image: ${SIM_APP_IMAGE:-registry.mc02.dev/eventsnap/app:v0.17.5}
cpuset: *cpuset
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://eventsnap_test:eventsnap_test@db:5432/eventsnap_test
JWT_SECRET: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
# bcrypt("admin-test-pw"), cost 4. $ doubled to escape compose interpolation.
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
EVENT_SLUG: sim-wedding
EVENT_NAME: Hochzeit Simulation
APP_PORT: '3000'
# Both live on the SAME 30 GB filesystem, as they do on the real VPS — but in
# sibling directories, because config.rs::validate requires EXPORT_PATH outside
# MEDIA_PATH (a keepsake archive contains every photo in the event).
MEDIA_PATH: /disk/media
EXPORT_PATH: /disk/exports
SESSION_EXPIRY_DAYS: '30'
# Production pins these; the CI stack leaves them at code defaults. Sized to 2 vCPU.
DATABASE_MAX_CONNECTIONS: '15'
COMPRESSION_WORKER_CONCURRENCY: '2'
# Production disables comments for this event (docker-compose.yml, product decision).
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
# reset between runs. Never set on the real box.
EVENTSNAP_TEST_MODE: '1'
RUST_LOG: eventsnap_backend=info,tower_http=warn
volumes:
- sim_disk:/disk
deploy:
resources:
limits:
memory: 1G
cpus: '1.2'
reservations:
memory: 256M
cpu_shares: 512
memswap_limit: 1152m
expose:
- '3000'
frontend:
# Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
image: ${SIM_FE_IMAGE:-registry.mc02.dev/eventsnap/frontend:v0.17.6}
cpuset: *cpuset
depends_on:
- app
environment:
PORT: '3001'
HOST: '0.0.0.0'
ORIGIN: 'http://localhost:3102'
PUBLIC_CAMERA_ENABLED: ${SIM_CAMERA:-true}
deploy:
resources:
limits:
memory: 256M
cpus: '0.6'
cpu_shares: 256
memswap_limit: 320m
expose:
- '3001'
caddy:
image: caddy:2-alpine
cpuset: *cpuset
depends_on:
- app
- frontend
volumes:
- ./Caddyfile.sim:/etc/caddy/Caddyfile:ro
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
cpu_shares: 1024
memswap_limit: 320m
ports:
- '3102:3102'
volumes:
# 30 GB loopback ext4, created by e2e/loadtest/sim-disk.sh. Holds media AND exports,
# exactly as one VPS disk holds both.
sim_disk:
external: true
name: eventsnap_sim_media
# Postgres data stays on the host disk, NOT on the 30 GB volume. Scoping decision:
# the 30 GB budget under test is the one the APP manages and measures (statvfs on
# MEDIA_PATH drives the disk gate). Putting PG on the same volume would test
# filesystem exhaustion instead, and a full disk under Postgres risks ending the run
# for an infrastructural reason rather than an application one. Its growth is sampled
# separately and reported against DISK_RESERVE_BYTES (10 GB), which exists to cover it.
sim_pgdata:

View 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);
});

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
/**
* Real-browser pass over the event AFTER the load simulation has filled it.
*
* `event-sim.mjs` drives the HTTP API directly — deliberately, because 150 real
* browsers would bottleneck the test box rather than the server. That leaves two
* things unmeasured, and both are guest-visible:
*
* 1. The SvelteKit frontend container, which the API driver never touches.
* 2. Whether the frontend ESCAPES the XSS caption the API stored verbatim. The
* backend stores captions raw by design (`upload.rs` length-checks only), so
* the entire defence is the renderer. The abuse suite proved the payload is
* in the database; only a browser can prove it is inert.
*
* Runs three real engines against the loaded gallery and reports load timings,
* console errors, and the XSS verdict.
*
* node e2e/loadtest/browser-check.mjs
*/
import { chromium, firefox, webkit, devices } from '@playwright/test';
import { writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
const API = `${BASE}/api/v1`;
const OUT = join(__dirname, 'results', 'browser');
const XSS_CAPTION = '<script>alert(document.cookie)</script><img src=x onerror=alert(1)>';
const XSS_MARKERS = ['<script>alert(', 'onerror=alert(1)', 'onerror=alert('];
async function joinGuest(name) {
const res = await fetch(`${API}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name }),
});
if (!res.ok) throw new Error(`join failed ${res.status}`);
return res.json();
}
/** Seed the guest session the way the app itself does, so we land on the gallery. */
async function seedSession(context, acct, name) {
await context.addInitScript(
([jwt, uid, dn]) => {
localStorage.setItem('eventsnap_jwt', jwt);
localStorage.setItem('eventsnap_user_id', uid);
localStorage.setItem('eventsnap_display_name', dn);
// Skip the first-run guide. It is a modal over the feed, and leaving it up
// means the feed never lazy-loads — which silently turns the XSS check into
// "the payload wasn't on screen", not "the payload was neutralised".
localStorage.setItem('eventsnap_guide_seen', '1');
},
[acct.jwt, acct.user_id, name]
);
}
/**
* Post a fresh XSS caption so the payload is the NEWEST item and therefore on the
* first page of the feed. The load test's payload is real but buried behind ~780
* newer uploads, and a check that never renders the payload proves nothing.
*/
async function seedXssUpload(jwt) {
const { readdir, readFile } = await import('node:fs/promises');
const dir = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool';
const files = (await readdir(dir)).filter((f) => f.toLowerCase().endsWith('.jpg') || f.toLowerCase().endsWith('.jpeg'));
const buf = await readFile(join(dir, files[0]));
const form = new FormData();
form.append('file', new Blob([buf], { type: 'image/jpeg' }), 'x.jpg');
form.append('caption', XSS_CAPTION);
const res = await fetch(`${API}/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}` },
body: form,
});
if (!res.ok) throw new Error(`xss seed upload failed ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()).id;
}
async function runEngine(engineName, launcher, deviceProfile) {
const result = { engine: engineName, device: deviceProfile ?? 'desktop', pages: {}, consoleErrors: [], dialogs: [], xss: null };
let browser;
try {
browser = await launcher.launch();
} catch (e) {
// A missing system library on the test box is not a finding about the app.
// Skip the engine and say so, rather than failing the whole pass.
result.skipped = `could not launch: ${String(e).split('\n')[0].slice(0, 120)}`;
return result;
}
const context = await browser.newContext(deviceProfile ? devices[deviceProfile] : {});
const acct = await joinGuest(`Browser ${engineName} ${Date.now() % 10000}`);
await seedSession(context, acct, `Browser ${engineName}`);
const page = await context.newPage();
// An executed payload would surface as a dialog. Nothing should ever fire.
page.on('dialog', async (d) => {
result.dialogs.push({ type: d.type(), message: d.message() });
await d.dismiss();
});
page.on('console', (m) => {
if (m.type() === 'error') result.consoleErrors.push(m.text().slice(0, 200));
});
page.on('pageerror', (e) => result.consoleErrors.push(`pageerror: ${String(e).slice(0, 200)}`));
for (const [label, path] of [
['gallery', '/'],
['diashow', '/diashow'],
]) {
const t0 = Date.now();
try {
await page.goto(`${BASE}${path}`, { waitUntil: 'load', timeout: 60000 });
const loadMs = Date.now() - t0;
await page.waitForTimeout(6000); // let the feed + images settle
const imgCount = await page.locator('img').count();
const shot = join(OUT, `${engineName}-${label}.png`);
await page.screenshot({ path: shot, fullPage: false });
result.pages[label] = { loadMs, settledMs: Date.now() - t0, imgCount, screenshot: shot };
} catch (e) {
result.pages[label] = { error: String(e).slice(0, 200) };
}
}
// ── XSS verdict ───────────────────────────────────────────────────────────
// Walk the gallery looking for the stored payload. It must appear as TEXT and
// never as a live <script> element or an img with an onerror handler.
try {
await seedXssUpload(acct.jwt);
await page.goto(`${BASE}/`, { waitUntil: 'load', timeout: 60000 });
await page.waitForTimeout(6000);
const verdict = await page.evaluate((markers) => {
const bodyText = document.body.innerText ?? '';
const html = document.body.innerHTML ?? '';
const asText = markers.some((m) => bodyText.includes(m));
// Any script tag that isn't a real app/module script would be injected.
const injectedScripts = [...document.querySelectorAll('script')]
.filter((s) => (s.textContent ?? '').includes('alert('))
.map((s) => (s.textContent ?? '').slice(0, 80));
const onerrorImgs = [...document.querySelectorAll('img[onerror]')].map((i) => i.getAttribute('onerror'));
return {
payloadVisibleAsText: asText,
injectedScripts,
onerrorImgs,
// escaped entities are the positive signal that the renderer did its job
escapedMarkup: html.includes('&lt;script&gt;') || html.includes('&lt;img'),
};
}, XSS_MARKERS);
result.xss = verdict;
} catch (e) {
result.xss = { error: String(e).slice(0, 200) };
}
await browser.close();
return result;
}
const main = async () => {
await mkdir(OUT, { recursive: true });
const results = [];
results.push(await runEngine('chromium', chromium, 'Pixel 7'));
results.push(await runEngine('webkit', webkit, 'iPhone 14'));
results.push(await runEngine('firefox', firefox, null));
const out = join(__dirname, 'results', `browser-check-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
await writeFile(out, JSON.stringify(results, null, 2));
console.log('\n' + '═'.repeat(70));
console.log('REAL BROWSER CHECK');
console.log('═'.repeat(70));
let xssFail = false;
let inconclusive = false;
for (const r of results) {
console.log(`\n${r.engine} (${r.device})`);
if (r.skipped) {
console.log(` SKIPPED — ${r.skipped}`);
continue;
}
for (const [k, v] of Object.entries(r.pages))
console.log(
` ${k.padEnd(8)} ${v.error ? 'ERROR ' + v.error : `load ${v.loadMs}ms, ${v.imgCount} <img> after settle`}`
);
console.log(` console errors: ${r.consoleErrors.length}${r.consoleErrors.length ? ' → ' + r.consoleErrors[0] : ''}`);
console.log(` dialogs fired : ${r.dialogs.length}`);
const x = r.xss ?? {};
const bad = (x.injectedScripts?.length ?? 0) > 0 || (x.onerrorImgs?.length ?? 0) > 0 || r.dialogs.length > 0;
// Absence of an explosion only counts if the payload actually reached the DOM.
const rendered = !!(x.payloadVisibleAsText || x.escapedMarkup);
if (bad) xssFail = true;
if (!bad && !rendered) inconclusive = true;
const label = bad ? '✗ PAYLOAD LIVE' : rendered ? '✓ inert (rendered as text)' : '? INCONCLUSIVE — payload never reached the DOM';
console.log(
` XSS : ${label} ` +
`(as text: ${x.payloadVisibleAsText}, escaped markup: ${x.escapedMarkup}, ` +
`injected scripts: ${x.injectedScripts?.length ?? 0}, onerror imgs: ${x.onerrorImgs?.length ?? 0})`
);
}
console.log(
`\nverdict: ${
xssFail
? '✗ stored XSS EXECUTES in a real browser'
: inconclusive
? '? INCONCLUSIVE in at least one engine — payload never rendered, so nothing was proven'
: '✓ stored payload rendered as inert text in every engine that ran'
}`
);
console.log(`report → ${out}`);
console.log('═'.repeat(70));
};
main().catch((e) => {
console.error('browser check failed:', e);
process.exit(1);
});

1556
e2e/loadtest/event-sim.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Drives a REAL upload through the composer's file picker and proves the bytes survived.
*
* Written during the v0.18.3v0.18.6 run of hotfixes, which were all one failure wearing
* different masks: the browser handing the queue something that was not the file. First an
* empty body (WebKit stores a picked `File` as a reference to an OS file iOS then deletes),
* then — once the bytes were copied — the risk of a SHORT one, because that copy is a
* multi-second chunked loop for a video and the same purge can land partway through it.
*
* Every check that missed those bugs shared one shortcut: it asserted the upload was
* ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size cap and the
* decode budget, is stored, gets a preview, and shows in the gallery — and is still not the
* guest's video. So this asserts the only thing that actually settles it: fetch the stored
* original back and compare its SHA-256 to the file on disk.
*
* Two paths matter and they are not the same code:
* * at or below MATERIALISE_CHUNK_BYTES (4 MB) the copy is a single `arrayBuffer()`
* * above it, a chunked loop — the one that can produce a short blob
* Pass at least one file of each, or the run proves half of what it claims.
*
* Requires the sim stack (e2e/docker-compose.sim.yml) on :3102.
*
* node e2e/loadtest/upload-integrity-check.mjs photo.jpg big-video.mp4
*/
import { chromium, devices } from '@playwright/test';
import { readFile, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
const API = `${BASE}/api/v1`;
/** Mirrors MATERIALISE_CHUNK_BYTES in frontend/src/lib/upload-queue.ts. */
const CHUNK_BYTES = 4 * 1024 * 1024;
const sha = (buf) => createHash('sha256').update(buf).digest('hex');
const files = process.argv.slice(2);
if (!files.length) {
console.error('usage: upload-integrity-check.mjs <file> [file...] (include one >4 MB)');
process.exit(2);
}
async function main() {
const browser = await chromium.launch();
const context = await browser.newContext({ ...devices['Pixel 7'] });
// The guide is a modal over the composer; leaving it up means the picker is never reached.
await context.addInitScript(() => localStorage.setItem('eventsnap_guide_seen', '1'));
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(String(e).slice(0, 140)));
await page.goto(`${BASE}/join`, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.fill('input[type=text]', `Integrity ${Date.now() % 100000}`);
await page.locator('button[type=submit]').first().click();
await page.waitForTimeout(4500);
for (const label of ['Weiter', 'Verstanden', 'Los geht']) {
const btn = page.getByRole('button', { name: new RegExp(label, 'i') });
if ((await btn.count()) && (await btn.first().isVisible())) {
await btn.first().click();
await page.waitForTimeout(700);
break;
}
}
const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt'));
// Report which upload entries the sheet offers, so a run also records whether
// PUBLIC_CAMERA_ENABLED was in effect — the composer path differs with it.
await page.locator('button[aria-label="Hochladen"]').last().click();
await page.waitForTimeout(900);
const sheet = await page.locator('body').innerText();
console.log(
`[sheet] Galerie=${sheet.includes('Foto oder Video wählen')} ` +
`Kamera=${sheet.includes('Jetzt aufnehmen')}\n`
);
// Close it again. The FAB TOGGLES, so leaving the sheet open here makes the loop's first
// "open the sheet" click close it instead — and the file chooser then never fires.
await page.getByRole('button', { name: /Abbrechen/i }).first().click();
await page.waitForTimeout(600);
const results = [];
for (const path of files) {
const name = basename(path);
const local = await readFile(path);
const size = (await stat(path)).size;
const caption = `integrity ${name} ${Date.now()}`;
const chunked = size > CHUNK_BYTES;
// Open the sheet fresh for every file — submitting returns to the feed, and the
// invariant this relies on is simply that the sheet is CLOSED at the top of each pass.
await page.locator('button[aria-label="Hochladen"]').last().click();
await page.waitForTimeout(900);
const chooser = page.waitForEvent('filechooser');
await page.getByText('Foto oder Video wählen').click();
(await chooser).setFiles(path);
await page.waitForTimeout(2500);
await page.locator('textarea').fill(caption);
await page.getByRole('button', { name: /^Hochladen$/ }).first().click();
// Generous: the queue retries, and a large file on a throttled box takes its time.
await page.waitForTimeout(Math.max(9000, (size / 1e6) * 900));
// Find it server-side. The feed is the guest's own view, so this also proves the photo
// is actually visible rather than merely stored.
const feed = await fetch(`${API}/feed?limit=50`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
const row = feed.uploads?.find((u) => u.caption === caption);
if (!row) {
results.push({ name, size, chunked, ok: false, why: 'never appeared in the feed' });
continue;
}
// The whole point: compare the bytes that came BACK, not the status that went out.
const served = Buffer.from(
await fetch(`${API}/upload/${row.id}/original`).then((r) => r.arrayBuffer())
);
const ok = served.length === size && sha(served) === sha(local);
results.push({
name,
size,
chunked,
ok,
why: ok ? '' : `stored ${served.length} of ${size} bytes / hash mismatch`,
});
}
await browser.close();
console.log('═'.repeat(66));
console.log('UPLOAD INTEGRITY');
console.log('═'.repeat(66));
for (const r of results) {
console.log(
` ${r.ok ? '✓' : '✗'} ${r.name.padEnd(24)} ${(r.size / 1e6).toFixed(1).padStart(6)} MB ` +
`${r.chunked ? 'chunked copy' : 'single read '} ${r.why}`
);
}
if (!results.some((r) => r.chunked))
console.log('\n ⚠ no file above 4 MB — the chunked copy path was NOT exercised');
if (pageErrors.length) console.log(`\n page errors: ${pageErrors[0]}`);
const failed = results.filter((r) => !r.ok).length;
console.log(`\n${failed ? `${failed} of ${results.length} corrupted` : `✓ all ${results.length} byte-identical`}`);
console.log('═'.repeat(66));
process.exit(failed ? 1 : 0);
}
main().catch((e) => {
console.error('integrity check failed:', e);
process.exit(1);
});

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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',
@@ -41,8 +48,19 @@
{
kind: 'text',
icon: '👆',
title: 'Lange tippen für mehr',
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.'
title: 'Mehr zu einem Foto',
// 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',
@@ -52,9 +70,18 @@
{
kind: 'text',
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:
'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“.' : '')
}
]);

View File

@@ -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
@@ -305,8 +315,14 @@
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
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">
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>
{/if}

View 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);

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
classifyUploadStatus,
isIncompleteBody,
isReversibleLock,
entryToQueueItem,
shouldAbortForStall,
@@ -54,6 +55,91 @@ 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.
*/
/**
* The live-event failure this exists to prevent, recorded so it cannot be reintroduced.
*
* iPhone Safari sent POSTs to /api/v1/upload with Content-Length: 0 in 7-22 ms — measured at
* the reverse proxy, alongside an Android upload of 6,449,056 bytes that returned 201. Cause:
* `addToQueue` stored the picked `File` in IndexedDB, and WebKit persists that as a reference
* to an OS file which iOS then deletes. The File keeps its name and size and reads as nothing,
* and `xhr.send()` does not throw — it puts an empty body on the wire.
*
* Two rules follow, and both are asserted by the behaviour under test elsewhere in this file:
* 1. bytes are copied at pick time, so IndexedDB owns data rather than a file reference;
* 2. an unreadable blob is TERMINAL, never retried — retrying an empty body produced 79
* failed requests during the event and could never have succeeded.
*
* Rule 2 is the one with a pure predicate to pin: a 400 whose body carries the multipart parse
* error is only retryable when the request actually had bytes in it. `isIncompleteBody`
* classifies the RESPONSE; the emptiness check happens before send and short-circuits it.
*/
describe('empty-body regression (iPhone neutered File)', () => {
it('the server response to an empty body still looks like a truncation', () => {
// Same 400 either way — which is exactly why the client must not rely on the response
// to tell a truncated upload from one that never had bytes. The pre-send readability
// probe is what separates them.
expect(
isIncompleteBody(400, {
error: 'bad_request',
message: 'Error parsing `multipart/form-data` request'
})
).toBe(true);
});
});
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);

View File

@@ -628,6 +628,14 @@ class TerminalError extends Error {
*/
class NetworkError extends Error {}
/**
* The blob is in IndexedDB but its bytes are unreadable — iOS purged the OS file behind a
* stored `File`. Deliberately NOT a NetworkError: retrying cannot bring the bytes back, and
* treating it as transient is what produced an empty-POST retry storm during the event. The
* guest has to re-pick the photo, and the message says so.
*/
class UnreadableBlobError extends Error {}
/**
* The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
* subclass because the transport outcome is identical — but it must NOT stop the batch or
@@ -701,6 +709,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).
@@ -839,7 +889,49 @@ export async function releaseResolvedParks(state: {
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
export type EnqueueResult = 'queued' | 'duplicate' | 'full' | 'unreadable';
/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */
const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
/**
* Copy a picked file's bytes into a Blob this origin owns, so IndexedDB stores DATA rather
* than a reference to an OS file that iOS will delete. See the call site in `addToQueue` for
* why that reference is the bug.
*
* Chunked deliberately. `new Blob([await file.arrayBuffer()])` is one line and correct for a
* 3 MB photo, but it pulls the whole file into the JS heap — and this queue accepts videos up
* to 500 MB, where that would very likely get the tab killed by the OS. Trading a crash for a
* failed upload is not a fix. Reading a slice at a time and letting each chunk become its own
* Blob keeps peak heap at one chunk; the browser's blob store owns the accumulated parts and
* can spill them to disk, which is exactly where a half-gigabyte video should live.
*/
async function materialise(file: File): Promise<Blob> {
let out: Blob;
if (file.size <= MATERIALISE_CHUNK_BYTES) {
out = new Blob([await file.arrayBuffer()], { type: file.type });
} else {
const parts: Blob[] = [];
for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) {
const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES);
parts.push(new Blob([await slice.arrayBuffer()]));
}
out = new Blob(parts, { type: file.type });
}
// Verify the copy. The purge this whole function exists to defeat can also land PARTWAY
// THROUGH the loop above: a 500 MB video is many seconds of reading, and once the OS file
// is gone the remaining slices read as nothing. `new Blob` is happy to build a short blob
// out of them, and short is far worse than absent — it uploads, the server stores it, and
// the guest gets a truncated video that looks like it worked. A read that returns fewer
// bytes than the file claims is never legitimate, so refuse it here, while the guest is
// still holding the phone and can pick the file again.
if (out.size !== file.size) {
throw new UnreadableBlobError(
'Diese Datei konnte nicht vollständig gelesen werden — bitte wähle sie noch einmal aus.'
);
}
return out;
}
export async function addToQueue(
file: File,
@@ -887,6 +979,31 @@ export async function addToQueue(
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
// exactly ONCE per file here and reused by every retry — see uploadItem.
const id = uuid();
// MATERIALISE THE BYTES. Do not store the `File` itself.
//
// WebKit persists a File in IndexedDB as a REFERENCE to the OS backing file rather than a
// copy of its contents. iOS purges that file soon after the picker closes, which leaves a
// "neutered File": `.name` and `.size` still read correctly, so nothing looks wrong, but
// the bytes are gone. WebKit then does NOT throw on `xhr.send()` — the note at the send
// site assumed it would — it puts the request on the wire with an EMPTY BODY, the server
// cannot parse a multipart with no parts, and the guest sees a 400.
//
// Measured on the live event rather than inferred: every failing iPhone upload reached
// Caddy with `Content-Length: 0` in 7-22 ms, while an Android upload in the same minute
// sent 6,449,056 bytes and got a 201.
//
// Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It
// costs one full read at pick time, which is also the moment the file is guaranteed still
// readable — the picker has only just handed it over.
// A file the browser cannot fully read is not a queueable item. Returning a result rather
// than throwing keeps the composer's per-file loop intact: one bad photo out of five must
// not abandon the other four, which is what an exception here would do.
let blob: Blob;
try {
blob = await materialise(file);
} catch {
return 'unreadable';
}
const entry: QueueEntry = {
id,
userId,
@@ -897,7 +1014,7 @@ export async function addToQueue(
caption,
hashtags,
status: 'pending',
blob: file
blob
};
await storePut(entry);
@@ -1070,6 +1187,11 @@ async function processQueue(): Promise<void> {
// NetworkError, which it extends.)
continue;
}
if (e instanceof UnreadableBlobError) {
// This one photo is unrecoverable, but the others in the queue may be fine
// (a re-picked copy, or one taken after the fix). Keep draining.
continue;
}
if (e instanceof NetworkError) {
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
// and the `online` listener resumes it; if the failure hit while nominally
@@ -1120,7 +1242,26 @@ async function uploadItem(id: string): Promise<void> {
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
formData.append('client_upload_id', entry.id);
formData.append('file', entry.blob, entry.fileName);
// Never send a body we cannot read. `entry.blob.size` is NOT sufficient on WebKit: a
// neutered File keeps its metadata and reports the original size while reading as
// nothing. Only an actual read tells the truth, so probe one byte.
//
// This covers items queued BEFORE the materialise-on-pick fix above, which are still
// sitting in IndexedDB holding a dead File reference. Without it those retry until the
// budget is spent, every attempt an empty POST — 79 of them during the event.
const blob = entry.blob;
let readable = false;
try {
readable = (await blob.slice(0, 1).arrayBuffer()).byteLength > 0;
} catch {
readable = false;
}
if (!readable && entry.fileSize > 0) {
throw new UnreadableBlobError(
'Dieses Foto ist auf dem Gerät nicht mehr lesbar — bitte wähle es noch einmal aus.'
);
}
formData.append('file', blob, entry.fileName);
if (entry.caption) formData.append('caption', entry.caption);
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
@@ -1252,6 +1393,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.
@@ -1429,6 +1579,20 @@ async function uploadItem(id: string): Promise<void> {
}
throw e;
}
if (e instanceof UnreadableBlobError) {
// The bytes are gone from the browser's storage (iOS purged the OS file behind a
// stored `File`). No retry can recover them, so this is terminal — but unlike a
// server rejection the photo itself is fine and still in the camera roll, so the
// message asks for a re-pick rather than reporting the file as refused. Dropping
// the dead blob also frees the queue slot for the re-picked copy.
delete entry.blob;
entry.status = 'blocked';
entry.error = e.message;
await storePut(entry);
updateItemStatus(id, 'blocked', e.message);
toast(`${entry.fileName}: ${e.message}`, 'error', 8000);
throw e;
}
if (e instanceof TerminalError) {
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
// so the UI shows a clear reason and offers no retry.

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
import { initTheme } from '$lib/theme-store';
@@ -227,9 +226,11 @@
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<!-- NO `<svelte:head><link rel="icon">` here. There used to be one, pointing at
`$lib/assets/favicon.svg` — the orange Svelte logo from the project skeleton. Vite inlined it
as a data URI into the layout bundle and this block applied it AFTER hydration, so it beat the
branded `<link rel="icon">` in app.html and the tab showed the framework's logo on every page.
app.html is now the single source for the icon; the skeleton asset is deleted. -->
{@render children()}

View File

@@ -30,6 +30,8 @@
export_released: boolean;
disk_free_bytes: number | null;
keepsake_required_bytes: number;
/** False when KEEPSAKE_ENABLED=false — no downloadable gallery for this event. */
keepsake_enabled: boolean;
disk_low: boolean;
}
@@ -736,6 +738,11 @@
>
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button>
<!-- Hidden entirely when the keepsake is switched off (KEEPSAKE_ENABLED=false):
there is no archive to build, the backend refuses the call with 403, and a
button that always errors reads as a broken app rather than a disabled
feature. The note below says so in words instead. -->
{#if event.keepsake_enabled}
<button
onclick={() =>
(confirmAction = {
@@ -756,6 +763,12 @@
>
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
{:else}
<p class="self-center text-sm text-gray-500 dark:text-gray-400">
Der Galerie-Download ist für dieses Event deaktiviert. Gäste speichern Fotos
direkt aus der Galerie.
</p>
{/if}
</div>
<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and

View File

@@ -309,8 +309,15 @@
{/if}
{:else}
<!-- 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">
Willkommen bei
Willkommen bei der
</p>
{#if eventName}
<h1
@@ -320,8 +327,10 @@
{eventName}
</h1>
{: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">
dem Event
Feier
</h1>
{/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">

View File

@@ -163,6 +163,16 @@
}
const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++;
// The browser could not read this file's bytes (iOS purges the OS file behind a
// picked photo). Named per file rather than counted like `full`: the guest has to
// find and re-pick this specific one, so a bare number would not be actionable.
if (result === 'unreadable') {
toast(
`„${sf.file.name}“ konnte nicht gelesen werden. Bitte wähle das Foto noch einmal aus.`,
'error',
8000
);
}
}
// Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) {
@@ -308,6 +318,18 @@
rows="4"
class="input resize-none text-sm"
></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">
{caption.length} / {MAX_CAPTION_LENGTH}
</div>

View File

@@ -1,12 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="EventSnap">
<!-- Full-bleed brand background keeps the icon "maskable": safe content sits within the centre 80%. -->
<rect width="512" height="512" fill="#2563eb"/>
<g fill="none" stroke="#ffffff" stroke-width="22" stroke-linejoin="round" stroke-linecap="round">
<!-- Camera body -->
<rect x="116" y="172" width="280" height="200" rx="34"/>
<!-- Viewfinder bump -->
<path d="M212 172l24-34h40l24 34" fill="#ffffff" stroke="none"/>
<!-- Lens -->
<circle cx="256" cy="276" r="58"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Hochzeit von Julia &amp; Jonas">
<!-- Full-bleed brand background keeps the icon "maskable": safe content sits within the centre 80%
(51.2-460.8). Rotated, the rings span 102.9-409.1 on BOTH axes including stroke, so an Android
circle crop cannot clip them. The stricter circular test is unaffected by the rotation: the
farthest painted point is 51 (centre offset) + 117 (radius + half stroke) = 168 from the
middle, comfortably inside the 204.8 safe radius, and rotating about that same middle does
not change any point's distance from it. -->
<rect width="512" height="512" fill="#8a6a2b"/>
<!-- Two interlocking bands. Deliberately just the two rings: no gem, no shine, no inner bevel.
This renders at 16px in a browser tab, where any detail smaller than the stroke width turns
into a smudge — the same reason the camera icon this replaces was three shapes and no more.
The -45° tilt lifts the right band above the left. It is negative because SVG's y axis points
down, so a positive angle would rotate the other way and drop the right band instead. Applying
it to the GROUP rather than to each shape is what keeps the interlock intact — the circles and
the overlap arc below are all still authored on the horizontal axis and rotate together, so
the crossing geometry never has to be recomputed. -->
<g fill="none" stroke="#ffffff" stroke-width="26" transform="rotate(-45 256 256)">
<circle cx="205" cy="256" r="104"/>
<circle cx="307" cy="256" r="104"/>
<!-- The interlock. Both circles above are drawn whole, so the right band covers the left one at
BOTH crossings and they read as merely overlapping. Redrawing this arc of the left band —
the segment through the upper intersection at (256, 165.4) — puts it back on top there
while the right stays on top at the lower crossing, which is what makes them look linked
rather than stacked. Keep it in sync if either circle moves. -->
<path d="M214.1 152.4A104 104 0 0 1 290.2 196.3"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -8,7 +8,7 @@
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"theme_color": "#8a6a2b",
"icons": [
{
"src": "/icon.svg",