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>
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).
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>
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).
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>
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.
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>
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>
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>
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.
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>
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.
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>
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>
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>
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>
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>
- 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>
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>
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.
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
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>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
- 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>
Feed: shows star icon (→ /host) for host/admin, shield icon (→ /admin)
for admin only, alongside the existing account icon.
Host: shows shield icon (→ /admin) for admin only next to "Zur Galerie".
Admin: replaces "Host-Dashboard" text link with star icon (→ /host).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Enable minijinja 'json' feature so the tojson filter is available in
the Memories.html template (was causing 'unknown filter' render error)
- Replace window.location.href downloads with fetch+blob so the
Authorization header is sent (window.location.href caused 401)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Password form at /admin/login that calls POST /api/v1/admin/login and
redirects to /admin on success. Admin dashboard now redirects to
/admin/login instead of /join when unauthenticated. Test guide updated.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: migration 007 adds a case-insensitive unique index on user names
per event. join endpoint returns 409 conflict when the name is taken.
find_by_event_and_name uses LOWER() for case-insensitive recovery.
Frontend: join page handles 409 with a name-taken view — amber warning,
name-choice tips, inline PIN recovery form, and "Anderen Namen wählen"
button. Test guide updated with Steps 8 and 9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: rate limiter gains check_with_retry() returning seconds until
the next slot opens. Upload 429 responses include retry_after_secs in
JSON and a Retry-After header.
Frontend: upload queue catches 429 as RateLimitError, resets affected
item to pending, schedules processQueue() for the server-reported delay,
and shows a live countdown banner in the queue UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Disable Axum's 2 MB default body limit on the upload route so large
photos/videos are accepted without HTTP 400
- Serialize UserRole as lowercase in JWT so the frontend role checks
('guest'/'host'/'admin') match correctly
- Drain multipart body before returning early upload errors (rate-limit,
ban, event-lock) to keep the HTTP keep-alive connection clean and
prevent cascading Netzwerkfehler / empty-500 responses
- Add TraceLayer for request logging and Vite dev proxy config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 4-step dismissible onboarding overlay shown on first feed visit
(welcome, upload, hashtags, PIN importance). Dismissed state persisted
in localStorage under eventsnap_guide_seen. Step indicator dots and
skip/continue buttons included.
Update HTML export guide modal to persist the eventsnap_html_guide_seen
flag: first download shows the instructions modal; subsequent clicks go
straight to download without interruption.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>