Commit Graph

31 Commits

Author SHA1 Message Date
fabi
9666d74a46 fix(export): close all 13 findings from the multi-agent review
A 6-lens multi-agent review (103 agents; every finding adversarially verified by three
refute-by-default skeptics) confirmed the epoch redesign is sound — the core invariant
holds — and found 13 real defects around it. All are fixed here.

The redesign's invariant was re-verified and stands: an accepted upload is always in the
keepsake, and a downloadable keepsake always reflects the most recent release.

But a WEAKER adjacent invariant did NOT hold — "a downloadable keepsake reflects the
current content" — and that is the theme of the biggest fixes.

CONTENT REMOVAL NOW ALWAYS REACHES THE KEEPSAKE
  Regeneration was wired only to host deletes. Ban, unban, guest self-delete and guest
  comment-delete did not trigger it — yet the export query filters `is_banned = FALSE`,
  so the pipeline already agreed that content must not be there. Ban someone for abusive
  content after release and every guest kept downloading an archive containing it.
  All five removal paths now invalidate and rebuild. `query_comments` also gained the
  banned/hidden filter it was missing entirely (the ZIP had it; the viewer did not).

  Each removal is now ATOMIC with its invalidation (one tx, models made executor-generic).
  Previously the delete committed in its own tx and the regeneration in a second: a dropped
  handler future between them left the taken-down photo permanently downloadable, and
  nothing could notice — the keepsake still looked complete and the host could no longer
  even find the upload to retry.

NO MORE TAKEDOWN STORM
  Every removal spawned two uncancellable full exports. A superseded worker was INERT, not
  STOPPED: it still ran every ffmpeg spawn and image resize and wrote a whole archive before
  discovering it had lost. Five deletes left ten workers alive, each holding a
  full-gallery-sized temp, in a 1 GB container.
  Now: a debounce before `claim_job` (a superseded worker fails its claim and does ZERO
  work, collapsing a burst into one export), plus `update_progress` — which already ran
  exactly the right liveness predicate and threw the answer away — returning it so both file
  loops bail the instant they are retired. Moderating a comment no longer rebuilds the
  multi-GB ZIP either: the ZIP is media-only, so it is carried forward, with prune taught to
  protect any file a live row still references.

AN ESCAPE HATCH
  A failed export was terminal at runtime: release_gallery refuses an already-released event,
  recovery only runs at boot, and there was no retry route — the only outs were restarting the
  container or reopening uploads to every guest. Added POST /host/export/rebuild. Also widened
  mark_failed's guard to `status IN ('running','pending')`: when claim_job itself ERRORED, the
  row was still `pending`, so the failure was never recorded and the job sat at 0% forever.

SILENT INVALIDATION
  Retiring the epoch 404s the download instantly, but nothing told the clients — guests kept
  seeing an enabled download button through the whole rebuild. Now broadcasts an invalidation.
  And the download was a top-level <a href>: a 404 (or a 429 from the per-IP export limit that
  everyone on the venue WiFi shares) NAVIGATED THE USER OUT OF THE PWA onto raw JSON. It now
  targets a hidden iframe, so an error response is harmless.

ALSO
  - The HTML export still had the exists()-then-open TOCTOU the ZIP path was fixed to remove,
    at three sites, two with a hard `?` that failed the ENTIRE viewer. It is reachable: the
    compression worker hard-deletes an original when a transcode fails and can still be running
    when the gallery is released.
  - Crash-orphaned .tmp files were immortal (prune deliberately skips .tmp). Swept at boot,
    where it is unambiguously safe.
  - export_status read the epoch in one statement and used it in the next; now one query.
  - DiskCache::snapshot IGNORED its path argument on a cache hit, returning whatever filesystem
    was measured last. Latent only because both callers pass media_path — it would have silently
    misreported the moment anyone measured the exports volume. Now keyed by path.
  - Corrected two comments that asserted safety properties the code does not have (claim_job
    does NOT prevent a retired-epoch claim — retirement is enforced at READ time; and a
    multi-replica reap is NOT contained, it can corrupt the keepsake). Added a rollback runbook
    to migration 014, the repo's first destructive migration.

TESTS
  The regression guard for the headline FOR SHARE fix was probabilistic — it could pass on
  broken code if the interleaving fell the wrong way. It is now STRUCTURAL: the upload is held
  open mid-body with its pre-flight check already passed, so the release provably lands inside
  the exact window. Verified 5/5 FAILING (201 instead of 403) with the fix reverted, 5/5
  passing with it.

Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:35:47 +02:00
fabi
8e906d7866 refactor(export): single epoch authority; fix upload-path TOCTOU losing photos
A deep investigative review of the export concurrency (four parallel adversarial
reviewers) found that three rounds of race fixes had been chasing the wrong bug, and
that the model itself was the root cause. This replaces the model and fixes the real
data loss.

THE ACTUAL BUG (upload path, not the state machine)
  `upload` checked `uploads_locked_at` BEFORE streaming the body — minutes, for a large
  video — then committed the row without re-checking. A release landing mid-upload let the
  row commit AFTER the export snapshot: the photo appeared in the live feed and was
  PERMANENTLY missing from the keepsake, with nothing to regenerate it. release_gallery's
  comment claims to prevent exactly this; it never did. Every prior round fixed the DB
  state machine, and the leak was upstream of it.
  Fix: re-assert the lock under `SELECT ... FOR SHARE` INSIDE the commit tx. That row lock
  conflicts with release_gallery's `UPDATE event`, so either the upload commits first (and
  the release — hence the snapshot — is ordered after it) or the release wins and the
  upload is rejected as `uploads_locked` (reversible; the client keeps the blob).
  Regression test verified NON-VACUOUS: with the fix reverted it fails, reporting accepted
  uploads missing from the keepsake.

THE MODEL (migration 014)
  "Which generation is current?" had no single answer: it was assembled from three
  separately-written pieces across two tables (`export_released_at`, a per-job
  `release_seq`, and cached `export_{zip,html}_ready` flags). Keeping them in agreement
  took ~10 hand-written guards; each review round found one more path that slipped through.
  Now: ONE monotonic `event.export_epoch`, bumped in the same UPDATE as any change to
  `export_released_at`. Each job carries a copy. Downloadable IFF
  `released AND job.epoch = event.export_epoch AND status = 'done'` — readiness DERIVED,
  never stored. A retired-epoch worker is INERT BY CONSTRUCTION: losing a race can no
  longer corrupt state, only waste work.
  Deleted: both ready-flag columns, both ready-flip blocks, `if ready { continue }`, the
  reopen seq-bump, open_event's transaction, and the cross-table claim guard.

  That last one was also UNSOUND: under READ COMMITTED, a blocked UPDATE re-evaluates its
  WHERE against the updated target row but answers subqueries on OTHER tables from the
  original snapshot — so the previous `EXISTS (SELECT ... FROM event ...)` claim guard
  could admit a claim against an already-reopened event while RETURNING the post-bump seq.
  Every guard is now same-row (`epoch = $n`), which EPQ re-evaluates correctly.

OTHER REAL DEFECTS FIXED
  - release_gallery committed the release and THEN awaited the enqueue inside the handler
    future. Axum drops that future on client disconnect → event released, uploads locked,
    ZERO export_job rows, host unable to retry ("bereits freigegeben"), restart-only
    recovery. Now one tx; workers spawn (detached) after commit.
  - No flush/fsync before the tmp→final rename. async_zip's close() never flushes the inner
    file and tokio's File drop DISCARDS write errors — so a full disk silently produced a
    truncated archive that was then marked done and published, after which the last good
    generation was pruned. Now flush + sync_all, which also surfaces ENOSPC.
  - Download read the ready flag and the file path in TWO queries; a reopen between them
    served a keepsake that should 404. Now one read through the `export_current` view.
  - `if !src.exists() { continue }` then `open()?` — a TOCTOU that turned a tolerated gap
    into a hard failure of the ENTIRE keepsake (unretryable while released). Now open-first,
    warn, and skip.
  - Recovery was flag-driven and never checked the disk: a `done` row whose archive was gone
    was a permanent dead end needing manual DB surgery. Now verifies the file and re-arms.
  - Temp files/dirs leaked on every error path (the leak is what fills the disk that
    corrupts the next archive). Now cleaned up.
  - Export archives were not event-scoped (`viewer_tmp_` already was), so a second event
    would collide on paths and one event's prune would delete another's live keepsake.
  - Host deletes after release never regenerated the keepsake — a photo removed on request
    lived on in the archive forever. Now bumps the epoch and rebuilds without it.
  - claim_job swallowed DB errors as "someone else won", stranding a job pending at 0%.
  - export_status reported retired-epoch rows (a progress bar that never moves).
  - The startup sweep's single-instance assumption is now documented, not hidden.

Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop (incl. 2 new regressions; the
upload-acceptance one confirmed to fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:48:38 +02:00
fabi
9f3712894d fix(export): pin export workers to a live release epoch; make reopen atomic
Adversarial re-review of c197b2c found the previous fix both incomplete AND that it
introduced a new stuck-state regression. Two independent reviewers flagged the latter.

HIGH (data loss, still open after the last fix) — a worker could claim a LIVE seq
after a reopen. `claim_job` took any `pending` row. If a reopen landed between
`release_gallery`'s spawn and the worker's claim, the row was left pending at the
*bumped* seq, so the worker claimed a CURRENT generation and spent minutes exporting a
snapshot taken while the event was open and guests were still uploading. On the next
re-release (which re-arms `export_released_at` BEFORE it bumps the seq) that worker's
finalize and ready-flip both passed their guards, flipped ready on its stale snapshot,
and made `enqueue_and_spawn_exports` skip regeneration — silently serving a keepsake
missing every upload from the reopen window. The reopen seq-bump did not help: the
worker's seq was not stale.
  Fix: `claim_job` now refuses to claim unless the event is still released. A worker's
  generation is therefore always born AND finalized inside a single live release epoch.
  The unclaimed pending row is harmless — the next release resets and re-spawns it.

HIGH (regression I introduced in c197b2c) — `open_event`'s event-UPDATE and its
export_job seq-bump were two autocommit statements. The first is exactly what re-enables
`release_gallery`, so a re-release could slip between them: it spawns a FRESH worker at
seq N+1, then our second statement bumps that live generation to N+2, orphaning the
worker. Its seq-guarded finalize/fail/flip then all match nothing, the row is stranded
`running` with no owner, and the host cannot retry ("bereits freigegeben") — only a
process restart recovers it.
  Fix: both statements now run in one transaction, so the row lock on `event` serializes
  `release_gallery` behind the commit.

LOW
  - The flip-lost path left its stale archive on disk (asymmetric with the finalize-lost
    path, which deletes it). If a host reopened and never re-released, no future
    generation would ever prune it. Now discards `out_path` and still sweeps older gens.
  - A reopen clears the ready flags server-side, but nothing told the clients: the nav and
    /export page kept advertising a keepsake that now 404s. Both now refresh on
    `event-opened` (a superseded worker deliberately emits no `export-progress`).

Tests
  - New: a release broadcasts `export-progress` 100 for both types + one `export-available`.
    The export SSE had ZERO e2e coverage, and this round made the emit conditional —
    `/export/status` polling reads the job row, not the SSE, so a regression here would
    have left every other test green while the live UI silently stopped updating.
  - New: open ‖ release churn always converges to a consistent, downloadable keepsake.
    Honestly labelled a STRESS test, not a regression guard: verified it still passes
    against a deliberately non-transactional build even with ~90 concurrent pairs, so it
    does NOT cover the atomicity fix (that window is not reproducible out-of-process).
  - Verified the reopen-supersession test is non-vacuous by empirically reverting the
    seq-bump — it fails, as intended.

Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 158 passed / 1 skipped on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:35:25 +02:00
fabi
d643256f36 fix(rereview): close residual export finalize↔flip double-race at reopen
Adversarial re-review of df275bb found the two-guard ready-flip fix still left a
narrow double-race open, plus test-hygiene drift from the banUser param removal.

MED — residual stale-keepsake race the `export_released_at` guard alone missed
  The flip relied on two guards defeating two clearers: the `release_seq` EXISTS
  check (vs a re-release bump) and `export_released_at IS NOT NULL` (vs open_event's
  clear). But release_gallery re-arms `export_released_at = NOW()` and bumps
  `release_seq` as SEPARATE statements, so a stale worker's flip landing in the gap
  between them satisfies BOTH guards (released re-armed, seq not yet bumped) and
  resurrects a pre-reopen keepsake — the next re-release then skips regeneration and
  serves an archive missing the reopen-window uploads.

  Root-cause fix: `open_event` now bumps every `export_job.release_seq`, making a
  reopen a supersession point symmetric with a re-release. A worker that captured the
  pre-reopen seq can never match its `release_seq`-guarded finalize/flip again,
  regardless of when the re-release re-arms `export_released_at`. The released-anchor
  guard stays as defense-in-depth. Added a deterministic e2e asserting the reopen
  bumps the seq (the invariant that closes the race without depending on
  sub-millisecond worker timing).

LOW
  - Gate the `export-progress: 100` SSE + `prune_stale_export_files` on the ready-flip
    actually flipping (rows_affected > 0). A superseded worker no longer advertises a
    misleading 100% or prunes on a fresh generation's behalf.
  - moderation.spec.ts: the two ban tests had become byte-identical after the
    hide_uploads param removal; drop the one whose "hide_uploads=true" title no longer
    matched what it exercised, keep the accurate "always hides" test.
  - host-dashboard page-object: drop the dead `banUser(hideUploads)` param + its
    checkbox branch (the UI no longer renders that checkbox — a latent hang trap).
  - sse-eviction.spec.ts: rename the test whose title referenced the removed flag.

Verified: backend 40 tests, e2e 156 passed / 1 skipped on chromium-desktop
(incl. the new reopen-supersession regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:06 +02:00
fabi
df275bbefa fix(rereview): close export-flip race + queue-dedup reload regression
Adversarial re-review of the persona-audit + audit-followup rounds (6411747..)
found one HIGH and one MED regression plus LOW gaps. All fixed with coverage.

HIGH — export stale-keepsake resurrected by an open_event race
  A reopen landing in the window between a *current* export worker's finalize_job
  and its ready-flag flip cleared export_released_at + the ready flags but left the
  export_job row `done` at the same release_seq. The seq-guarded flip then still
  matched and re-set export_{zip,html}_ready=TRUE on a pre-reopen snapshot; the next
  re-release read that stale TRUE and skipped regeneration (`if ready { continue }`),
  serving a keepsake missing every upload from the reopen window — the exact data
  loss migration 012 exists to prevent. Both ready-flip UPDATEs are now additionally
  anchored on `export_released_at IS NOT NULL`, so a landed reopen makes the flip a
  no-op and the re-release regenerates cleanly.

MED — queue dedup broke for reloaded items
  loadQueue rebuilt QueueItems from IndexedDB without copying lastModified, which the
  new addToQueue dedup keys on. A file re-selected after a page reload / PWA relaunch
  missed the duplicate check and uploaded twice. Rehydration now carries lastModified
  (extracted to a pure, tested entryToQueueItem helper).

LOW
  - diashow: clear the upload-processed debounce timer in onDestroy (no stray
    post-unmount /feed fetch).
  - USER_JOURNEYS §9.5: document the reconnect-delta ban replay (hidden_user_ids /
    uploads_hidden_at, migration 013), not just the live user-hidden SSE.
  - e2e api-client: drop the misleading hide_uploads param from banUser — the backend
    takes no body and always hides; strip the dead boolean at all call sites.

Tests
  - Extract isReversibleLock (the terminal-403 KEEP-vs-PURGE-blob discriminator) into a
    pure exported helper + unit tests, so the data-loss-critical branch is covered
    without an XHR harness.
  - entryToQueueItem unit tests lock the lastModified-carry regression.
  - Document the export flip-race guard in the reopen/re-release spec (the sub-ms
    finalize↔flip interleave isn't deterministically forceable with fast fixtures;
    covered by the SQL guard + the end-to-end completeness test).

Verified: backend 40 tests, frontend 44 unit tests, svelte-check 0 errors,
e2e 156 passed / 1 skipped on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:47:44 +02:00
fabi
811c724685 fix(audit-followup): close regressions/gaps from the persona-audit re-review
Adversarial re-review of the persona-audit commit surfaced two HIGH regressions I'd
introduced plus MED/LOW gaps. All fixed:

HIGH
- Auth privilege escalation: reads are sessionStorage-first, but guest `setAuth` wrote only
  localStorage — a resident admin token shadowed a new guest login (guest ran as admin on a
  shared device). setAuth/setAdminAuth now DISPLACE the other store so exactly one identity
  is resident. Adds unit + e2e regression guards.
- Host dashboard: `exportGenerating` used `||`, so a one-half export failure stuck on
  "wird erstellt…" forever and never showed the re-release hint. Changed to `&&`.

MEDIUM
- Locked-upload auto-resume was unreliable (`event-opened` has no reconnect replay and SSE
  is down on non-feed pages) — now also resumes on `feed-delta`, which fires on every SSE
  reconnect.
- Queue-cap eviction could drop a recoverable locked item (parked as `error` with a live
  blob) — now evicts only `done`/`blocked`.
- Host page async-`onMount` leaked SSE handlers if unmounted mid-load — added a `destroyed`
  guard before registration.

LOW
- An unparseable 403 body now keeps the blob (reversible) instead of purging it.
- `refreshEventState` is sequence-guarded so a late close-refresh can't clobber a reopen.
- `/export/status` moved out of the all-or-nothing `reload()` so its failure can't blank the
  whole host dashboard.

Verified: svelte-check 0 errors, 36 frontend unit tests (incl. new displacement guards).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:19:51 +02:00
fabi
36fe59caa5 fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session
Follows the perf + security + user-flow work with a role/persona audit (guest, host,
admin, projector) and fixes across three review rounds. Highlights:

HIGH
- Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has
  no replay, so a client that missed it (esp. the unattended diashow) kept cycling a
  banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in
  /feed/delta; feed + diashow evict those users. Applied even on a truncated delta.

MEDIUM
- Locked-upload data loss: a photo staged offline during a lock/release was purged as a
  terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code;
  the queue keeps the blob and auto-resumes on the `event-opened` SSE.
- Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake.
- Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never
  in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host
  race can't hand out a conflicting PIN.
- Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren
  hidden on peer-host rows for non-admins (they always 403'd).
- Host dashboard shows live keepsake generation progress / ready state + link to /export.
- Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices.

Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq`
(migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation
temp/final paths, download follows `file_path`, prune only strictly-older generations.

LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles
galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401;
delta `>=` tie-break + 429 retry; misc copy/labels.

Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a
data-completeness test. Reconciles USER_JOURNEYS §9/§11.

Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit
tests, 155 e2e passing on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:06:28 +02:00
MechaCat02
641174717c fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m52s
E2E / Cross-UA smoke matrix (push) Failing after 3m50s
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:

- Offline upload queue auto-resumes on reconnect: network errors keep items
  pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
  returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
  re-release regenerates the keepsake; workers claim their job atomically so a
  reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
  exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
  revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
  is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
  not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
  live; feed_delta rate-limited; like returns {liked, like_count} to fix
  multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
  quota increment is transactional; operator floor.

Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:05:37 +02:00
fabi
a4b2c5bd1c fix(security): harden authz, media gating, recover, SSE, deps
Security audit follow-up. No Critical/High existed; these close the
Medium/Low findings.

F1 [Med] Host privilege boundary: a host could demote a peer host to
  guest and then ban / PIN-reset (→ account takeover via /recover) them,
  because the ban/pin-reset peer guards key off the target's *current*
  role. set_role now blocks a non-admin from demoting a host.

F2 [Med] Moderation now revokes preview/thumbnail access: they are served
  through visibility-checked aliases (/api/v1/upload/{id}/{preview,
  thumbnail}) that filter soft-deleted / ban-hidden uploads, and direct
  /media/previews|thumbnails is 404-blocked. Feed emits the gated URLs;
  Caddy keeps them privately cacheable (max-age=300, short so moderation
  revokes promptly — the live feed already evicts cards via SSE). Uses a
  lean find_visible_media() (paths+mime only) on the media hot path.

F3 [Med/Low] npm audit fix: svelte 5.55.1→5.56.4 (SSR-XSS advisories),
  devalue 5.6.4→5.8.1 (DoS). Prod deps: 0 vulnerabilities.

F4 [Low] /recover no longer leaks account existence: unknown display name
  returns the same 401 as a wrong PIN, and runs a dummy bcrypt verify so
  timing matches — closing the enumeration + timing oracle.

F5 [Low] SSE streams re-validate the session every ~60s and close once it
  is logged out / expired, instead of running until client disconnect.

F6 [Info] X-Content-Type-Options: nosniff set at the app layer on all
  media responses (defense-in-depth alongside the edge).

Tests: new e2e specs for F1 (host cannot demote peer host), F2 (preview
gated + 404 after delete + direct /media blocked), F4 (uniform 401).
40 backend unit tests + 182 e2e passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 06:14:00 +02:00
fabi
d6c91974eb perf(backend): close config/upload/export hot paths + stability fixes
Performance:
- Cache the runtime `config` table in-memory (ConfigCache) with synchronous
  invalidation on every write (admin PATCH + test reseed). Was re-reading each
  key from Postgres on every request (~8 round-trips per upload).
- Stream uploads chunk-by-chunk to a temp file instead of buffering the whole
  body in RAM (peak was up to the per-class cap, e.g. 500 MB/video); only 512
  sniff-bytes are kept for magic-byte detection, then atomic rename into place.
- Cache the media-filesystem disk snapshot (DiskCache, 15s TTL) shared by the
  quota check and admin stats; drop the discarded System::refresh_all().
- HTML export streams video (and small-image) originals straight into the ZIP
  via a manifest instead of copying them to a temp dir first (removed the
  transient 2x disk usage) and drops the double directory scan.
- Auth extractor resolves session -> live user in one JOIN (was two queries),
  touching last_seen_at by token hash.

Stability:
- SSE: on broadcast lag, emit a `resync` event so the client runs a delta
  fetch instead of silently losing events; frontend reconciles adds, deletions,
  and (via an in-place refresh) like/comment counts on visible cards.
- Storage quota fails OPEN when the disk can't be read (was a 0-byte limit that
  locked out all uploads).
- Graceful shutdown drains in-flight requests on SIGTERM/SIGINT, bounded by a
  10s backstop so open SSE streams can't stall a deploy.
- Upload removes the persisted file if the DB transaction fails (no orphaned
  bytes with no row to reclaim them).

Tests:
- New pure select_disk() with 5 unit tests (longest-prefix, fallbacks, fail-open).
- New e2e export-video spec covering the HTML export's video-streaming branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:26:45 +02:00
fabi
faf7a2504a fix(audit): restore broken upload pipeline + role-based E2E audit fixes
A comprehensive role-based E2E audit (guest/host/admin, across browser
sessions) surfaced one critical and several smaller issues; this addresses
them and hardens the tests that missed them.

Critical
- The client upload pipeline was fully broken: the IndexedDB v1->v2 upgrade
  opened a *new* transaction inside the upgrade callback, which throws during
  a version-change transaction and aborted the whole upgrade, leaving the
  queue object store uncreated -- so no UI upload ever fired. Reuse the
  version-change transaction the callback provides, and bump the DB to v3 with
  a contains() guard so installs already corrupted by the shipped bug
  self-heal on next load. Re-enabled the previously-fixme'd UI upload E2E test.

High / Medium
- Event lock is uploads-only again: likes, comments and browsing stay open
  while the event is locked (USER_JOURNEYS 9.3 / FEATURES) -- it was wrongly
  freezing social interaction. Updated the event-lock spec accordingly.
- get_original now excludes soft-deleted and ban-hidden uploads, and direct
  /media/originals/** serving is blocked, so a hidden user's originals can no
  longer be pulled by UUID (all originals go through the checked alias).
- The upload handler reads the file field with an early-abort size cap chosen
  from the declared content-type, instead of buffering the entire body before
  the size check.

Low
- unban_user mirrors the ban role guard (a host can no longer unban a
  host/admin banned by an admin).
- reset_user_pin's UPDATE is event-scoped.
- Admin login returns and stores a real identity (user_id + display name)
  instead of a blank session.
- The host user list no longer renders target-actions (ban/promote/demote/PIN)
  on the caller's own row, where the backend always rejected them.
- /diashow gains a client-side auth guard like the other protected routes.
- The join page shows the event name via a new public GET /api/v1/event.

Verified: backend cargo build clean, frontend svelte-check 0 errors, full
Playwright E2E suite 144 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 07:28:27 +02:00
fabi
3f6dafba05 test(review-2): strengthen CR2 export-leak test to drive a real export
The prior test asserted 404 on /media/exports/Gallery.zip against an empty
stack — it would pass even if exports were still written under /media, because
no archive was ever produced. Now it:
  1. seeds an upload, releases the gallery, and polls until the real zip job
     writes Gallery.zip to disk;
  2. asserts the archive is NOT served from public /media (the CR2 leak); and
  3. asserts it IS retrievable via the gated ticket endpoint (200 + PK zip
     magic) — proving the 404 means "not public", not "no file".

Also fixes a test-isolation gap the CR2 relocation introduced: __truncate wiped
media_path but not export_path, so a real export would leave Gallery.zip on disk
and break export.spec's "ready-but-file-missing → 404" test. truncate_all now
purges export_path too. Full 06-export dir: 5/5 green, no contamination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:19:09 +02:00
fabi
0ed97f45cf fix(review-2): address re-review — close two blocker regressions + harden tests
The round-2 re-review (full suite green) found two real defects the passing
tests missed, both now fixed and covered:

B1 — banned users could still edit/delete their OWN content: edit_upload,
     delete_upload, delete_comment skipped the is_banned guard that upload/
     like/comment got (round-2 removed the global extractor block and missed
     these three). Added the guard (using auth.is_banned — no extra query).
     The moderation ban test now asserts 403 on DELETE upload, PATCH upload,
     and DELETE comment for a banned owner (+ upload survives) — it previously
     only exercised POST /upload, which is why the gap shipped green.

B2 — H3 live-eviction was dead code: 'user-hidden' and 'comment-deleted' were
     missing from KNOWN_EVENTS, so EventSource never listened and the handlers
     never fired. Added both. New browser-driven sse-eviction test asserts a
     hidden user's card actually evicts from an open feed with no reload — the
     backend-only SseListener checks couldn't catch this.

Minor items folded in:
- admin dashboard bounces a mid-session-demoted admin to /feed via live
  /me/context (mirrors the host page) instead of a dead error screen.
- feed "new posts" pill cleared on any full refresh (pull-to-refresh no longer
  strands it).
- corrected the stale sse.rs comment (banned users may hold a read-only stream).

Verified: backend 35 unit tests; svelte-check 0 errors; e2e 04-host + comment-ui
+ export-leak 13/13 on chromium (incl. the two new regression tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:45:57 +02:00
fabi
b3d876fa85 fix(review-2): medium/low + docs — config validation, hygiene, doc sync
- compression_concurrency wired to boot config (state.rs) and removed as a
  dead admin control.
- patch_config gains per-key integer/range validation so numerically-valid-but-
  nonsensical values (-1/NaN/3.5) are rejected instead of silently reverting to
  default at read time.
- clearAuth now also clears the display name (shared-device residual).
- e2e/Caddyfile.test mirrors prod security headers + the SSE encode-exclusion so
  the test stack is representative and can catch header regressions.
- .gitignore: ignore root-level Playwright artifacts (test-results/, report).
- docs: USER_JOURNEYS §10 and SECURITY-BACKLOG updated to match the implemented
  read-only-ban behavior and the export-path fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:56 +02:00
fabi
80179357a7 fix(review-2): high — read-only ban model, failed-compression cleanup, live SSE eviction
H1: corrected a round-1 over-reach. Bans are read-only per USER_JOURNEYS §10:
    the base AuthUser extractor no longer rejects banned users (they keep read
    access); Require{Host,Admin} and write handlers enforce the ban via
    auth.is_banned → 403 (incl. self-unban). Demoted hosts still lose powers
    immediately (live DB role) and are bounced off the dashboard. Also fixed the
    login/recover redirect regression on unauthenticated 401.

H2: failed compression now self-heals instead of leaving a permanent broken
    card — refund the quota, delete the orphaned original, soft-delete the row;
    the frontend toasts the uploader and evicts the card on upload-error.

H3: self-delete, ban-with-hide (user-hidden), and comment-deletes now broadcast
    SSE; feed, diashow, and lightbox evict the affected content live instead of
    waiting for a reload. sse-eviction.spec.ts covers it.

Riders: feed/+page.svelte also carries the medium truncated-delta pill; host.rs
also carries the low atomic release_gallery claim; admin/+page.svelte also drops
the dead compression_concurrency control (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:43 +02:00
fabi
dba4d3f932 fix(review-2): critical — repair comment posting + close export data leak
CR1: the lightbox comment button POSTed to /upload/{id}/comment (singular);
     no such route exists, so every UI-submitted comment 404'd and was lost.
     Fixed to /comments (plural). The prior e2e passed because its seed helper
     POSTs the API directly — added comment-ui.spec.ts which drives the real
     component so this can't regress silently again.

CR2: export archives (Gallery.zip / Memories.zip / HTML viewer) were written
     under media_path, which is a public ServeDir — so GET /media/exports/
     Gallery.zip served the entire event (every photo, caption, comment,
     uploader name) to any anonymous visitor at a guessable URL, bypassing the
     ticket + release gate. Moved exports to a separate EXPORT_PATH (=/exports)
     on its own volume (Dockerfile chown, compose volume, gated handler reads
     the new path). export-leak.spec.ts asserts /media/exports/Gallery.zip → 404.

Riders (git can't split hunks): LightboxModal also gains H3 live-eviction on
comment-deleted/upload-deleted; config.rs also wires compression_concurrency
from boot config (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:32 +02:00
fabi
41ac742af8 test+docs: tighten H1 assertions, cover secret/XFF edges, document boot & SSE-ban
Re-review follow-ups (non-blocking quality items):

Tests:
- moderation H1 specs now assert exact `→ 403` instead of bare .rejects.toThrow(),
  so a spurious 500 can no longer masquerade as "revocation worked".
- config: added case-insensitivity (upper/mixed-case placeholder) and the
  len==32/31 boundary cases for validate_secrets.
- rate_limiter: added the trailing-comma empty-entry case for client_ip (must
  fall back, not return "").
  (Backend unit tests: 35 pass.)

Docs:
- README: note that with APP_ENV=production a placeholder .env makes the app
  refuse to boot and Caddy wait unhealthy — reason is in `docker compose logs app`.
- SECURITY-BACKLOG + sse.rs comment: document that a mid-session ban does not
  tear down an already-open SSE stream (new tickets are blocked; low blast radius).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 07:14:33 +02:00
fabi
91ff961850 fix(security): medium/low — event-lock, atomic config, a11y, diashow, hygiene
- social: likes/comments rejected on a closed event (e2e proven).
- admin: patch_config validates-then-writes atomically; char-based length.
- hashtag/comment models: atomic tag writes in edit + comment paths.
- Modal: inert the background (modal-inert action) for screen readers.
- sse.ts: idempotent visibilitychange listener (no duplicate reconnects).
- diashow: videos advance on `ended` (transitions + page wiring).
- docs/hygiene: README clone-case fix; removed stale committed .env.test and
  gitignored it; docker-compose.dev.yml tidy.

Left documented as accepted-risk per plan: PIN-persist-after-logout,
UUID-reachable hidden uploads, DECISION-media-auth.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:58 +02:00
fabi
f08d858281 fix(security): high — live authz, XFF, SSE buffering, atomicity, pagination
H1: auth extractor re-reads the live user row — trusts the DB role (not the
    JWT claim) and rejects banned users mid-session. e2e proves a demoted
    host loses powers and a banned host is locked out and can't self-unban.
H2: client_ip takes the right-most XFF hop (the one Caddy appends); spoofed
    left-most entries are ignored, restoring IP-based throttles.
H3: Caddy excludes /api/v1/stream from `encode` so SSE isn't buffered.
H4: upload — quota increment + row insert + hashtag links now one txn.
H5: feed keyset pagination tiebroken on (created_at, id) + composite index
    (migration 010); feed_delta bounded with LIMIT.
H7: LightboxModal keys its comment load off upload.id, ending the
    SSE-driven refetch storm.
H8: CSP via SvelteKit kit.csp (svelte.config.js) hardens the localStorage
    token model against injection.

Migration 010 also adds the latent comment/comment_hashtag indexes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:50 +02:00
fabi
498b4e256b fix(security): critical — repair PIN reset + enforce real prod secrets
C1: reset_user_pin wrote to a non-existent column (pin_failed_attempts);
    the real column is failed_pin_attempts, so every PIN reset 500'd. Fixed
    the column name; new e2e (pin-reset.spec.ts) proves a reset returns a
    usable PIN and the target can recover with it.

C2: config.rs::validate_secrets now rejects placeholder-ish secrets
    (change_me/dev_secret/placeholder), enforces len>=32 in prod, and
    requires a real ADMIN_PASSWORD_HASH. docker-compose.yml sets
    APP_ENV=production so the guard actually runs. Corrected the false
    "fixed" claim in SECURITY-BACKLOG.md. .env.example documents the rule.

Riders in these files (documented here since git can't split hunks):
- host.rs also carries the event-scoped ban_user fix and the
  close_event/open_event no-op broadcast guard (medium).
- docker-compose.yml also adds ORIGIN (H6), app/frontend healthchecks with
  Caddy waiting on health, and per-service memory limits (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:39 +02:00
fabi
74849c8d50 test: cover grid filter OR/AND (extract pure fn + unit + real e2e)
The two filter-search cases were test.fixme placeholders (expect(true).toBe(true)),
so the OR/AND chip rules were untested.

- Extract the grid-filter predicate from feed/+page.svelte into a pure
  filterUploads(uploads, filters) in $lib/feed-filter (behavior-preserving) and
  unit-test it (9 cases): tag OR, user OR, tag+user AND, AND-excludes-partial,
  case-insensitive caption match, null caption.
- Replace the e2e fixmes with real tests that seed known captions/uploaders, switch
  to grid view, activate chips via the search suggestions, and count grid tiles:
  OR widens 1→2, AND narrows 2→1.

Frontend unit: 27 passing. filter-search e2e: 3 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:08:28 +02:00
fabi
b1e2e66305 test(e2e): address self-review follow-ups (dedup, XSS render guard, SSE hardening)
Follow-ups from the code review of the test-quality batches:

- Consolidate duplicated helpers into e2e/helpers/: seed.ts (seedUpload,
  seedComment, listComments, findFeedRow) and sse.ts (mintSseTicket, openStream,
  trackStreamOpens). Refactor authorization-deep, xss-injection, like-comment,
  sse-ticket-abuse, ddos, sse-realtime, multi-tab, and SseListener to use them —
  the upload/comment/ticket-flow contracts now live in one place each instead of
  being re-inlined across 3–7 specs.
- xss-injection display-name loop: it navigated to /feed (which renders uploader
  names, not the viewer's) so "nothing fired" passed vacuously — the payload was
  never rendered. Now navigate to /account (the actual sink) and add a render
  guard asserting the payload reached the DOM as escaped text before checking
  __xssFired.
- sse-realtime reconnect: snapshot the stream-open count AFTER backgrounding, so
  the "new connection" assertion is attributable to the foreground event and can't
  be satisfied by a spurious native/error reconnect before the toggle.
- recover-page: correct the comment (auto-submit is the onPinInput handler, not an
  $effect).

44 affected specs verified green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:49:44 +02:00
fabi
5f523d55fe test(e2e): fix vacuous 429 retry test (route never matched the feed)
The "429 from server is surfaced (no infinite retry storm)" test routed
`**/api/v1/feed`, but the app requests `/api/v1/feed?limit=20` — a plain glob
without a trailing wildcard doesn't match a URL with a query string, so the
route never fired: `attempts` stayed 0 and `expect(attempts).toBeLessThan(15)`
passed trivially. The test never forced a 429 or exercised any retry behavior.

Fix: match with a regex `/\/api\/v1\/feed(\?|$)/` (catches the query-string URL,
excludes /feed/delta), and gate on `attempts >= 1` before judging retry
behavior so it can't pass again without actually hitting the throttled endpoint.

Found during self-review of the test-quality batches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:33:28 +02:00
fabi
564104ae23 test(e2e): robustness — stable locators, exact assertions, no fixed sleep
- ContextSheet: add data-testid="context-sheet". longpress tests targeted the
  open sheet via the `.translate-y-0` animation class (breaks on any animation
  refactor) — now target `[data-testid="context-sheet"][aria-modal="true"]`,
  which is stable and unambiguous vs. the centered LightboxModal (also aria-modal).
- toast-on-failure: the like button was `button.filter(hasText:/\d+/).first()`,
  which could match any digit-bearing button (e.g. the comment count) → use the
  stable aria-label "Gefällt mir".
- config stats: assert the exact user_count (4 = 3 seeded guests + admin) instead
  of `>= 3` — deterministic after the per-test truncate, catches under/overcount.
- offline-network 429 test: replace the fixed 3s waitForTimeout with a
  poll-until-the-retry-count-stabilizes (faster, and a real storm never stabilizes
  → the poll fails, which is the intended outcome).

Note: reviewed the "config restore not in try/finally" finding — it's a non-issue.
The truncate auto-fixture wipes+reseeds the whole config table (and clears the
rate limiter) before every test, so config state cannot leak between tests.

All affected specs verified green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:24:03 +02:00
fabi
136417d6b4 test(e2e): make vacuous feed/SSE tests assert real behavior
Several tests ran green while asserting nothing. Replace them with real
assertions, and fix the SSE helper they depend on.

sse-listener: exchange the JWT for a single-use ticket (POST /stream/ticket) and
connect via ?ticket= — the helper still used the dead ?token= scheme, so every
SSE-based assertion would have silently failed to receive events.

like-comment:
- "like is idempotent" asserted nothing (void feed; void b) → now seeds a real
  upload and pins the like contract: counted once per user, toggles off on repeat
  (guards double-count), and a second user's like is counted independently.
- "comment → SSE to B" asserted length >= 0 (always true) → B now subscribes to
  the stream, A comments, and B must receive the new-comment event for that upload
  (comment_count === 1). ~30s due to reverse-proxy SSE buffering; timeout raised.

sse-realtime: only checked a nav link was visible → now counts EventSource opens
and asserts a fresh stream connection after hidden→visible (also fixes the sim,
which set visibilityState but not document.hidden, so the close never fired).

multi-tab "SSE delivers to both": only checked nav links → now asserts each tab
opens its own stream connection (delivery isn't asserted — it hinges on the ~30s
proxy buffering; connection establishment is the reliable, honest signal).

safe-area: delete the /join probe whose only assertion was Array.isArray(x) ===
true (always true); the real sheet-level env() check already exists below it.

All verified green against the live backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:05:53 +02:00
fabi
8f6e1d4ff7 test(e2e): stored-XSS (caption/comment) + SSE-ticket abuse coverage
Close two malicious-input gaps flagged in the suite review: XSS was only fuzzed
through display_name, and the SSE ticket flow had no security assertions.

xss-injection:
- Stored XSS in captions — upload with each XSS payload as the caption, mark it
  feed-visible, render /feed and assert window.__xssFired stays false, no dialog
  fires, and no live `img[onerror]`/`<script>` element is produced (Svelte escaping
  renders it as inert text). A trailing CAPMARK gates the assertion on the caption
  actually having rendered, so it can't pass vacuously.
- Stored XSS in comments — post the two render-executing payloads as a comment,
  open the lightbox (which loads comments) and assert the same inert-render props.

sse-ticket-abuse (new):
- Minting a ticket requires auth (POST /stream/ticket without Bearer → 401).
- Single-use: after the first open consumes the ticket, replaying it → 401 (a 200
  would be capability replay). The first open (→200) also proves a fresh ticket works.
- An unminted/garbage ticket → 401.

All verified green against the live backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:34:55 +02:00
fabi
20125fb713 test(e2e): pin security assertions + real cross-user IDOR coverage
Turn "accept-both-outcomes" documentation tests — which pass whether the app is
secure or vulnerable — into assertions that pin the secure behavior, and replace
fake-UUID authz tests that 404'd before ever reaching the ownership guard with
real cross-user resources.

file-upload-attacks:
- SVG-with-<script> → pin 400 (infer returns None for text → stored-XSS defense);
  was [201,400], which accepted the vulnerable outcome.
- zero-byte → pin 400; path-traversal → pin 201 with UUID-derived storage and a
  no-path-echo check (both were [201,400]).
- Fix the application/octet-stream rationale (no "application bypass" exists — the
  handler ignores the declared type and keys off magic bytes).

authorization-deep (IDOR):
- B deleting A's comment: seed a real upload+comment as A, assert 403, assert the
  comment survives, and assert the owner (A) still gets 204 — proving the 403 is
  about identity, not a broken route. (Was a DELETE on the all-zeros UUID → 404
  before the user_id guard, so authorization was never exercised.)
- Add B-deletes-A's-upload (403, countUploads unchanged) and B-edits-A's-caption
  (403, caption intact) — the find_by_id_and_event + ownership guards.

export: pin the ZIP-download 404 (was [404,200] — a 200 is a data-exposure
regression) and split into the two real branches: not-yet-ready, and
ready-but-file-missing (new db.setExportZipReady helper).

All 21 assertions verified green against the live secure backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:27:22 +02:00
fabi
a3c0082c4b test(e2e): update suite for shipped features + gate Chromium-only perms
The running test container had been built from an older tree; rebuilding it to
pick up current app code surfaced several tests that predated shipped
security/UX features:

- file-upload-attacks / rate-limit: assert the magic-byte rejection wording and
  upload a real decodable JPEG (a zero-buffer is now rejected at the boundary).
- ddos: open SSE via the single-use /stream/ticket flow, not the dead ?token=.
- recover-page / join: the PIN field auto-submits on the 4th digit, so don't
  race an explicit submit click against the ensuing navigation.
- gestures-doubletap / sheet-escape: target the lightbox by aria-labelledby and
  anchor the radio accessible-name match at the start (gated dialog semantics).

playwright.config: camera/mic/clipboard are Chromium-only permissions (they threw
"Unknown permission: camera" on firefox/webkit and failed the test at context
creation); grant them per-Chromium-project. firefox-android drops the
unsupported isMobile flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:05 +02:00
fabi
e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00
MechaCat02
309c25bc06 feat(frontend): UX review followups — primitives + a11y/UX fixes across 4 passes
New shared primitives:
- Toaster + toast-store, ConfirmSheet, Modal, focusTrap action,
  pullToRefresh action, avatarPalette + initials helper, Skeleton,
  HeartBurst, haptics, export-status store with onClearAuth hook

Critical UX/a11y:
- Replaced window.confirm with branded ConfirmSheet
- Focus management + Escape on every modal (PIN, Lightbox,
  Onboarding, ContextSheet, data-mode sheet, leave-confirm,
  HTML guide, host/admin ban + PIN-display modals)
- Sheet backdrops are real buttons with aria-label
- Silent ApiError catches now surface via global Toaster

Major polish:
- Dark-mode parity on HashtagChips + avatars (shared palette)
- Conditional Export tab in BottomNav (badge dot when ZIP ready)
- Back chevrons on /recover (history-aware) and /export
- Upload composer discard confirmation when content is staged
- Camera segmented Photo/Video shutter
- PIN auto-submit on 4th digit, paste-flash-free (controlled input)
- Welcome-back toast on /feed after PIN recovery

Minor:
- Skeleton states on feed; pull-to-refresh with live drag indicator
- Haptics on like / capture / submit / PIN-copy / onboarding complete
- Comment 500-char counter; quota "Fast voll" / "Limit erreicht" labels
- Onboarding pip ≥24px tap targets; long-press hint step
- overscroll-behavior lock on <html> while feed mounted
- teardownExportStatus wired via onClearAuth (covers 401 + explicit logout)
- ConfirmSheet per-instance titleId; Modal requires titleId or ariaLabel

Tests (7 new Playwright specs):
- 01-auth/pin-auto-submit, 01-auth/back-chevron
- 03-feed/confirm-sheet-delete, 03-feed/toast-on-failure
- 09-mobile/focus-trap, 09-mobile/sheet-escape,
  09-mobile/upload-cancel-confirm

FOLLOWUPS.md captures the deferred AT inert containment work
with acceptance criteria + implementation sketches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:50:28 +02:00
MechaCat02
e42d8a92a1 feat(e2e): Playwright suite — 134 tests across 9 spec areas + UA matrix
Adds an end-to-end Playwright test suite under e2e/ that spins up an
isolated docker-compose stack (Postgres :55432, Caddy :3101, backend with
EVENTSNAP_TEST_MODE=1, SvelteKit adapter-node frontend) and exercises the
SvelteKit app against the real Rust backend.

Phase 1 — happy paths covering every documented USER_JOURNEYS.md flow:
  01-auth/      join, recover, admin login, leave event, PIN lockout
  02-upload/    gallery picker (API path), rate-limit + admin toggle
  03-feed/      like/comment SSE, filters, SSE reconnect on visibility
  04-host/      event lock API, ban/unban, promote
  05-admin/     config validation, foundational authz guards, stats
  06-export/    /export status + download stub
  __smoke/      cross-UA happy-path (runs on every UA project)

Phase 2 — adversarial + browser chaos:
  07-adversarial/  XSS payloads (6 × display name path), SQLi shapes,
                   length / encoding / RTL override / NUL byte;
                   file-upload boundaries (ELF body claimed as JPEG,
                   oversize vs max_image_size_mb, zero-byte, NUL
                   filename, path-traversal, SVG-with-script);
                   JWT alg:none, signature/payload tamper, expired
                   session, PIN brute-force (serial + parallel),
                   admin password brute-force; deep authz (cross-user
                   delete, banned user across like/comment/feed-read,
                   host→admin escalation); small-scale DDoS (20× /join,
                   10MB comment body, 10 concurrent SSE).
  08-browser-chaos/ localStorage / sessionStorage / cookie purge,
                    IndexedDB drop mid-session, offline → reconnect,
                    slow-3G, 503 flakes, 429 with no retry storm,
                    multi-tab same/different user, no-JS, hostile CSS,
                    clock skew ±1h / -2d, localStorage quota exhausted.

Phase 3 — mobile gestures (runs only on chromium-mobile / Pixel 7):
  09-mobile/    touch-target ≥44px audit, env(safe-area-inset-bottom)
                structural check, long-press (FeedListCard → ContextSheet,
                quick-tap negation, click-suppression), double-tap
                (feed card like + lightbox heart-burst, via synthetic
                pointer events to bypass the first-tap-fires-click trap),
                viewport reflow (portrait/landscape/narrow/phablet),
                plus fixme stubs documenting planned gestures (swipe
                lightbox L/R, swipe-down dismiss, pull-to-refresh,
                long-press-comment).

Cross-UA matrix (chromium-engine projects run @smoke only):
  chromium-pixel7, chromium-galaxy-s22, samsung-internet (Samsung UA
  emulation on Galaxy viewport), edge-android, plus webkit-iphone,
  chrome-ios, firefox-android, firefox-desktop — the latter four need
  libavif16 on the host (Playwright dep) but the configs are in place.

Infrastructure:
  - fixtures/test.ts central test.extend (api, db, adminToken, guest,
    host, signIn). Per-test DB truncate via the dev-only POST
    /admin/__truncate route, gated by EVENTSNAP_TEST_MODE=1.
  - helpers/sse-listener.ts, helpers/upload-client.ts (Node-side
    multipart for adversarial file-upload tests + JPEG/PNG/ELF magic
    constants), helpers/touch.ts (longPress / doubleTap / swipe /
    inlineStyle / computedStyle).
  - 10 page objects covering every route + UploadSheet/Lightbox.
  - global-setup waits for /health, logs in admin, disables every
    rate-limit and quota toggle.
  - .github/workflows/e2e.yml: PR check runs chromium-desktop + the
    smoke matrix in parallel, uploads playwright-report/ and traces on
    failure.

Findings the suite surfaces as live `[finding]` warnings (not silenced):
  1. /admin/login has no rate-limit or lockout (bcrypt cost only).
  2. PIN-attempt counter races under parallel /recover requests.
  3. Zero-byte uploads pass /api/v1/upload.
  4. SVG-with-script can pass the magic-byte check (consider CSP +
     X-Content-Type-Options on /media/*).

Stack-internal docs live in e2e/README.md (UA tier table, Samsung
Internet escalation tiers A/B/C, debugging tips, roadmap).

Final tally: 134 passed / 0 failed / 9 skipped (test.fixme stubs for
not-yet-shipped gestures and one UI-upload-flow investigation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:37:11 +02:00