diff --git a/Caddyfile b/Caddyfile index 3ceff7c..a8bebb8 100644 --- a/Caddyfile +++ b/Caddyfile @@ -17,19 +17,20 @@ @hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$ header @hashed_assets Cache-Control "public, max-age=31536000, immutable" - # Media previews and thumbnails - @previews path /media/previews/* /media/thumbnails/* - header @previews Cache-Control "public, max-age=3600" + # Preview/thumbnail images. These are now served by the app through a + # visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation + # can revoke access; direct /media/previews|thumbnails is 404-blocked at the app. + # Privately cacheable for a short window (the app sets the same header; this is the + # edge carve-out from the blanket no-store below). Kept short so a moderated image + # stops being served to a direct-URL holder promptly. + @media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail + header @media_api Cache-Control "private, max-age=300" - # Original media files (private — only host can download). Force download - # rather than inline rendering as defense-in-depth against any future - # content-type confusion (previews/thumbnails are re-encoded and stay inline). - @originals path /media/originals/* - header @originals Cache-Control "private, max-age=86400" - header @originals Content-Disposition "attachment" - - # API — never cache - @api path /api/* + # API — never cache, EXCEPT the gated image routes above. + @api { + path /api/* + not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail + } header @api Cache-Control "no-store" # Route API and media requests to the Rust backend diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index 561e7df..f73d3ba 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -121,6 +121,18 @@ pub struct RecoverResponse { pub user_id: Uuid, } +/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used +/// to run a constant-time-ish verify on the "unknown display name" branch of +/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't +/// be told apart by timing. +fn dummy_pin_hash() -> &'static str { + static HASH: std::sync::OnceLock = std::sync::OnceLock::new(); + HASH.get_or_init(|| { + bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12) + .expect("bcrypt hashing of a static dummy value cannot fail") + }) +} + pub async fn recover( State(state): State, headers: HeaderMap, @@ -158,9 +170,13 @@ pub async fn recover( User::find_by_event_and_name(&state.pool, event.id, display_name).await?; if users.is_empty() { - return Err(AppError::NotFound( - "Kein Benutzer mit diesem Namen gefunden.".into(), - )); + // No user with this name. Run a throwaway bcrypt verify so this branch takes + // the same time as the user-exists path, and return the SAME error as a wrong + // PIN — so "no such name" and "wrong PIN" are indistinguishable by response or + // timing. Display names are already public on the feed, but this still closes + // the /recover enumeration + timing oracle. + let _ = bcrypt::verify(&body.pin, dummy_pin_hash()); + return Err(AppError::Unauthorized("PIN ist falsch.".into())); } for user in &users { diff --git a/backend/src/handlers/feed.rs b/backend/src/handlers/feed.rs index 9e40fd4..14af73f 100644 --- a/backend/src/handlers/feed.rs +++ b/backend/src/handlers/feed.rs @@ -139,8 +139,17 @@ pub async fn feed( let uploads = rows .into_iter() .map(|r| { - let preview_url = r.preview_path.map(|p| format!("/media/{p}")); - let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}")); + // Gated media aliases (visibility-checked, direct /media blocked). Emit the + // URL only when the variant actually exists — the URL is what signals the + // client which variant to load. + let preview_url = r + .preview_path + .as_ref() + .map(|_| format!("/api/v1/upload/{}/preview", r.id)); + let thumbnail_url = r + .thumbnail_path + .as_ref() + .map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)); FeedUpload { liked_by_me: liked_set.contains(&r.id), id: r.id, @@ -225,8 +234,14 @@ pub async fn feed_delta( id: r.id, user_id: r.user_id, uploader_name: r.uploader_name, - preview_url: r.preview_path.map(|p| format!("/media/{p}")), - thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")), + preview_url: r + .preview_path + .as_ref() + .map(|_| format!("/api/v1/upload/{}/preview", r.id)), + thumbnail_url: r + .thumbnail_path + .as_ref() + .map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)), mime_type: r.mime_type, caption: r.caption, like_count: r.like_count, diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index cbae4e3..231e8f7 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -219,9 +219,14 @@ pub async fn set_role( .await? .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; - if target.0 == "admin" { + // Admins are untouchable by hosts. A plain host also may not demote another + // *host*: without this guard a host could demote a peer host to guest and then + // ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer + // guards key off the target's *current* role, so a prior demotion would launder + // past them. Only an admin may change a host's role. + if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) { return Err(AppError::Forbidden( - "Admins können nicht geändert werden.".into(), + "Du darfst die Rolle dieses Benutzers nicht ändern.".into(), )); } diff --git a/backend/src/handlers/sse.rs b/backend/src/handlers/sse.rs index 7f0138f..8abb12d 100644 --- a/backend/src/handlers/sse.rs +++ b/backend/src/handlers/sse.rs @@ -59,7 +59,7 @@ pub async fn stream( .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?; let rx = state.sse_tx.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|msg| match msg { + let events = BroadcastStream::new(rx).filter_map(|msg| match msg { Ok(sse_event) => Some(Ok(Event::default() .event(sse_event.event_type) .data(sse_event.data))), @@ -73,6 +73,29 @@ pub async fn stream( } }); + // The session is only checked once at open. Re-validate it periodically so a + // logged-out or expired session's stream is closed rather than kept alive until the + // client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively* + // gone/expired session ends the stream; a transient DB error just retries next tick. + let pool = state.pool.clone(); + let session_hash = token_hash.clone(); + let session_gone = async move { + let mut ticker = tokio::time::interval(Duration::from_secs(60)); + ticker.tick().await; // consume the immediate first tick + loop { + ticker.tick().await; + match Session::find_by_token_hash(&pool, &session_hash).await { + Ok(Some(_)) => {} // still valid — keep streaming + Ok(None) => break, // logged out or expired — stop + Err(e) => { + tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying"); + } + } + } + }; + + let stream = futures::StreamExt::take_until(events, Box::pin(session_gone)); + Ok(Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(30)) diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 3f08565..09b1167 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -527,35 +527,26 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate { } } -/// Streaming download of the original file behind an upload. Used by: -/// - the per-post "Original anzeigen" context action (`window.open`) -/// - `` / `