Commit Graph

122 Commits

Author SHA1 Message Date
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
2c44006392 Merge test/review-followups: dedup helpers + XSS render guard + SSE hardening 2026-07-01 19:49:44 +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
ec64fc361b Merge test/fix-vacuous-429-test: 429 retry test now actually fires 2026-07-01 19:33:28 +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
bbdb45155e Merge test/robustness-cleanup: stable locators + deterministic assertions
Stable data-testid for ContextSheet (drops animation-class coupling), aria-label
like-button locator, exact stats count, and poll-until-stable instead of a fixed
3s sleep in the 429 retry test.
2026-07-01 19:24: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
104c3dde16 Merge test/unit-layer: backend cargo + frontend vitest unit tests
Add a fast, DB-free unit layer: backend #[cfg(test)] modules for the rate
limiter, SSE ticket store, and hashtag boundaries; frontend Vitest (with an
$app/environment stub) for avatar and pickMediaUrl logic. 20 backend + 11
frontend tests, all green.
2026-07-01 19:14:33 +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
fabc6af656 Merge test/deflake-vacuous-tests: make vacuous feed/SSE tests real
Fix the SSE listener (ticket flow) and replace assert-nothing tests with real
coverage: like toggle/count semantics, comment→SSE delivery, SSE reconnect on
visibility change, per-tab SSE connection, and remove a safe-area no-op.
2026-07-01 19:05:53 +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
d4aa7b4932 Merge test/stored-xss-sse-abuse: stored-XSS + SSE-ticket abuse coverage
Add stored-XSS coverage for captions and comments (inert-render assertions) and
a new SSE-ticket-abuse spec (auth-required mint, single-use replay rejection,
garbage-ticket rejection).
2026-07-01 07:34:56 +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
da586d0978 Merge test/pin-security-assertions: pin security assertions + real IDOR
Turn accept-both-outcomes documentation tests into pinned secure-behavior
assertions (SVG stored-XSS→400, zero-byte→400, path-traversal→201, export→404),
and replace fake-UUID authz tests with real cross-user IDOR coverage (comment
delete, upload delete, caption edit all → 403 with no state change).
2026-07-01 07:28:23 +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
6ccf2d0011 Merge fix/sheet-aria-modal-gating: sheet a11y gating + test-suite hygiene
Gate always-mounted sheet dialog semantics on open state; fix focus-trap Escape
drop and deep-link back navigation; update the e2e suite for shipped magic-byte
validation, SSE ticket flow and PIN auto-submit; gate Chromium-only permissions.
2026-06-30 22:12:11 +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
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
1c7495e568 Merge fix/security-audit-port: image-decode DoS cap + audit backlog/media-auth docs
Ports the parts of the 2026-06-27 security audit that were not already absorbed
into main:
- compression.rs: image::Limits decode cap (decompression-bomb DoS guard)
- docs/SECURITY-BACKLOG.md: triaged backlog reconciled against main
- docs/DECISION-media-auth.md: unauthenticated-UUID vs signed-gateway writeup

Verified: cargo build clean.
2026-06-30 20:27:59 +02:00
fabi
d9025f783a fix(security): port image-decode DoS cap + audit backlog/media-auth docs
From the 2026-06-27 audit branch (fix/audit-2026-06-27-critical-medium), whose
work was ~80% absorbed into main via the batch branches. This ports the pieces
that were NOT in main:

- compression.rs: cap image decode with image::Limits (12000x12000, 256 MiB
  max_alloc) via ImageReader instead of image::open(). The upload body-size cap
  bounds the file on disk but not the *decoded* dimensions, so a small
  decompression-bomb image could OOM the box during decode/resize. Surgical port
  of the audit's decode guard only (not its image/video semaphore split).

- docs/SECURITY-BACKLOG.md: the audit's triaged backlog, reconciled against main
  (each item tagged done / open / contingent-on-signed-media). Records the still-
  open items: host moderation UI gap, owner-delete SSE broadcast, quota
  mount-detection (starts_with) + low-disk guard.

- docs/DECISION-media-auth.md: writes up the one real architectural divergence —
  main serves media unauthenticated (ServeDir + UUID-capability) while the audit
  built a signed/TTL'd gateway. Lays out the tradeoff (leaked URL = permanent vs
  ~24h access; banned-user access) and a recommendation, for a human decision.

Verified: cargo build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:26:57 +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
8f5afb9df7 Merge feat/ux-review-batch-3: feed virtualization + UX review batch fixes
Squash of the branch's two commits:
- feat(eventsnap): UX review batch-3 — feed DOM-windowing virtualization,
  global safe-area/PWA/lang fixes, in-place SSE feed patching (+ backend count
  broadcast), ConfirmSheet on destructive host/admin actions, streamed export
  download, shared IconButton + scrollLock primitives, and a11y/UX polish.
- fix(feed): width-change measurement invalidation + best-effort SSE counts.

Verified: svelte-check 0 errors, frontend build clean, cargo build clean.
2026-06-30 19:51:11 +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
bbec815854 Merge feat/ux-review-followups: UX review batch fixes + shared primitives
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
2026-05-24 22:50:34 +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
b241ba6415 Merge fix/security-review-followups: security review batch fixes
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
2026-05-17 21:00:55 +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
2340f21637 Merge feat/e2e-test-suite: Playwright E2E suite + small fixes
Adds a Playwright-driven end-to-end test suite under e2e/ with 134 tests
across 9 spec folders, running against an isolated docker-compose test
stack. Mobile-gesture coverage on a Pixel 7 viewport. Cross-UA smoke
matrix including a Tier-A Samsung Internet emulation. CI workflow in
.github/workflows/e2e.yml.

Also ships small fixes surfaced while writing the suite:

  Backend
  - JWT now carries a `jti` (per-token UUID) so two admin logins in the
    same wall-clock second don't collide on session.token_hash UNIQUE.
  - join handler rejects 0x00 in display names with a clean 400 (was 500
    from Postgres).
  - dev-only POST /api/v1/admin/__truncate route, registered only when
    EVENTSNAP_TEST_MODE=1.
  - rate_limiter.clear() for test isolation.
  - Dockerfile: rust:1.88, COPY ./migrations into the build context.

  Frontend
  - Removed `aria-hidden="true"` from the leave-confirm and data-mode
    backdrops on /account (it cascaded into the dialog content and made
    Abmelden / Aktivieren unreachable to a11y tools).
  - Bumped the PIN-copy button to ≥44×44 px touch target.
  - data-testid attributes on ~20 stable interactive elements
    (auth + upload routes).

Findings the suite surfaces as `[finding]` warnings on every run:
  1. /admin/login has no rate-limit or lockout.
  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.

Final test result: 134 passed / 0 failed / 9 skipped (test.fixme stubs
for planned gestures and one UI-upload-flow investigation).
WebKit/Firefox projects need libavif16 on the host to launch — configs
are in place; install with `sudo apt-get install libavif16` to enable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:37:42 +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
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
05f76514a2 fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint
Two bugs surfaced while running the new E2E suite, plus a small test hook:

- jwt.rs: add a per-token `jti: Uuid` claim. Without it, two `create_token`
  calls in the same wall-clock second for the same (sub, role, event_id)
  produced identical JWT bytes — and identical sha256(token) hashes —
  which then collided on `session.token_hash UNIQUE` with a 500. Manifests
  in real use when an admin clicks "Anmelden" twice fast.

- auth/handlers.rs: reject display names containing 0x00. Postgres rejects
  NUL in TEXT columns with `invalid byte sequence for encoding "UTF8"` and
  the request leaks back as a 500. Now returns 400 with a clean message.

- handlers/test_admin.rs + main.rs: new POST /api/v1/admin/__truncate route,
  compiled in always but only **registered** when EVENTSNAP_TEST_MODE=1 is
  set on startup. Truncates every event-scoped table, reseeds config from
  migration defaults, wipes media on disk, and clears the in-memory rate
  limiter. RequireAdmin-gated so it's not anonymous even in test mode. In
  production builds (no env var) the route returns 404 — verified by the
  startup log message.

- services/rate_limiter.rs: add `clear()` so the truncate handler can wipe
  the in-memory window map between tests.

- Dockerfile: bump rust:1.87 → rust:1.88 (current dep tree needs it) and
  COPY ./migrations into the build context so the `sqlx::migrate!()` macro
  can resolve at compile time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:34 +02:00
MechaCat02
e6efffafe5 Merge feat/platform-v0.16: platform features, dark mode, hardening
A bundle of v0.16 platform work landing on top of the SvelteKit viewer.
Brings in 7 commits across docs, backend infra + features, frontend
plumbing, the live diashow, all UI surfaces (with dark mode), and a
viewer rebuild.

Headline features:
- Live diashow with two-queue policy (live drains first, shuffle as
  fallback) and pluggable transitions. Design: docs/CONCEPT_DIASHOW.md.
- Dark mode: 'system' / 'light' / 'dark' preference, picked in the
  onboarding step + the Account page, applied via Tailwind v4
  class-based dark variant with FOUC guard.
- Host/Admin PIN reset with one-time PIN modal; admin may also reset
  host PINs. Hosts may demote other hosts.
- Per-user dynamic storage quota enforced on upload + live widget in
  My Account and on the upload screen. Toggleable per-area.
- All rate-limits + quotas individually toggleable from the admin
  config UI (rendered as switches + a privacy_note textarea).
- Mobile-first gestures: long-press → context sheet, double-tap to
  like with heart-burst. Buttons stay as desktop equivalents.
- Data mode (Saver vs Original) per device, applied across feed,
  lightbox, and diashow.
- Per-event Datenschutzhinweis admin-editable, live-refreshed on all
  clients via SSE event-updated.
- /api/v1/upload/{id}/original endpoint, /me/context + /me/quota.

Hardening (latent issues from the long-term review):
- Startup recovery for stuck compression / export jobs after a crash.
- Hourly cleanup of expired sessions + cold rate-limiter HashMap keys.
- ffmpeg 120s timeout with kill_on_drop (no more permit leaks).
- Per-user IndexedDB upload queue (no more cross-user leak on shared
  devices); IDB schema bumped to v2.
- SSE reconnect uses exponential backoff (no more retry storm).
- PIN lockout no longer escalates — attempts reset when the cooldown
  expires.
- soft_delete is now transactional and decrements total_upload_bytes
  so quotas don't drift.
- pin-reset SSE handler filters by user_id so a host resetting Anna's
  PIN doesn't clear Bob's cached PIN.
- Privacy note shown preformatted; admin-editable, ≤16 KiB cap.

Docs:
- New: FEATURES.md (role matrix), USER_JOURNEYS.md, IDEAS.md,
  CONCEPT_DIASHOW.md, backend/migrations/README.md,
  frontend/src/lib/README.md.
- Refresh: PROJECT.md, README.md, TEST_GUIDE.md, the two existing
  CONCEPT_*.md banners.
2026-05-16 14:39:13 +02:00
MechaCat02
16d8bdb680 Merge feat/html-viewer-export: SvelteKit-static offline viewer
Replaces the old single-file minijinja HTML export with a polished
read-only SvelteKit app shipped alongside the event data. Same
components and Tailwind tokens as the live app — visual parity with
zero divergence risk.

Brings in 4 commits:
  4f96653 feat: add export-viewer SvelteKit static app
  2fd66a8 chore: build and commit export-viewer static output
  ffc926b feat: replace HTML export with SvelteKit viewer
  1685bf1 fix: update export page guide text for new viewer

Design: docs/CONCEPT_HTML_VIEWER.md
2026-05-16 14:38:14 +02:00
MechaCat02
2761ac7db6 chore: rebuild export-viewer with the shared Tailwind theme
Pre-built output regenerated after frontend/export-viewer/src/app.css
started importing ../../src/tailwind-theme.css. Output now picks up the
same @theme tokens and class-driven dark variant as the live app, so
future viewer-side use of bg-primary / dark: utilities will resolve
identically to the main bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:33:44 +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
2e98f5ddf5 backend(features): quota enforcement, PIN reset, /me, original download, toggles
- handlers/me.rs (new): GET /api/v1/me/context (profile + role + privacy_note
  + quota toggle state, fetched once on app bootstrap) and GET /api/v1/me/quota
  (live used / limit / active uploaders / free disk).
- handlers/upload.rs:
  - quota enforcement via the dynamic formula
    floor((free_disk * tolerance) / max(active_uploaders, 1)),
    gated by quota_enabled + storage_quota_enabled toggles
  - new GET /api/v1/upload/{id}/original — unauthed by design
    (matches /media/previews/* — URL is the secret) so it works as
    <img src> / <video src> / window.open
  - rate-limit toggle wiring (rate_limits_enabled + upload_rate_enabled)
- handlers/host.rs:
  - POST /api/v1/host/users/{id}/pin-reset — Host may reset guest PINs,
    Admin may reset guest + host PINs (never another admin or self).
    Returns the freshly-generated plaintext PIN once; emits a global
    pin-reset SSE so the affected user's device can clear its localStorage.
  - set_role guard expanded so hosts can demote other hosts (not self,
    never admins) — backend match for the doc'd permission model.
- handlers/admin.rs: ALLOWED_KEYS split into NUMERIC_KEYS / BOOL_KEYS /
  TEXT_KEYS with per-kind validation; saving privacy_note broadcasts an
  event-updated SSE so other clients refresh live.
- handlers/feed.rs, handlers/admin.rs (export), auth/handlers.rs:
  rate-limit toggle wiring at every limiter call site.
- auth/handlers.rs: when an expired PIN lockout is detected on /recover,
  reset failed_pin_attempts to zero before the bcrypt check — without
  this every wrong PIN re-locked the user after the cooldown.
- main.rs: wire startup_recovery + spawn_periodic_tasks, register the
  new /me/context, /me/quota, /upload/{id}/original, and
  /host/users/{id}/pin-reset routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:32:05 +02:00
MechaCat02
141c918dd5 backend(infra): shared config helper, startup recovery, periodic maintenance
Foundations for the v0.16 features. No new endpoints here — those land in
the next commit on top of these.

- migrations 008 + 009: commit the load-bearing compression_status column
  that was uncommitted on disk; add 009_feature_toggles seeding the master
  + per-endpoint rate-limit switches, the master + per-area quota switches,
  and the admin-editable privacy_note.
- services/config.rs (new): get_str / get_i64 / get_usize / get_f64 / get_bool
  consolidating the scattered helpers that lived in three handlers.
- services/maintenance.rs (new):
  - startup_recovery() — resets compression_status='processing' and
    export_job.status='running' rows orphaned by a previous crashed
    instance, so users never see permanent "Wird vorbereitet…" spinners.
  - spawn_periodic_tasks() — hourly cleanup of expired sessions (rows
    were never pruned) + rate-limiter HashMap pruning (windows kept one
    entry per IP forever).
- services/jobs.rs (new sketch): BackgroundJob trait + JobContext for
  future jobs to plug into the same progress + SSE pipeline as
  compression/export. Not wired yet — codifies the convention.
- services/compression.rs: 120s hard timeout + kill_on_drop on ffmpeg
  so a malformed video can't hang and leak a worker semaphore permit.
- services/rate_limiter.rs: new prune() called from the periodic task.
- state.rs: SseEvent::new() constructor so event-type strings stay
  consistent instead of being typed inline at every emit site.
- models/user.rs: UserRole::as_str() for /me/context serialization.
- models/upload.rs: soft_delete() now runs in a transaction and
  decrements the uploader's total_upload_bytes (GREATEST(0, …) guard) —
  fixes a quota drift where deleting reclaimed no quota.
- Cargo.toml + Cargo.lock: add `infer = "0.15"` (multipart MIME sniffing
  used by the upload handler).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:31:41 +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
f7fdfa4627 docs: add mobile testing guide
Comprehensive testing guide for the v0.15.0 mobile-first UI
covering bottom nav, upload FAB/sheet, feed views, search,
account, host/admin dashboards, and edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:29:25 +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
ffc926bf4d feat: replace HTML export with SvelteKit viewer
Replace the minijinja-based HTML export with a full SvelteKit static
viewer app. The new export produces a ZIP with:
- Pre-built viewer assets (index.html + JS/CSS bundle)
- data.json with all posts, comments, tags, and like counts
- Processed media: 400px thumbnails for grid, full images (2000px
  cap if >5MB), video thumbnails via ffmpeg

Remove minijinja dependency, add include_dir to embed viewer assets
at compile time. Update Dockerfile to copy static/ for builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:26:03 +02:00
MechaCat02
2fd66a800a chore: build and commit export-viewer static output
Pre-built SvelteKit static output for embedding into HTML export ZIPs.
When viewer source changes, rebuild with `npm run build` in
frontend/export-viewer/ and re-commit this directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:01:08 +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
MechaCat02
4a5506f32d feat: mobile-first UI redesign (v0.15.0)
- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages
- Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer
- Upload page redesigned as full-screen composer with thumbnail strip, textarea,
  quick-tag chips, sticky submit button; bottom nav suppressed while composing
- Slim upload progress bar above bottom nav driven by queue state
- Feed: list/grid view toggle; list = chronological full-width FeedListCard;
  grid = 3-col with search bar, autocomplete from loaded posts, filter chips
- Account page: role-gated dashboard links (Host / Admin); Konto section with
  leave-confirm bottom sheet; no more per-page header nav icons
- Host dashboard: back arrow, collapsible sections, 2-col stats, user search
- Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer),
  stacked config inputs with sticky save, new Nutzer tab
- BottomNav hidden on unauthenticated pages via isAuthenticated store
- FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB
- Concept docs added to docs/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:40:57 +02:00