Commit Graph

33 Commits

Author SHA1 Message Date
MechaCat02
a895bebdc2 harden(deploy): close public DB exposure + make JWT prod guard effective
Two HIGH deployment fixes found in the go-live review, plus two cheap backstops.

HIGH 1 — Postgres was published to the public internet. The committed,
auto-merged docker-compose.override.yml mapped 5432:5432 on 0.0.0.0 (and a
Docker publish bypasses ufw), so a `docker compose up` on the prod host exposed
the DB with the .env password. Bind the dev port to 127.0.0.1 so it's reachable
only from the host's loopback even when the override lands in prod. .env.example
also stops shipping `secret` as the DB password (now CHANGE_ME_*).

HIGH 2 — the JWT-secret production guard was inert. APP_ENV was never set to
production (defaulted to development), so the guard never ran; and it only
matched one stale dev sentinel, not the `.env.example` placeholder that an
operator is most likely to leave in place. Now: docker-compose.yml forces
APP_ENV=production on the app service, and config.rs refuses to boot in
production on any placeholder (change/example/placeholder/replace) or any secret
< 64 chars. Verified: placeholder and short secrets are rejected; a 128-char
secret passes. This turns "looks protected" into actually protected.

Backstops:
- frontend container now runs as the non-root `node` user (the backend was
  already hardened; the frontend wasn't).
- app gets mem_limit 3g so a miss in the in-app upload RAM caps OOM-kills the
  container instead of the 8 GB host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:35:57 +02:00
MechaCat02
2d7169e971 harden: low-severity bucket — unspoofable IP, admin floor, log/SSE hygiene
The cheap, real LOWs from the review (bucket 🅰); 🅱 items and won't-fix
decisions are recorded in docs/SECURITY-BACKLOG.md.

- XFF spoofing (highest-value): client_ip now prefers an unspoofable X-Real-IP
  (Caddy `header_up X-Real-IP {remote_host}`) and otherwise takes the *rightmost*
  X-Forwarded-For token, never the client-controlled leftmost. The join cap,
  recover throttle, and admin-login floor all keyed on this. Unit-tested.
  Caddyfile also drops the dead /media/previews|originals cache matchers (the
  gateway sets its own Cache-Control) and the false "only host can download"
  comment.
- admin_login: add a hard, non-disableable rate floor (30/5min/IP) so the DB
  rate-limit master toggle can't remove brute-force protection.
- SSE: the broadcast upload-error payload no longer includes the raw error
  (absolute filesystem paths) — generic message to clients, details logged
  server-side only.
- Access logs: the request span logs the path only, never the query string, so
  the replayable media `?sig=` capability isn't written to logs.
- Reaper TOCTOU: reap_deleted now deletes rows by the ids it actually unlinked
  (DELETE … WHERE id = ANY) instead of re-evaluating the time predicate, so a
  row crossing the grace line mid-sweep can't be row-deleted without its files
  unlinked.
- Dockerfile: run the backend as a non-root user; create /media owned by `app`
  so a fresh volume is writable.
- a11y: aria-labels on the upload caption and feed search inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:01:41 +02:00
MechaCat02
db1e5b8833 fix: post-review punch-list — upload concurrency cap, atomic release, ban SSE
Follow-up to the adversarial re-review of the audit-fix branch.

MUST-FIX:
- H3: bound aggregate upload RAM. The body limit alone didn't cap concurrency,
  so ~N parallel 550MB uploads could OOM the box. Add an Arc<Semaphore>
  (UPLOAD_MAX_CONCURRENCY=4) in AppState, acquired at the top of the upload
  handler before the multipart body is read, so waiting requests hold only a
  connection — peak buffered RAM ≈ 4×550MB. (Matches the CompressionWorker
  semaphore pattern; avoids tower's non-default `limit` feature.)
- M8: release_gallery now runs the claim UPDATE + both job INSERTs in one
  transaction, so the row lock is held until the jobs exist — closing the
  cross-table TOCTOU where two concurrent presses could both spawn workers
  racing the same output files. Export temp filenames are now per-run
  (Gallery.{uuid}.zip.tmp, viewer_tmp_{event}_{uuid}, Memories.{uuid}.zip.tmp)
  so overlapping runs can't truncate each other. Final served names unchanged.
- M11: 'user-banned' was missing from sse.ts KNOWN_EVENTS, so EventSource
  silently dropped the frame and the forced-logout handler never fired. Added.

SHOULD-FIX:
- M8-3: re-release is now allowed when nothing is in progress and at least one
  job failed (was: only when *every* job failed), so a one-sided failure (zip
  done, html failed) is no longer permanently unrecoverable.
- M18: two light-mode contrast spots the earlier sweep missed — the LightboxModal
  char-counter class: directives and the account PIN-missing notice.
- M15: the diashow auto-advance is now slowed to a ≥30s floor under
  prefers-reduced-motion (WCAG 2.2.2), making the app.css comment accurate.

Verified: cargo build + 6 tests, npm run check (0 errors), and a live smoke test
against Postgres — first release 204, second 400, one-sided-failure re-release
204, no SQL errors; a normal upload still returns 201 through the new permit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:40:54 +02:00
MechaCat02
23a7d89a89 fix(migrations): renumber feed-view migration 006 → 010
The new v_feed-rewrite migration collided with the existing 006_user_pin_lockout
(versions must be unique). Verified against a fresh Postgres: all migrations
001–010 now apply cleanly on startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:47:27 +02:00
MechaCat02
8272197cea fix(auth): escalating recovery-PIN lockout backoff (M4)
The failed-attempt counter is no longer reset when the lockout window expires,
so each further wrong PIN past the 3-strike threshold doubles the lockout —
15min, 30, 60, … capped at 24h. Combined with the now 6-digit PIN (1M space),
this makes sustained guessing via /recover infeasible. A successful recovery or
a host PIN reset clears the counter; legitimate users (who don't fail) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:28:31 +02:00
MechaCat02
5bd008591b fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal +
accessible name, a focus-trap/Escape/focus-restore $effect mirroring
ContextSheet, and inert + pointer-events-none when closed so its controls leave
the tab order and AT tree.

H12: a layout-level $effect toasts each upload-queue item the first time it
transitions to 'error', so a banned/locked/over-quota guest is actually told
why their photo didn't post instead of the composer silently closing.

M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against
loops on the auth screens), so a 401 no longer strands the user on a dead page.

M11: ban and event-lock are pushed to guests. A new uploadsLocked store is
seeded from /me/context (now exposes uploads_locked) and kept live by
event-closed/opened SSE; the feed shows a banner and the FAB is disabled when
locked. A user-banned SSE event force-logs-out the targeted user.

M19: feed and export pages track a distinct loadError and render an "Erneut
versuchen" retry card instead of collapsing a fetch failure into "empty" /
"not released". Light-mode empty-state text bumped to gray-500 for contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:16:01 +02:00
MechaCat02
cf428725b9 perf(feed): rewrite v_feed, bound feed_delta, server-time SSE cursor (H6,H7,M9)
H6: migration 006 replaces v_feed's double LEFT JOIN + COUNT(DISTINCT) (which
materialized a likes×comments Cartesian per upload) with correlated scalar
subqueries that each use their own index. Output columns are unchanged, so
feed + hashtag-filtered paths both benefit.

H7: feed_delta now applies the feed rate limit (keyed per user), caps results
at 200 rows, and clamps how far back a client `since` may reach (7 days). When
clamped or capped it returns reload_required=true; the SSE client turns that
into a full feed reload instead of streaming the whole gallery through the view
on every tab refocus.

M9: the SSE reconnect cursor is now advanced only from server timestamps (an
upload's created_at, seeded from the feed and updated on new-upload events and
delta responses), never the client clock — so a skewed phone clock can't drop
events missed while backgrounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:58:35 +02:00
MechaCat02
ae6c496f94 fix(authz): event-scope social handlers, atomic gallery release (M1, M3, M8)
M1: toggle_like / add_comment / list_comments now resolve the target via
Upload::find_by_id_and_event, returning 404 for uploads outside the caller's
event or already soft-deleted — closing the cross-event IDOR and the
write-to-deleted-post path.

M3: the redundant in-handler is_banned fetches (upload, like, comment) are
removed; the AuthUser extractor (WS1) already rejects banned users on every
authenticated route, which also covers the previously-ungated edit_upload /
delete_upload / delete_comment.

M8: release_gallery claims the release with a single conditional UPDATE and only
proceeds when it matches a row, so concurrent presses can't spawn duplicate
export workers racing on the same files. Re-release is now permitted when all
prior export jobs terminally failed (e.g. a crash mid-export), and the job
upsert resets failed rows so the workers re-run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:43:12 +02:00
MechaCat02
9364cb624a fix(dos): bound uploads, offload bcrypt, cap image decode, split queues
H3: re-enable a request-body limit (550MB, sized to the largest allowed video +
multipart overhead) instead of DefaultBodyLimit::disable(), and drop the
second full-file copy by keeping the upload as Bytes.

H4: image decode now goes through ImageReader with explicit dimension
(12000x12000) and allocation caps, rejecting decompression bombs before the
expensive resize, and the whole spawn_blocking is wrapped in a 120s timeout
mirroring the ffmpeg guard.

H8: every bcrypt hash/verify (join, recover, admin_login, host PIN reset) now
runs via spawn_blocking through a new services::password helper, so concurrent
auths can't pin the async workers.

M7: images and videos get independent compression permit pools, so slow videos
can no longer starve image-preview generation.

H5: add a generous per-IP daily account-creation cap (NAT-safe at ~100 guests)
on top of the burst cap, and wire up the previously-dead upload_count_quota_*
config as an event-wide file-count cap that a re-join cannot reset.

Also bumps generated recovery PINs from 4 to 6 digits (part of M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:38:59 +02:00
MechaCat02
8faf702208 feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:

- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
  download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
  CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
  uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
  get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
  commit; soft_delete returns the paths; an hourly reaper reclaims disk for
  rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).

Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).

Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:30:30 +02:00
MechaCat02
ab9f1d89b2 fix(upload): server-side MIME/ext allowlist + atomic transactional write
C2: stored-XSS via the application/* MIME bypass is closed — the declared
Content-Type and client filename are no longer trusted. New classify() derives
both the canonical MIME and a safe extension purely from magic bytes against a
strict allowlist (jpeg/png/webp/heic/mp4/mov/webm); anything infer can't
positively identify (incl. HTML/SVG script payloads) is rejected. Unit tests
cover accept + reject paths.

M12: the stored extension can no longer carry '/' or arbitrary client text —
it's one of the allowlist's safe constants, ending directory-pollution and the
export DoS.

M5/M6: quota is now reserved with a single conditional UPDATE that row-locks
(no TOCTOU overshoot) and the reservation + row INSERT run in one transaction;
the file is written before commit and unlinked on any rollback, so a crash
mid-write can no longer leak quota bytes or orphan a row. Upload::create is now
executor-generic to run inside the tx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:19:34 +02:00
MechaCat02
2068e8c1f3 fix(auth): reconcile role/ban from DB, revoke sessions, fix pin/comment bugs
H1: AuthUser now JOINs session→user and sources role/ban/event from the live
DB row instead of trusting JWT claims. Bans take effect on the next request;
demotion/promotion is immediate. Adds Session::find_auth_context and rejects
tokens whose sub/event_id disagree with the session row. This also closes M2
(banned export) and M3 (banned edit/delete) globally — banned users can no
longer pass the AuthUser extractor.

Adds Session::delete_by_user_id and revokes all of a user's sessions on
ban_user, set_role, and reset_user_pin so existing JWTs die immediately.
ban_user also emits a user-banned SSE for forced client logout.

H9: reset_user_pin used a non-existent column pin_failed_attempts (runtime
sqlx, so it 500'd in prod). Corrected to failed_pin_attempts — the only
account-recovery path now works.

C4: LightboxModal posted comments to /comment (singular); backend only
registers /comments. One-character route fix re-enables the comment feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:14:22 +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
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
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
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
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
d0a199e9b5 fix: HTML export tojson filter + authenticated file download (v0.14.1)
- 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>
2026-04-03 20:01:48 +02:00
MechaCat02
0351e967c0 feat: unique display names + inline recover on join (v0.13.1)
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>
2026-04-03 18:37:51 +02:00
MechaCat02
de0e395a9e feat: auto-retry uploads when rate limited (v0.13.0)
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>
2026-04-03 18:37:51 +02:00
MechaCat02
3dc69e6c6d fix: upload body limit, role case, and connection drain (v0.12.1)
- 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>
2026-04-03 18:14:12 +02:00
MechaCat02
989d88022a feat: implement rate limiting across all API endpoints
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:03:59 +02:00
MechaCat02
258e2bd84d feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.

Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
  async_zip (Stored compression), organised into Photos/ and Videos/
  with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
  inlined CSS + JS, relative media paths); packs it with README.txt and
  all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
  export_zip_ready / export_html_ready on the event, and broadcast SSE
  export-progress + export-available when both complete

Backend — new endpoints (AuthUser):
- GET /export/zip   → streams Gallery.zip if export_zip_ready
- GET /export/html  → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)

Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)

Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
  download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:56:21 +02:00
MechaCat02
32c16da3e2 feat: implement admin dashboard
Add Admin Dashboard at /admin for server configuration, disk usage
monitoring, and export job status, plus a public export/status endpoint.

Backend — new /api/v1/admin/* endpoints (RequireAdmin auth):
- GET  /admin/stats           → user/upload/comment counts + disk usage
- GET  /admin/config          → all config key/value pairs
- PATCH /admin/config         → update any subset of config keys; validates
                                 key whitelist and numeric values
- GET  /admin/export/jobs     → export_job rows for the event

Backend — public (AuthUser) endpoint:
- GET  /export/status         → released flag + zip/html job status/progress

Frontend — /admin page:
- Stats grid: guest count, upload count, comment count
- Disk usage bar with GB/MB formatting; red ≥ 90%, amber ≥ 75%
- Config form: labelled numeric inputs for all eight config keys,
  sends only changed values on save
- Export jobs list: type label, status badge, progress bar for running jobs,
  error message if failed; manual refresh button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:45:37 +02:00
MechaCat02
71a2987a3e feat: implement host dashboard
Add Host Dashboard for event and guest management, accessible at /host.

Backend — new /api/v1/host/* endpoints (RequireHost auth):
- GET  /host/event                    → event name + lock/release state
- POST /host/event/close|open         → lock or unlock uploads; SSE broadcast
- POST /host/gallery/release          → set release timestamp, enqueue export jobs
- GET  /host/users                    → all guests with upload count & bytes
- POST /host/users/{id}/ban           → ban with optional upload-hide choice
- POST /host/users/{id}/unban         → lift ban
- PATCH /host/users/{id}/role         → promote guest→host or demote host→guest
- DELETE /host/upload/{id}            → host-level soft-delete + SSE
- DELETE /host/comment/{id}           → host-level soft-delete

Frontend — /host page:
- Event controls: lock/unlock toggle and release-gallery button with status badges
- Guest table: display name, role badge, upload count, storage used
- Ban flow: modal asking whether to keep or hide the user's uploads
- Promote/demote buttons respecting caller role (host can promote guests; admin can demote hosts)
- auth.ts: getRole() decodes JWT payload client-side to gate the route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:43:09 +02:00
fabi
964598e41d feat: implement gallery feed with social features and SSE
- Cursor-based feed endpoint using v_feed view with hashtag filtering
- Like toggle (INSERT ON CONFLICT), comments CRUD
- Feed delta endpoint for SSE-driven incremental updates
- SSE client with Page Visibility API (pause/reconnect)
- Responsive photo/video grid with infinite scroll
- Hashtag filter chips, lightbox modal with comments
- Media file serving via tower-http ServeDir

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:17:06 +02:00
fabi
3f052a4f91 feat: implement upload pipeline with compression and SSE
Backend:
- POST /api/v1/upload: multipart file upload with caption + hashtags
  - Validates file size against DB config limits (image/video separate)
  - Checks user ban status and event upload lock
  - Saves original to disk under {media_path}/originals/{slug}/
  - Tracks user total_upload_bytes for quota enforcement
  - Extracts hashtags from caption text and explicit CSV field
  - Upserts hashtags and links them to uploads
- PATCH /api/v1/upload/{id}: edit caption and hashtags (owner only)
- DELETE /api/v1/upload/{id}: soft-delete (owner only)
- GET /api/v1/stream: SSE endpoint with 30s keepalive
  - Broadcasts new-upload events to all connected clients
  - Uses tokio broadcast channel for fan-out

Services:
- CompressionWorker: Tokio semaphore-bounded (concurrency=2) background processor
  - Images: resize to 800px wide JPEG preview via image crate
  - PNG originals: lossless compression via oxipng
  - Videos: ffmpeg thumbnail extraction (1 frame at 1s, scaled to 800px)
  - Updates upload record with preview_path/thumbnail_path on completion

Models:
- Upload with full CRUD (create, find, update caption, soft delete, set paths)
- Hashtag with upsert, link/unlink, extract_hashtags() text parser
- UploadDto for API serialization with like/comment counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 21:48:59 +02:00
fabi
8b9d916265 feat: implement authentication flow
Backend:
- AppConfig, AppError, AppState modules for shared infrastructure
- JWT creation/verification with HS256 (jsonwebtoken crate)
- Session management: SHA-256 token hashing, DB-backed sessions
- Auth middleware: AuthUser, RequireHost, RequireAdmin extractors
- POST /api/v1/join: name-only registration, 4-digit PIN + bcrypt hash
- POST /api/v1/recover: PIN-based recovery with 3-attempt lockout (15 min)
- POST /api/v1/admin/login: bcrypt password verification
- DELETE /api/v1/session: logout (session invalidation)
- Migration 006: user PIN lockout columns (failed_pin_attempts, pin_locked_until)
- Models: Event, User (with role enum), Session with all CRUD methods

Frontend:
- api.ts: typed fetch wrapper with automatic Bearer token injection
- auth.ts: JWT/PIN localStorage management with Svelte store
- /join: name entry form with PIN display modal and copy button
- /recover: name + PIN recovery form with saved PIN pre-fill
- /feed: placeholder gallery page with logout
- Root layout: auth initialization on mount
- Root page: redirect to /join or /feed based on auth state

All responses use German language strings as specified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 21:44:03 +02:00
fabi
e976f0f670 feat: add database schema and SQLx migrations
- 5 reversible migrations: extensions/enums, tables, indexes, views, config seed
- Tables: event, user, session, upload, hashtag, upload_hashtag, comment,
  comment_hashtag, like, export_job, config
- Views: v_feed (uploads with like/comment counts), v_hashtag_counts
- Indexes optimised for feed queries, session lookup, hashtag filtering
- Config table seeded with default rate limits and quotas
- db.rs module: PgPool creation with auto-migration on startup
- docker-compose.override.yml: expose db port 5432 for local dev
- Fix crate names: async_zip, tower_governor (underscore, not hyphen)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 21:15:25 +02:00
fabi
b89b1d6ffa chore: scaffold monorepo for EventSnap
- Rust/Axum backend skeleton with all crates, multi-stage Dockerfile
- SvelteKit + TypeScript frontend with Tailwind CSS v4, adapter-node, Dockerfile
- docker-compose.yml: db (postgres:16) → app → frontend → caddy with healthcheck and named volumes
- Caddyfile: TLS via Let's Encrypt, cache headers, API/media routing to backend
- .env.example: all environment variables documented with defaults
- README.md: project overview, features, stack, deploy guide, roadmap
- .gitignore: excludes secrets, build artifacts, node_modules, media uploads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:15:44 +02:00