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>
This commit is contained in:
@@ -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<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// Signed gateway URL for the full-resolution original. Always present.
|
||||
pub original_url: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub caption: Option<String>,
|
||||
pub like_count: i64,
|
||||
@@ -55,6 +58,33 @@ struct FeedRow {
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<AppState>,
|
||||
auth: AuthUser,
|
||||
@@ -135,24 +165,13 @@ pub async fn feed(
|
||||
let upload_ids: Vec<Uuid> = 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<Uuid> = 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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user