Commit Graph

69 Commits

Author SHA1 Message Date
MechaCat02
3fb1b5d80d feat(comments): hide every comment mention when COMMENTS_ENABLED=false
The kill-switch previously left comment UI/text visible in several places.
Sweep the whole surface so a comments-off instance shows no trace:

Frontend (main app):
- VirtualFeed grid tile: gate the comment button/count on $commentsEnabled
  (was ungated — the only feed surface still showing it).
- admin stats: hide the "Kommentare" count card.
- UploadSheet "Uploads geschlossen" notice, host ban-modal description, and
  host/admin unban confirmations: drop the "…und kommentieren" wording.
- export page: drop "Kommentaren" from the keepsake description.

Keepsake export (had no concept of the flag):
- export.rs: thread comments_enabled into the exported data (ViewerEvent),
  wired through spawn_export_jobs/recover_exports and their call sites
  (host.rs, main.rs).
- export-viewer: gate comment counts (list + grid) and the lightbox comments
  section; older exports without the field default to enabled (?? true).

Backend still 403s comment writes when disabled (unchanged) — this is the UI
half so stale clients and archives match.

Verified on the running stack (COMMENTS_ENABLED=false): /event reports
comments_enabled=false, a regenerated keepsake embeds "comments_enabled": false
with no comment UI, and the uploads-closed notice renders "…ansehen und liken."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:26:59 +02:00
MechaCat02
5009590882 feat(diashow): guarantee all eligible photos shown + 2048px display derivative
Diashow completeness rewrite so every eligible upload is shown regardless of
bursts, disconnects, or library size:

- queue.ts: SlideQueue with live/shuffle queues, allKnown map, recentlyShown
  ring; merge(dedup, live-first), remove/removeByUser (prunes recentlyShown),
  knownIds for reconcile-eviction. Adds queue.test.ts (burst/completeness/race).
- diashow/+page.svelte: reconcile (full paginate + evict, pre-scan snapshot to
  spare concurrent uploads) on mount/reconnect/periodic; catchUpNew paginate-
  until-known for bursts with debounced maxWait; hard-cut removals; decode
  timeout + candidate fallback + bounded skip so a broken image never stalls.

New ~2048px "display" derivative for big-screen sharpness, decoupled from the
data-saver preview (800px) used on phones:

- migration 016: upload.display_path + v_feed rebuilt (DROP+CREATE, not REPLACE,
  to slot the column beside preview/thumbnail).
- compression: generate_image_derivatives emits preview+display (downscale-only
  guard, no upscaling); backfill_missing_display regenerates on startup (safe:
  logs on error, never soft-deletes).
- upload.rs/main.rs: GET /upload/{id}/display (mirrors preview auth/cache),
  /media/displays direct-serve blocked.
- feed.rs + types.ts: display_url in feed/delta DTOs.
- diashow candidate chain: display -> original -> preview.

Verified on the running stack: migration applied, 10/10 existing images
backfilled (2048px cap honoured, small images not upscaled), /display serves
200, /feed returns display_url, diashow cycles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:53:53 +02:00
MechaCat02
4026648f98 fix(diashow): working transitions + slide/push, fullscreen, activity controls
Transitions never actually animated: Svelte scopes @keyframes names but does NOT
rewrite animation references in inline style attributes, so the inline
'animation: crossfade-in ...' never matched its scoped keyframe — a hard cut, no fade
(Ken Burns' zoom was dead too). Move the animation into scoped classes and pass dynamic
values via CSS custom properties.

- Real two-layer crossfade: hold the outgoing frame opaque beneath the incoming one
  (which is decode-gated), so there's no black flash and no decode pop.
- New slide transitions (from below/left/right) as one reusable component; the previous
  frame is pushed off the opposite edge in step (a conveyor/push), easeInOutSine 1s.
- Fullscreen toggle button (Fullscreen API) + 'f' shortcut; Esc in fullscreen no longer
  also navigates away.
- Controls now reveal on pointer/keyboard activity and fade when idle (cursor hides too)
  instead of being always visible.
- wakelock: null the sentinel on the OS 'release' event so re-acquire-on-visible
  actually fires (previously the screen could sleep after the tab was first hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:22:15 +02:00
MechaCat02
44641473ea fix(quota): stop /me/quota leaking raw disk to guests
The per-user quota widget was shown to everyone and the /me/quota payload returned
free_disk_bytes (raw server free space) and active_uploaders to any authenticated
guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero
the server-wide telemetry fields for non-staff in the handler. Guests still get their
own used/limit so enforcement stays transparent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:22:03 +02:00
MechaCat02
57a907eca5 feat(theme+comments): runtime colour theme and COMMENTS_ENABLED switch
Colour theme is configurable at runtime from two seed colours (brand + accent);
neutrals stay fixed for contrast safety. Tailwind v4 var()-based tokens let a
:root:root override recolour everything with no rebuild; the 50->950 ramps are
derived via a color-mix ladder. Config lives in the DB config table (admin UI:
Config > Farbschema, presets + custom pickers + live preview), served on the public
/event endpoint with env defaults (THEME_PRESET/PRIMARY/ACCENT), propagated live via
event-updated SSE, and cached in localStorage for a no-flash boot. The keepsake export
mirrors the same ladder in Rust so offline archives match the event theme.

COMMENTS_ENABLED (env, default true) is a boot-time kill-switch: the backend rejects
new comments with 403 and the frontend hides the comment button (feed card) and
panel/composer (lightbox). Existing comments stay in the DB, hidden, and return when
re-enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:21:55 +02:00
MechaCat02
3c3a7d0082 fix(diashow): open SSE on mount; raise upload rate 10→100/hour
diashow only subscribed to SSE events but never called connectSse(), so a
kiosk/projector opening /diashow directly (not via /feed) never opened the
EventSource — the showcase display got a one-time /feed snapshot and no live
updates, showing "Noch keine Beiträge" forever when turned on before any
photos. Open the stream in onMount (idempotent) and close it in onDestroy,
mirroring the feed page.

Raise the default upload_rate_per_hour from 10 to 100 (migration 015, scoped
to installs still on the old default so admin overrides are preserved). Guests
routinely upload bursts of 10-20 photos; the old default throttled the first
burst. Also update the code fallback and the test-mode reseed.

Both verified end-to-end against the docker test stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:29:08 +02:00
MechaCat02
3654aca18b feat(export): single-file offline keepsake viewer + guest download
Rebuild the guest "keepsake" (offline HTML gallery) so it renders when the
extracted index.html is opened over file://. Browsers block external ES-module
scripts and fetch() at origin null, so a normal multi-file SvelteKit build shows
a blank window. Build the viewer as one self-contained index.html (inlined
JS/CSS via vite-plugin-singlefile, standalone non-SvelteKit entry) and inject
the data as window.__EXPORT_DATA__; the backend embeds the built viewer via
include_dir! and writes the data global into index.html when zipping.

Also surface the guest-facing download: a feed banner and the BottomNav Export
tab, both shown once the host releases the export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:28:54 +02:00
MechaCat02
f243bfe89a feat(ui): elegant wedding white/silver/gold redesign
Reskin the whole app via design tokens in tailwind-theme.css (remapped
color ramps) plus a define-once component layer in lib/styles/components.css
(.btn, .card, .input, .chip, .badge, .sheet, …). Buttons are muted/outlined
gold rather than flat fills. Self-host Inter + Fraunces (woff2) under the
existing font-src 'self' CSP. Restyle the shared components and the account,
admin, host, join, recover and upload screens against the new tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:28:42 +02:00
fabi
15d338eeb8 fix(frontend): render client-side (ssr=false) to close pre-hydration interaction races
EventSnap is a client-only SPA: auth is a localStorage JWT and every page fetches its
data in onMount, so SSR only ever produced a logged-out skeleton — including interactive
controls that exist in the DOM before hydration attaches their handlers. Interacting in
that window silently no-ops: a caption typed on /upload never reaches the reactive state
(so cancel() navigates away instead of confirming), and a click on /account's custom
role=radio / leave button does nothing. This surfaced as a ~1-4% mobile e2e flake and is
a real (if brief) bug for users on slow connections too.

Disable SSR app-wide (csr stays on). The server now ships a ~2.5 KB shell with no page
controls, so the pre-hydration window is structurally impossible. No SEO is lost (private,
QR-gated app). app.html paints a themed boot spinner to cover the JS-load gap (script-free;
inline <style> is CSP-safe via style-src 'unsafe-inline'); the root layout's onMount removes
it once the app paints.

Also fixes the one regression ssr=false exposed: /recover's back chevron used
`cameFromApp = from !== null`, which assumes SSR semantics (cold load => from is null). In
CSR mode the initial navigation reports a non-null `from`, sending history.back() to
about:blank. Key on `type !== 'enter'` instead — SvelteKit's initial-load marker in both
SSR and CSR modes.

Verified: desktop 210 passed, mobile 22 passed, back-chevron 10/10, frontend gates green
(svelte-check 0 errors, eslint, prettier, vitest 46).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:15:14 +02:00
fabi
f8cba95e49 chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).

Rules encode "catch bugs, not enforce taste":
  - svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
    flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
    admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
    loops.
  - svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
    freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
  - svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
    a bug, and pure churn.
  - svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
    can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
  - no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).

Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.

Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:45:42 +02:00
fabi
02971f3186 test(e2e): de-vacuum security tests; add quota, authz-sweep, keepsake-regen coverage
An audit found tests that pass on broken code. The dominant pattern: fire a security
assertion at the all-zeros UUID and accept [403, 404] — the 404 comes from the resource
LOOKUP, not the guard, so the guard can be deleted and the test still passes. Repaired to
use real resources and demand exactly 403:
  - banned-user cannot like / comment (the only coverage of those ban invariants)
  - host cannot promote a real guest (or self) to admin — asserts nobody's role changed
  - IDOR comment-delete already used a real resource; kept
Also inverted recovery.spec's "unknown name → nicht gefunden" test: that asserted the exact
account-enumeration oracle the F4 fix removed, so restoring the vuln would have made it
pass. Now: an unknown name must be byte-identical to a wrong PIN.

New coverage for paths that ran in production but in zero tests:
  - quota.spec.ts: storage quota enforcement (413 over-limit, atomic increment under two
    uploads held mid-body so both carry a stale total=0 — the real race; a naive Promise.all
    version was itself vacuous and is documented as such). Proven to fail without the guard.
  - authz-sweep.spec.ts: table-driven guest→403 / host→403 over ALL 19 privileged routes +
    anonymous + __truncate. No live hole found; the whole surface is now locked.
  - ban / unban / host-comment-delete AFTER release regenerate the keepsake (data-loss
    paths that were dead under test); comment-delete mid-build doesn't strand the ZIP.

Lower-severity de-vacuuming: 10 MB comment test hit Caddy's 502 before the real 500-char
cap (now seeds a real upload, 501→400 / 500→201, mutation-verified); XSS name payloads
shortened under the 50-char cap so they actually store+render; ui-rendering XSS test now
proves the payload rendered before asserting no <b>; export page-object locators fixed to
the real "Download" label with a positive empty-state anchor; avatar palette spread test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:26:40 +02:00
fabi
c229b560d8 fix(feed,host): two mis-tap bugs where a live re-render destroys the click target
Both are the same class as the autocomplete bug fixed in bf68bc0: a handler must never
destroy the DOM node that is being clicked.

feed grid tiles: VirtualFeed keys tiles by upload.id but slices the uploads array
POSITIONALLY (`uploads.slice(i*COLS, …)`). A `new-upload` SSE prepends to the array, so
every tile shifts one slot and each row's keyed {#each} sees a new set of ids — Svelte
destroys and recreates the tile nodes. At a party, where photos stream in continuously, a
guest mid-tap can have the node torn out (tap swallowed) or, worse, like/open a DIFFERENT
photo that slid under their finger. Grid now buffers live arrivals behind the existing
"neue Beiträge" pill; list view is keyed at the top level and stays live.

host rebuild button: it lived inside an SSE-driven {#if exportGenerating}{:else if
exportReady}{:else} block, and rebuildExport() makes the backend broadcast export-progress
immediately — so pressing it flipped the branch and unmounted the button mid-click, on the
one screen whose purpose is recovering a broken keepsake. One button now stays mounted
across all three states; only its label and disabled vary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:25:19 +02:00
fabi
bf68bc08f4 fix(feed): commit filter suggestions on click, not mousedown
The `filter-search` spec was flaky (~3%: 5 failures in 180 runs), always as
"element was detached from the DOM" while clicking a suggestion. It was not a test
bug — it was reporting a real defect.

`selectSuggestion` sets `showAutocomplete = false`, which unmounts the dropdown, so a
suggestion button DESTROYS ITSELF when activated. It was wired to `onmousedown` — the
first event of the click sequence — purely to beat the input's `onblur`, which would
otherwise close the dropdown before a click could land. So whether the node survived to
`mouseup` depended on whether Svelte's flush happened to fall between the two events.
Under load it did, and the click was lost. That is also a real user-facing bug: because
it committed on PRESS, sliding off a suggestion you didn't mean to hit still applied the
filter, with no way to abort.

Fixed the standard combobox way: preventDefault on the dropdown's mousedown suppresses
the blur, which is the only thing onmousedown was buying — so the handler moves to
`onclick`, the LAST event, where self-destruction is harmless and press-then-slide-off
aborts like a button should. Because the input now keeps focus across a selection,
`onfocus` no longer re-fires, so reopening is also wired to click/input — without that
the picker could not be reopened without clicking away first.

Also freezes the suggestion SOURCE while the dropdown is open. `allTags` is ordered by
frequency and derives from `uploads`, which every `upload-processed` SSE replaces via
refreshFeedInPlace — so an arriving photo could REORDER the open dropdown under the
user's finger. At a party, where photos stream in continuously, that is a live mis-tap
hazard. This was NOT the cause of the flake (I first believed it was; a clean 60-run
said so and a 120-run refuted it), but it is a genuine defect on the same interaction,
so it stays.

Verified: 240/240 consecutive passes on the previously-flaky spec (baseline 2.8%, so
~0.1% odds of luck), full suite 162 passed / 1 skipped with zero failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:06:56 +02:00
fabi
3c683247c0 chore(deploy): expose the export rebuild hatch in the UI; rehearse migration 014
Three deploy-readiness gaps, none of them in the export invariant itself.

1. The escape hatch was unreachable. `POST /host/export/rebuild` was added as the fix
   for "a failed export is terminal", but nothing in the frontend called it — so recovery
   required a terminal, the API docs and a valid JWT. The host page instead told the host
   to reopen uploads and re-release, which is the destructive act the endpoint exists to
   avoid (it unlocks the gallery to every guest and retracts the release). The failed
   state now offers "Erneut versuchen"; a ready keepsake can be rebuilt behind a confirm,
   since a rebuild makes it briefly undownloadable.

2. Migration 014 had never been run against anything. It is the repo's first destructive
   migration and its backfill has to carry the old notion of "downloadable" across exactly
   — get it wrong and a released event's keepsake silently 404s, on data that cannot be
   re-created. backend/scripts/rehearse-014.sh proves it on a throwaway postgres, against
   a real pg_dump or a synthetic DB covering every pre-014 state, asserting up, down and
   up-again all preserve downloadability. Verified non-vacuous: retiring nothing (ELSE -1
   -> ELSE e.export_epoch) fails it, naming the three keepsakes it would resurrect.

   It also surfaced that the down migration is an inverse UP TO DRIFT: a ready flag that
   disagreed with its job row comes back FALSE. That heals corruption rather than
   restoring it, and costs nothing (no file_path to serve), so the assertion checks what
   actually matters — no genuinely downloadable keepsake loses its flag — and reports the
   normalisation instead of failing on it.

3. No advisory scanning. cargo audit + npm audit, in .github/workflows/ because Gitea
   Actions scans it too and e2e.yml already resolves actions/* from GitHub. The runner is
   not assumed to have cargo. RUSTSEC-2023-0071 (rsa/Marvin) is ignored SPECIFICALLY, not
   globally: it is unreachable here (HS256 + bcrypt, Postgres only) and unpatched, so
   failing on it would mean a permanently red pipeline everyone learns to ignore.

Tests: e2e pins that a failed keepsake recovers via rebuild while the event stays
released and uploads stay locked — so a future refactor implementing rebuild as an
internal reopen+re-release fails — and that rebuild cannot publish an unreleased gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:09:53 +02:00
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
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
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
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
a084ba86c5 fix(review): CSP nonce for FOUC script + feed_delta truncation signal
Two items surfaced by the branch re-review:

CSP regression (blocking): script-src 'self' blocked the template-authored
anti-FOUC theme script in app.html — SvelteKit's mode:'auto' only hashes
scripts it injects. Added nonce="%sveltekit.nonce%" so it's substituted per
request and included in the CSP (survives future edits, unlike a pinned hash).
Verified: emitted CSP nonce matches the script tag; no violation, no flash.

feed_delta silent truncation: the LIMIT 200 (added earlier to kill the
stale-`since` DoS) returned only the newest slice with no signal, so a client
that missed 200+ uploads during a reconnect could not tell it should full-
refresh — the older missed uploads were dropped and unrecoverable (the next
delta advances `since` past them). DeltaResponse now carries `truncated`; the
feed's feed-delta handler calls loadFeed(true) to resync from page 1 instead
of merging a partial slice.

Verified: cargo check clean, svelte-check 0 errors, production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:51:08 +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
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
226e455328 test(unit): broaden coverage — quota formula + auth token/JWT decode
Backend: extract the per-user quota formula from compute_storage_quota into a
pure `quota_limit_bytes(free_disk, tolerance, active)` (behavior-preserving) and
unit-test it: tolerance scaling, floor of fractional results, active<1 clamped to
1 (divide-by-zero guard), zero free disk, single-uploader identity.

Frontend (jsdom + browser:true mock): auth.ts token storage and JWT claim decode
— setAuth/getToken/getPin round-trip, clearAuth keeps the PIN (for recovery),
clearPin, getExpiry (exp seconds→ms Date; null on missing/malformed), getRole
(role claim; null on absent/malformed). Adds jsdom devDependency.

Backend: 25 passing. Frontend: 18 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:53:03 +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
723a492d44 test(unit): add a unit-test layer (backend cargo + frontend vitest)
The suite was almost entirely e2e; pure logic had no direct coverage. Add fast,
DB-free unit tests on both sides.

Backend (cargo test — inline #[cfg(test)] modules):
- rate_limiter: allow-up-to-max-then-block, per-key independence, sliding-window
  expiry, retry-after bounds, clear(), and client_ip X-Forwarded-For parsing
  (first entry / whitespace-trim / fallback).
- sse_tickets: single-use consume (replay → None), unknown ticket, uniqueness +
  hex shape, prune keeps fresh tickets, and an expired ticket (injected past-TTL
  entry) consumes to None.
- hashtag: fill the boundary gaps the 3 existing tests missed — stop-at-non-word,
  the 40-char cap (drops, doesn't truncate), no-dedup contract, and the German
  umlaut truncation limitation (pinned so a future Unicode fix is deliberate).

Frontend (Vitest — new, standalone config that stubs $app/environment so
server-safe module paths import cleanly under node):
- avatar: avatarPalette (neutral for empty, deterministic, real palette entry) and
  initials (?, single word, two words, whitespace collapse).
- data-mode-store: pickMediaUrl across original/saver modes and the
  preview→thumbnail→original fallback chain.
- `npm run test:unit` script added.

Backend: 20 passing. Frontend: 11 passing. svelte-check: 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:14:33 +02:00
fabi
792a4f0e4b fix(frontend): robust Escape handling + deep-link back navigation
focus-trap: focus the trapped container synchronously on mount instead of
only after a deferred rAF. The keydown listener is node-scoped, so an Escape
pressed before the rAF moved focus into the sheet landed on an element outside
the node and was silently dropped — a real keyboard-a11y gap (open a sheet,
immediately press Escape → nothing happened). The rAF still refines focus to
the first control once laid out.

recover: replace the `window.history.length > 1` back-chevron heuristic with
SvelteKit's afterNavigate `from` signal. history.length is 2 on a fresh-tab
deep link (about:blank + page), so history.back() landed on the blank entry.
`from` is null only on a full-page load, so deep-linked users now correctly
fall back to /join (or /feed when authed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:12:05 +02:00
fabi
b32231d4f4 fix(a11y): gate always-mounted sheet dialog semantics on open state
ContextSheet and UploadSheet stay mounted (slid off-screen via translate-y) when
closed, but declared role="dialog" + aria-modal="true" — and kept their buttons
in the a11y tree + tab order — unconditionally. So a screen reader always saw
2+ simultaneous modal dialogs, and their controls (e.g. each sheet's "Abbrechen")
collided with real open dialogs.

Surfaced by the e2e a11y suite: focus-trap asserted exactly one [role=dialog]
[aria-modal] and got 2; upload-cancel-confirm's getByRole('button',{name:
'Abbrechen'}) matched the composer's X *and* a closed sheet's button.

Fix: when closed, drop role/aria-modal and mark the subtree aria-hidden + inert
so it's out of the a11y tree and tab order entirely. Open sheets are unchanged.

Verified: chromium-mobile a11y/gesture suite now 21 passed / 5 skipped / 0 a11y
failures (focus-trap + upload-cancel-confirm green); svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:23:05 +02:00
fabi
a490642f5f Merge fix/security-review-batch-2: cross-event authz + upload OOM backstop
Brings the batch-2 security hardening into main (forked from the same base as
the UX batch; auto-merged cleanly — verified both sides' edits to social.rs /
main.rs coexist):

- Cross-event authorization: toggle_like / list_comments / add_comment now
  event-scope the upload via Upload::find_by_id_and_event (404 on cross-event
  access); delete_comment uses Comment::soft_delete_in_event. Closes the gap
  where a guest could like/comment/list across events by upload UUID.
- Upload OOM backstop: the /upload route gets DefaultBodyLimit::max(576 MiB)
  instead of disable(), so a multi-GB body can't be buffered before the
  handler's per-class size checks run.
- upload.rs per-class size-limit refactor, XSS allowlist, deploy hardening
  (Caddyfile, Dockerfiles, docker-compose, .env.example), and a data-mode
  doc-comment clarifying the original-media route is capability-(UUID-)gated.

The event-scope checks sit before, and batch-3's best-effort count broadcasts
after, the like/comment mutations — both preserved.

Verified: cargo build clean, svelte-check 0 errors.
2026-06-30 20:02:47 +02:00
fabi
0d7938aff5 fix(feed): width-change measurement invalidation + best-effort SSE counts
Post-commit review follow-ups for the batch-3 feed work.

Frontend — VirtualFeed: the guarded setOptions keyed only on (count, scrollMargin),
so a rotation (or the first-paint 0->real container width) changed colWidth / card
heights without invalidating the cached measurements, leaving getTotalSize and the
scrollbar stale until each row scrolled back through the window. Track appliedWidth
and call virtualizer.measure() on a width delta so heights re-estimate at the new
width (rendered rows re-measure immediately via their ResizeObserver). Covers list +
grid and the first-paint 120px-estimate case.

Backend — social.rs: the fresh like/comment count SELECT used `.await?`, so a
transient DB failure would 500 the request after the like/comment had already
committed. Make the count + broadcast best-effort (if let Ok { ... }); the mutation
succeeds regardless and the next event / pull-to-refresh reconciles the count.

Docs — FOLLOWUPS: record the review findings left as-is (grid-prepend tile reflow,
filtered-grid auto-load which is pre-existing, comment-delete stale count, and the
last-write-wins count ordering note).

Verified: svelte-check 0 errors, cargo build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:32:32 +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
fabi
1bb58b59ad fix(security): re-review follow-ups — drop HEIC/HEIF, fix stale comment
Address the two items from the adversarial re-review of batch-2.

- upload: remove image/heic + image/heif from ALLOWED_MEDIA. Neither the
  `image` crate nor the bundled ffmpeg 6.1 (Alpine 3.21 — HEIF demuxer
  only landed in ffmpeg 7.0) can decode them, so accepting them stored
  posts that never got a thumbnail. iOS Safari transcodes HEIC->JPEG on
  file-input selection, so this rejects only the rare HEIC-preserving
  path, now with a clear error instead of a silently broken post.
- data-mode-store: correct the stale "auth-gated" comment — the
  /original route is intentionally unauthenticated (UUID-as-capability)
  so it works from plain <img src> / <video src>.

Re-verified: cargo build (only the pre-existing middleware.rs warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:48:46 +02:00
fabi
edcef0258c fix(security): batch-2 hardening — XSS allowlist, event-scoping, deploy
Address the seven findings from the security & deployment review.

High:
- upload: make infer authoritative — reject files it can't identify
  (SVG/HTML/JS) and require the detected MIME to be on an ALLOWED_MEDIA
  allowlist; derive the stored MIME and on-disk extension from the
  detected type, ignoring client filename/Content-Type. Closes the
  stored-XSS vector via media served on-origin.
- deploy: rename docker-compose.override.yml -> docker-compose.dev.yml
  so the default `docker compose up -d` no longer publishes Postgres
  5432 to the host; the port map is now opt-in via -f. README updated.

Medium:
- upload: DefaultBodyLimit::disable() -> max(576 MiB) as an HTTP-level
  OOM backstop; handler still enforces precise per-class size limits.
- docker: run backend and frontend as non-root users.

Low:
- social/upload: event-scope toggle_like, list_comments, add_comment,
  delete_comment, edit_upload, delete_upload via find_by_id_and_event /
  soft_delete_in_event — cross-event IDs now resolve to 404.
- Caddy: site-wide HSTS / nosniff / X-Frame-Options / Referrer-Policy,
  plus Content-Disposition: attachment on /media/originals/*.
- .env.example: replace default Postgres password with a CHANGE_ME hint.

Out of scope: localStorage JWT (root cause fixed; httpOnly cookies are a
larger change tracked separately).

Verified: cargo build (no new warnings), cargo test (3 passed),
caddy validate, docker compose config (no 5432 published by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:39:29 +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
d228676a56 fix(security): cross-event authz, SSE ticket flow, account hardening, audit logs
Follow-up to the comprehensive code review. Five batches:

1. Cross-event authorization: host_delete_upload, unban_user, and
   host_delete_comment now scope by auth.event_id. Adds
   Upload::find_by_id_and_event / soft_delete_in_event and a
   Comment::soft_delete_in_event variant that joins through upload.

2. Token exposure: SSE auth no longer puts the JWT in the URL.
   New /api/v1/stream/ticket endpoint mints a short-lived single-use
   ticket bound to the session; the EventSource passes ?ticket=...
   instead. Refuse to start in APP_ENV=production with the dev JWT
   sentinel; warn loudly otherwise.

3. Account hardening: per-IP+name rate limit on /recover (mitigates
   targeted lockout DoS), per-IP rate limit on /admin/login, random
   32-char admin recovery PIN (replaces "0000"), structured tracing
   events for wrong PIN, lockout, failed admin login, ban/unban/role
   change/pin-reset/host-delete.

4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before=
   cursor), hashtag extraction whitelisted to ASCII alnum+underscore
   (≤40 chars) with unit tests, display_name / caption / comment body
   length validated in chars rather than bytes.

5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS
   env var (default 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:00:51 +02:00
MechaCat02
1cdab21514 fix(frontend): a11y backdrop, ≥44px PIN button, test-ids on auth & upload
- account/+page.svelte: remove `aria-hidden="true"` from the
  leave-confirm and data-mode-warning bottom-sheet backdrops. The
  attribute cascaded into the dialog children, making the inner
  Abmelden/Aktivieren/Abbrechen buttons unreachable in the accessibility
  tree (and to Playwright's `getByRole`). Discovered while writing the
  E2E suite; the visual layout is unchanged.

- join/+page.svelte: bump the PIN-copy button from `py-1` (28px tall) to
  `min-h-11 min-w-11 py-2` so it clears the ≥44px touch-target floor on
  mobile. Touch-target audit revealed the gap.

- data-testid attributes on stable interactive elements (join name input,
  join submit, PIN modal + copy + continue, recovery PIN + submit + try-
  different-name, admin login password + submit + error, recover name +
  PIN + submit + error, upload header submit + sticky submit + caption
  textarea). Targeted at ~20 spots where semantic locators were ambiguous
  (e.g. two "Hochladen" buttons on /upload, German strings that may iterate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:54 +02:00
MechaCat02
e619a3bd64 feat(ui): v0.16 features + dark mode across every page
Wires up everything from the previous commits into actual UI surfaces, and
applies Tailwind dark: variants throughout. All pages now support the
'system' / 'light' / 'dark' preference set in the onboarding step or in
Mein Konto → Design.

Layout & nav:
- routes/+layout.svelte: initTheme(), global pin-reset SSE handler that
  filters by user_id and calls clearPin(), one-shot /me/context fetch
  on boot to hydrate privacyNote + quota.
- components/BottomNav.svelte: dark variants on the frosted-glass bar.
- components/UploadSheet.svelte: dark variants on backdrop, sheet,
  source buttons.
- components/OnboardingGuide.svelte: new "Helles oder dunkles Design?"
  step (3-option custom-radio grid), reactive currentStep with proper
  type narrowing, dark variants throughout. Privacy-note nudge appears
  on the PIN step only when one is configured.

Feed:
- routes/feed/+page.svelte: diashow entry icon (tablet/desktop only),
  long-press → ContextSheet (Löschen for own posts, Original anzeigen
  for all), upload-deleted + feed-delta SSE handlers, dark variants on
  header, search, autocomplete, filter chips, empty states.
- components/FeedListCard.svelte: long-press wireup, double-tap-to-like,
  data-mode-aware mediaSrc via pickMediaUrl, kebab fallback for desktop,
  isOwn prop, dark variants.
- components/FeedGrid.svelte: long-press wireup, dark variants.
- components/LightboxModal.svelte: data-mode-aware src, double-tap heart
  burst, dark variants on card / comments / input.
- components/HashtagChips.svelte: dark variants.

Account:
- routes/account/+page.svelte: theme picker (3-button radio grid), data
  mode picker (with confirm sheet for Original), live quota widget,
  preformatted Datenschutzhinweis block, diashow tile (mobile only),
  pin now sourced from the $currentPin store so a global pin-reset
  clears it live, clearQueue() on explicit logout, dark variants
  across every card + both bottom sheets.

Upload:
- routes/upload/+page.svelte: per-user quota progress bar above the
  submit button, dark variants.

Host & Admin:
- routes/host/+page.svelte: PIN-reset confirm + one-time PIN modal,
  hosts may demote other hosts, canResetPinFor() helper, dark variants
  on all cards, modals, stats, toast.
- routes/admin/+page.svelte: Config form rebuilt as CONFIG_GROUPS with
  per-field kind (number / bool / text), renders toggles for the
  rate-limit + quota switches and a textarea for the privacy_note;
  Nutzer tab gains PIN reset + hosts-may-demote-hosts wiring; same
  one-time PIN modal; dark variants everywhere.
- routes/admin/login/+page.svelte: dark variants.

Join / Recover / Export:
- routes/join/+page.svelte: rename inline link to
  "Ich habe bereits einen Account", dark variants.
- routes/recover/+page.svelte: dark variants.
- routes/export/+page.svelte: dark variants on status cards + HTML
  guide modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:33:30 +02:00
MechaCat02
8a769b52bf feat(diashow): live slideshow with two-queue policy + pluggable transitions
A fullscreen auto-advancing slideshow any user can start. Design:
docs/CONCEPT_DIASHOW.md.

- lib/diashow/queue.ts: SlideQueue state machine — liveQueue drains first
  (FIFO, seeded by SSE upload-processed), then shuffleQueue (refilled
  from allKnown minus a 5-id ring buffer of recently shown). Pure logic,
  unit-testable.
- lib/diashow/wakelock.ts: Screen Wake Lock wrapper that re-acquires on
  visibility change (the OS drops the lock when the tab hides).
- lib/diashow/transitions/{index,crossfade,kenburns}.ts: registry +
  the v1 transitions. Adding a new animation is one file + one entry —
  the extensibility target from docs/FEATURES §2.9.
- routes/diashow/+page.svelte: fullscreen page, hides bottom nav,
  6 s default dwell (3/6/10 configurable), keyboard shortcuts
  (Escape exits, Space toggles pause), tap-to-reveal overlay with
  pause / dwell / transition / exit. Respects $dataMode to choose
  preview vs. original URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:32:55 +02:00
MechaCat02
251f9f1469 frontend(plumbing): shared theme tokens, cross-cutting stores, gestures, sheets
The plumbing layer the v0.16 UI features (and dark mode) build on.

Shared design tokens (Tailwind v4):
- tailwind-theme.css (new): @custom-variant dark (class-driven, beats OS
  default) + @theme color/font/radius tokens + baseline html/html.dark
  rules so any page that hasn't been re-themed still renders the right
  body bg + color-scheme.
- src/app.css + export-viewer/src/app.css now import the shared theme.
- src/app.html: 6-line FOUC guard sets <html class="dark"> before paint
  (mirrored from theme-store.ts) so dark reloads no longer flash white.
  Adds <meta name="theme-color"> kept in sync by initTheme().

Cross-cutting stores (one per concern, per docs/FEATURES §2.9):
- data-mode-store.ts: 'saver' | 'original' per-device, plus pickMediaUrl
  helper so feed cards / lightbox / diashow all resolve URLs the same way.
- privacy-note-store.ts: hydrated from /me/context, refreshed on SSE
  event-updated.
- quota-store.ts: { enabled, used, limit, active_uploaders, free_disk },
  refreshed after each upload completes.
- theme-store.ts: 'system' | 'light' | 'dark' preference + derived
  appliedTheme + initTheme() that syncs <html class>, localStorage,
  and the theme-color meta. Listens to prefers-color-scheme.
- auth.ts: currentPin writable mirror + clearPin() helper called from
  the global pin-reset SSE handler — fixes the stale-PIN bug where the
  localStorage copy survived a reset.

DTO mirror:
- types.ts: QuotaDto, MeContextDto, PinResetResponse, DeltaResponse each
  carry a `// mirrors backend/...` comment per the lib README convention.

SSE client:
- sse.ts: KNOWN_EVENTS registry (one entry per server-emitted type),
  synthetic feed-delta dispatched after foreground reconnect via the
  /feed/delta?since= endpoint, exponential backoff (1 → 60 s + jitter)
  on errors, attempt counter reset on user-initiated visibility resume.

Upload queue:
- upload-queue.ts: IDB schema bumped to v2 — entries tagged with userId;
  loadQueue filters by current user (no cross-user leak on shared
  devices); uploadItem refuses to upload an entry whose userId differs
  from getUserId() (defense-in-depth); new clearQueue() called on
  explicit logout. v2 upgrade wipes pre-v2 entries (no userId, can't
  attribute safely).

Mobile primitives:
- actions/longpress.ts: 500 ms hold with 10 px move tolerance, swallows
  the next click + the right-click contextmenu so the gesture doesn't
  double-fire the inner button's onclick.
- actions/doubletap.ts: tap-pair detector that preventDefaults the
  second tap so iOS Safari doesn't also zoom on double-tap.
- components/ContextSheet.svelte: generic bottom sheet driven by a
  ContextAction[] prop. Reused by feed posts, comments, host user rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:32:37 +02:00
MechaCat02
9a0ceeced7 docs: realign blueprint with shipped state + add feature/journey/ideas docs
- PROJECT.md, README.md, TEST_GUIDE.md: status line refreshed; rate-limiter
  doc-vs-code drift fixed; HTML export section rewritten for the SvelteKit-
  static viewer; SSE event names + new events documented; config seed block
  extended with planned toggles + privacy_note; decision log entries added.
- docs/CONCEPT_HTML_VIEWER.md, docs/CONCEPT_MOBILE_UI.md: banner the design
  intent as shipped; point at the source-of-truth code paths.
- docs/CONCEPT_DIASHOW.md: planned-then-shipped design for the live diashow
  (two-queue policy, pluggable transitions, data-mode aware).
- docs/FEATURES.md: capability matrix by role (Guest / Host / Admin) plus
  prose per area (auth, posting, feed, moderation, admin, export, gestures,
  data mode, quotas, privacy note, extensibility).
- docs/USER_JOURNEYS.md: step-by-step flows for every supported scenario,
  including PIN reset by host, data mode, privacy note, gestures, and the
  admin toggles.
- docs/IDEAS.md: speculative extensions (global diashow, reactions,
  multi-tenancy, animation pack, etc.) — explicitly out of v0.16 scope.
- backend/migrations/README.md, frontend/src/lib/README.md: codify the
  "never edit a shipped migration" rule and the lib/ conventions
  (one store per concern, gestures via actions, sheets via ContextSheet,
  transitions as drop-in components).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:31:06 +02:00
MechaCat02
1685bf105c fix: update export page guide text for new viewer
Change "Memories.html" to "index.html" in the HTML export
download guide modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:26:06 +02:00
MechaCat02
4f966533fe feat: add export-viewer SvelteKit static app
Standalone SvelteKit project at frontend/export-viewer/ using
adapter-static. Replicates the live feed experience as a read-only
offline gallery: list/grid views, search with autocomplete, hashtag
filtering, lightbox with swipe navigation and comments.

Built output goes to backend/static/export-viewer/ for embedding
into the HTML export ZIP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:01:01 +02:00