diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs index e0786aa..492b936 100644 --- a/backend/src/auth/middleware.rs +++ b/backend/src/auth/middleware.rs @@ -1,4 +1,4 @@ -use axum::extract::{FromRequestParts, State}; +use axum::extract::FromRequestParts; use axum::http::request::Parts; use uuid::Uuid; diff --git a/backend/src/handlers/feed.rs b/backend/src/handlers/feed.rs index 0a6f21f..86b72b1 100644 --- a/backend/src/handlers/feed.rs +++ b/backend/src/handlers/feed.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::auth::middleware::AuthUser; use crate::error::AppError; use crate::services::config; +use crate::services::media_token; use crate::services::rate_limiter::client_ip; use crate::state::AppState; @@ -27,6 +28,8 @@ pub struct FeedUpload { pub uploader_name: String, pub preview_url: Option, pub thumbnail_url: Option, + /// Signed gateway URL for the full-resolution original. Always present. + pub original_url: Option, pub mime_type: String, pub caption: Option, pub like_count: i64, @@ -55,6 +58,33 @@ struct FeedRow { created_at: DateTime, } +/// Build a feed DTO, minting fresh signed gateway URLs for each artifact. The +/// preview/thumbnail URLs are present only when the derivative exists so the +/// client can show a skeleton while compression is still running; the original +/// URL is always present. +fn to_feed_upload(r: FeedRow, liked: bool, secret: &str, now: i64) -> FeedUpload { + FeedUpload { + liked_by_me: liked, + preview_url: r + .preview_path + .as_ref() + .map(|_| media_token::signed_url(secret, "preview", r.id, now)), + thumbnail_url: r + .thumbnail_path + .as_ref() + .map(|_| media_token::signed_url(secret, "thumbnail", r.id, now)), + original_url: Some(media_token::signed_url(secret, "original", r.id, now)), + id: r.id, + user_id: r.user_id, + uploader_name: r.uploader_name, + mime_type: r.mime_type, + caption: r.caption, + like_count: r.like_count, + comment_count: r.comment_count, + created_at: r.created_at, + } +} + pub async fn feed( State(state): State, auth: AuthUser, @@ -135,24 +165,13 @@ pub async fn feed( let upload_ids: Vec = rows.iter().map(|r| r.id).collect(); let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await; + let now = Utc::now().timestamp(); + let secret = &state.config.jwt_secret; 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}")); - FeedUpload { - liked_by_me: liked_set.contains(&r.id), - id: r.id, - user_id: r.user_id, - uploader_name: r.uploader_name, - preview_url, - thumbnail_url, - mime_type: r.mime_type, - caption: r.caption, - like_count: r.like_count, - comment_count: r.comment_count, - created_at: r.created_at, - } + let liked = liked_set.contains(&r.id); + to_feed_upload(r, liked, secret, now) }) .collect(); @@ -202,20 +221,13 @@ pub async fn feed_delta( let upload_ids: Vec = rows.iter().map(|r| r.id).collect(); let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await; + let now = Utc::now().timestamp(); + let secret = &state.config.jwt_secret; let uploads = rows .into_iter() - .map(|r| FeedUpload { - liked_by_me: liked_set.contains(&r.id), - 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}")), - mime_type: r.mime_type, - caption: r.caption, - like_count: r.like_count, - comment_count: r.comment_count, - created_at: r.created_at, + .map(|r| { + let liked = liked_set.contains(&r.id); + to_feed_upload(r, liked, secret, now) }) .collect(); diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index 6cad627..b7365c5 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -324,24 +324,21 @@ pub async fn host_delete_upload( RequireHost(auth): RequireHost, Path(upload_id): Path, ) -> Result { - let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) + let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id) .await? .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; - let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?; - if !deleted { - return Err(AppError::NotFound("Upload nicht gefunden.".into())); - } + crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await; let _ = state.sse_tx.send(SseEvent::new( "upload-deleted", - serde_json::json!({ "upload_id": upload.id }).to_string(), + serde_json::json!({ "upload_id": upload_id }).to_string(), )); tracing::info!( actor_user_id = %auth.user_id, event_id = %auth.event_id, - upload_id = %upload.id, + upload_id = %upload_id, "host: host_delete_upload" ); diff --git a/backend/src/handlers/media.rs b/backend/src/handlers/media.rs new file mode 100644 index 0000000..88c1de6 --- /dev/null +++ b/backend/src/handlers/media.rs @@ -0,0 +1,117 @@ +//! Authenticated media gateway. +//! +//! Replaces the raw `/media` `ServeDir`. Every media byte now flows through a +//! signature check plus a DB lookup, so: +//! - soft-deleted uploads 404 (`find_by_id` filters `deleted_at`) — closes C3, +//! - ban-hidden uploaders' artifacts 404 (mirrors the `v_feed` rule) — H2, +//! - the export archives (which have no `upload` row) are unreachable — C1, +//! - HTML/SVG can't be served as an active document — the response carries +//! `nosniff` + a locked-down CSP (defense-in-depth behind the upload-time +//! allowlist) — C2 sink. +//! +//! Access is authorized by the embedded HMAC signature (see `media_token`), not +//! a Bearer header, because ``/`