Commit Graph

15 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration 010 also adds the latent comment/comment_hashtag indexes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:50 +02:00
fabi
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
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
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
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
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
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
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
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