diff --git a/.gitignore b/.gitignore index d03afd4..a34803d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ e2e/playwright-report/ e2e/test-results/ e2e/.cache/ e2e/.env.test +# Playwright artifacts when run from the repo root instead of e2e/ +/test-results/ +/playwright-report/ # OS .DS_Store diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 987a1f0..f687255 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -121,15 +121,18 @@ pub async fn patch_config( // Numeric keys validated as f64; boolean keys validated as truthy strings; the // privacy note is free text. Splitting these explicitly is verbose but makes the // failure mode for typos obvious (`Unbekannter Schlüssel: ...`). - const NUMERIC_KEYS: &[&str] = &[ - "max_image_size_mb", - "max_video_size_mb", - "upload_rate_per_hour", - "feed_rate_per_min", - "export_rate_per_day", - "quota_tolerance", - "estimated_guest_count", - "compression_concurrency", + // (key, integer_only, min, max). Ranges reject values that `parse::` would + // accept but that silently revert to the hardcoded default at read time + // (get_usize/get_i64 can't parse negatives/NaN/fractionals). `compression_concurrency` + // is intentionally absent — it's read once at boot, so a live edit was a no-op. + const NUMERIC_SPECS: &[(&str, bool, f64, f64)] = &[ + ("max_image_size_mb", true, 1.0, 1024.0), + ("max_video_size_mb", true, 1.0, 10240.0), + ("upload_rate_per_hour", true, 1.0, 100_000.0), + ("feed_rate_per_min", true, 1.0, 100_000.0), + ("export_rate_per_day", true, 1.0, 100_000.0), + ("quota_tolerance", false, 0.0, 1.0), + ("estimated_guest_count", true, 1.0, 1_000_000.0), ]; const BOOL_KEYS: &[&str] = &[ "rate_limits_enabled", @@ -150,10 +153,26 @@ pub async fn patch_config( // update behind — validation must fully precede any write. for (key, value) in &body { let key_str = key.as_str(); - if NUMERIC_KEYS.contains(&key_str) { - if value.parse::().is_err() { + if let Some(&(_, integer_only, min, max)) = + NUMERIC_SPECS.iter().find(|(k, ..)| *k == key_str) + { + let n = value.trim().parse::().ok().filter(|n| n.is_finite()); + let n = match n { + Some(n) => n, + None => { + return Err(AppError::BadRequest(format!( + "Ungültiger Wert für {key}: muss eine Zahl sein." + ))) + } + }; + if integer_only && n.fract() != 0.0 { return Err(AppError::BadRequest(format!( - "Ungültiger Wert für {key}: muss eine Zahl sein." + "Ungültiger Wert für {key}: muss eine ganze Zahl sein." + ))); + } + if n < min || n > max { + return Err(AppError::BadRequest(format!( + "Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}–{max})." ))); } } else if BOOL_KEYS.contains(&key_str) { @@ -282,7 +301,7 @@ pub async fn download_zip( )); } - let path = state.config.media_path.join("exports").join("Gallery.zip"); + let path = state.config.export_path.join("Gallery.zip"); if !path.exists() { return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); } @@ -308,7 +327,7 @@ pub async fn download_html( )); } - let path = state.config.media_path.join("exports").join("Memories.zip"); + let path = state.config.export_path.join("Memories.zip"); if !path.exists() { return Err(AppError::NotFound("Exportdatei nicht gefunden.".into())); } diff --git a/backend/src/state.rs b/backend/src/state.rs index d4e3e05..7743bf4 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -36,8 +36,12 @@ pub struct AppState { impl AppState { pub fn new(pool: PgPool, config: AppConfig) -> Self { let (sse_tx, _) = broadcast::channel(256); - let compression = - CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone()); + let compression = CompressionWorker::new( + pool.clone(), + config.media_path.clone(), + config.compression_concurrency, + sse_tx.clone(), + ); Self { pool, config, diff --git a/docs/SECURITY-BACKLOG.md b/docs/SECURITY-BACKLOG.md index cbb8ab4..e90ab40 100644 --- a/docs/SECURITY-BACKLOG.md +++ b/docs/SECURITY-BACKLOG.md @@ -64,8 +64,11 @@ decompression-bomb cap (`image::Limits` 12000×12000 / 256 MiB) lives in (not just the exact dev sentinel) and enforces `len ≥ 32` unconditionally in prod. - **Unspoofable client IP in the rate limiter** — `client_ip` now takes the right-most `X-Forwarded-For` entry (the hop Caddy appends), so a client-supplied left-most value is ignored. -- **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB role and - `is_banned` flag rather than the JWT claim, revoking demoted/banned sessions immediately. +- **Live role/ban re-check** — the auth extractor re-reads the user row and trusts the DB `role` + and `is_banned` flag rather than the JWT claim, so a demote/ban takes effect immediately (no + 30-day token window). Bans are enforced on write handlers + the host/admin extractors, preserving + the documented read-only access for banned guests (USER_JOURNEYS §10); demoted hosts lose host + powers at once. - **Container healthchecks** — `app` and `frontend` now have healthchecks and Caddy waits on `service_healthy`. - **Event-scoped `ban_user`** — the ban UPDATE is now scoped by `event_id` like its siblings. diff --git a/docs/USER_JOURNEYS.md b/docs/USER_JOURNEYS.md index b69e989..6031d34 100644 --- a/docs/USER_JOURNEYS.md +++ b/docs/USER_JOURNEYS.md @@ -147,12 +147,18 @@ the Host can clean up later). ## 10. Banned-guest experience -1. The banned user's next authenticated request returns HTTP 403 with a clear message - ("Du bist gesperrt."). -2. They can still browse the read-only feed (and download the export once it's released). -3. They cannot upload, like, or comment. -4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the - feed for everyone (the `v_feed` view already enforces this). +1. The ban takes effect immediately on the banned user's existing session — the auth + layer re-reads the live `is_banned` flag on every request rather than trusting the + JWT, so there's no 30-day token-lifetime window. +2. Their next authenticated *write* request returns HTTP 403 with a clear message + ("Du bist gesperrt."). Ban enforcement lives on the write handlers and the + host/admin extractors, not the base auth extractor. +3. They can still browse the read-only feed (and download the export once it's released). +4. They cannot upload, like, or comment; a banned host also loses all host/admin + actions (including unbanning themselves). +5. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the + feed for everyone (`v_feed` enforces this), and a live `user-hidden` SSE event evicts + their cards from every open feed + the diashow without a reload. ## 11. Admin — instance configuration diff --git a/e2e/Caddyfile.test b/e2e/Caddyfile.test index 84d505b..02fb85c 100644 --- a/e2e/Caddyfile.test +++ b/e2e/Caddyfile.test @@ -4,7 +4,17 @@ # of HTTPS/Let's Encrypt. :3101 { - encode zstd gzip + # Mirror prod: exclude the SSE stream from compression so buffering doesn't + # delay real-time events (and so the test stack exercises the real behavior). + @compressible not path /api/v1/stream + encode @compressible zstd gzip + + # Mirror prod's security headers (minus HSTS, which is HTTPS-only). + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + } reverse_proxy /api/* app:3000 reverse_proxy /media/* app:3000 diff --git a/e2e/helpers/seed.ts b/e2e/helpers/seed.ts index 5b97741..a243e2e 100644 --- a/e2e/helpers/seed.ts +++ b/e2e/helpers/seed.ts @@ -3,11 +3,26 @@ * flow. Centralising the API contract (routes, field names, expected statuses) * means an API change is a one-file edit, not a 7-file hunt. */ -import { uploadRaw, JPEG_MAGIC } from './upload-client'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { uploadRaw } from './upload-client'; import { db } from '../fixtures/db'; const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; +// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the +// backend when compression fails (a failed transcode refunds quota + deletes the +// row), which would delete the seeded upload out from under the test. Read lazily +// at call time (cwd is the e2e dir at runtime, like the gallery-path spec) so test +// collection can't trip on a module-load read. +let sampleJpg: Buffer | null = null; +function sampleImage(): Buffer { + if (!sampleJpg) { + sampleJpg = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')); + } + return sampleJpg; +} + export type SeedUploadOptions = { caption?: string; /** Mark compression done so the card is fully rendered in the feed. Default true. */ @@ -16,9 +31,7 @@ export type SeedUploadOptions = { /** Seed a real, accepted upload owned by `jwt` and return its id. */ export async function seedUpload(jwt: string, opts: SeedUploadOptions = {}): Promise { - const body = new Uint8Array(1024); - body.set(JPEG_MAGIC, 0); - const res = await uploadRaw(jwt, body, { + const res = await uploadRaw(jwt, sampleImage(), { filename: 'a.jpg', contentType: 'image/jpeg', caption: opts.caption, diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 3b85167..252a35f 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -83,6 +83,9 @@ export function clearAuth(): void { if (!browser) return; localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_ID_KEY); + // Clear the display name too — on a shared device the next user shouldn't see + // the previous guest's name pre-filled on /join. + localStorage.removeItem(DISPLAY_NAME_KEY); // PIN is intentionally kept so the user can recover isAuthenticated.set(false); // Hooks fire in registration order. Keep them dependency-free of each other —