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:
@@ -1,4 +1,4 @@
|
||||
use axum::extract::{FromRequestParts, State};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -324,24 +324,21 @@ pub async fn host_delete_upload(
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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"
|
||||
);
|
||||
|
||||
|
||||
117
backend/src/handlers/media.rs
Normal file
117
backend/src/handlers/media.rs
Normal file
@@ -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 `<img>`/`<video>` cannot send one.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::Response;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::models::user::User;
|
||||
use crate::services::media_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MediaQuery {
|
||||
pub exp: i64,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
State(state): State<AppState>,
|
||||
Path((kind, id)): Path<(String, Uuid)>,
|
||||
Query(q): Query<MediaQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !media_token::verify(
|
||||
&state.config.jwt_secret,
|
||||
&kind,
|
||||
id,
|
||||
q.exp,
|
||||
&q.sig,
|
||||
Utc::now().timestamp(),
|
||||
) {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Ungültige oder abgelaufene Medien-URL.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// `find_by_id` filters `deleted_at IS NULL`, so soft-deleted uploads 404.
|
||||
let upload = Upload::find_by_id(&state.pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||
|
||||
// Hide artifacts of users whose uploads were hidden by a host (ban + hide),
|
||||
// matching the `usr.uploads_hidden = FALSE` predicate in `v_feed`.
|
||||
let uploader = User::find_by_id(&state.pool, upload.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||
if uploader.uploads_hidden {
|
||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
let (rel_path, content_type) = match kind.as_str() {
|
||||
"original" => (upload.original_path.clone(), upload.mime_type.clone()),
|
||||
"preview" => (
|
||||
upload
|
||||
.preview_path
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
|
||||
"image/jpeg".to_string(),
|
||||
),
|
||||
"thumbnail" => (
|
||||
upload
|
||||
.thumbnail_path
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
|
||||
"image/jpeg".to_string(),
|
||||
),
|
||||
_ => return Err(AppError::NotFound("Unbekannter Medientyp.".into())),
|
||||
};
|
||||
|
||||
stream_file(&state.config.media_path.join(&rel_path), &content_type).await
|
||||
}
|
||||
|
||||
async fn stream_file(path: &std::path::Path, content_type: &str) -> Result<Response, AppError> {
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, StatusCode};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("Datei nicht gefunden.".into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_LENGTH, metadata.len())
|
||||
// URLs are signed + time-boxed; cache only privately, aligned to the
|
||||
// 1h URL-stability bucket.
|
||||
.header(header::CACHE_CONTROL, "private, max-age=3600")
|
||||
// Defense-in-depth against any active content slipping past the
|
||||
// upload-time allowlist: never sniff, never script.
|
||||
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||
.header(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||
)
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| AppError::Internal(e.into()))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod host;
|
||||
pub mod me;
|
||||
pub mod media;
|
||||
pub mod social;
|
||||
pub mod sse;
|
||||
pub mod test_admin;
|
||||
|
||||
@@ -284,6 +284,12 @@ pub async fn upload(
|
||||
uploader_name: user.display_name,
|
||||
preview_url: None,
|
||||
thumbnail_url: None,
|
||||
original_url: Some(crate::services::media_token::signed_url(
|
||||
&state.config.jwt_secret,
|
||||
"original",
|
||||
upload.id,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)),
|
||||
mime_type: canonical_mime,
|
||||
caption,
|
||||
hashtags: tags,
|
||||
@@ -349,7 +355,15 @@ pub async fn delete_upload(
|
||||
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
|
||||
}
|
||||
|
||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
||||
if let Some(paths) = Upload::soft_delete(&state.pool, upload_id).await? {
|
||||
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
|
||||
}
|
||||
|
||||
// Tell other clients to drop the photo immediately (mirrors host delete).
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"upload-deleted",
|
||||
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -417,56 +431,9 @@ 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`)
|
||||
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
||||
/// Data Mode = Original
|
||||
///
|
||||
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
|
||||
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
|
||||
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
|
||||
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
|
||||
/// and `window.open`, defeating the purpose of having the alias.
|
||||
pub async fn get_original(
|
||||
State(state): State<AppState>,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
let absolute = state.config.media_path.join(&upload.original_path);
|
||||
if !absolute.exists() {
|
||||
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||
}
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Response, StatusCode};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let file = tokio::fs::File::open(&absolute)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
let filename = absolute
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("original");
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, upload.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, disposition)
|
||||
.header(header::CONTENT_LENGTH, metadata.len())
|
||||
.body(Body::from_stream(stream))
|
||||
.map_err(|e| AppError::Internal(e.into()))
|
||||
}
|
||||
// The original-file download is now served by the authenticated, signed media
|
||||
// gateway (`handlers::media::serve`). The old unauthenticated `get_original`
|
||||
// alias was the H2 vulnerability and has been removed.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -2,7 +2,6 @@ use anyhow::Result;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -46,6 +45,7 @@ async fn main() -> Result<()> {
|
||||
pool,
|
||||
state.rate_limiter.clone(),
|
||||
state.sse_tickets.clone(),
|
||||
config.media_path.clone(),
|
||||
);
|
||||
|
||||
// Ensure media directories exist
|
||||
@@ -64,10 +64,6 @@ async fn main() -> Result<()> {
|
||||
"/api/v1/upload/{id}",
|
||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/upload/{id}/original",
|
||||
get(handlers::upload::get_original),
|
||||
)
|
||||
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||
@@ -129,13 +125,14 @@ async fn main() -> Result<()> {
|
||||
api
|
||||
};
|
||||
|
||||
// Serve media files from disk
|
||||
let media_service = ServeDir::new(&config.media_path);
|
||||
|
||||
// Media is served exclusively through the authenticated, signed gateway —
|
||||
// there is no raw static mount. The gateway verifies an HMAC signature and
|
||||
// consults the DB (deleted / ban-hidden / type), so private artifacts and
|
||||
// the export archives are never reachable by guessing a path.
|
||||
let router = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
.nest_service("/media", media_service)
|
||||
.route("/media/{kind}/{id}", get(handlers::media::serve))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ pub struct Upload {
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// On-disk artifact paths returned by the soft-delete methods so the caller can
|
||||
/// unlink the files after the DB commit.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct DeletedPaths {
|
||||
pub original: String,
|
||||
pub preview: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadDto {
|
||||
pub id: Uuid,
|
||||
@@ -26,6 +35,8 @@ pub struct UploadDto {
|
||||
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 hashtags: Vec<String>,
|
||||
@@ -125,18 +136,21 @@ impl Upload {
|
||||
///
|
||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
||||
/// delete action double-decrementing the counter.
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
///
|
||||
/// Returns the artifact paths of the row that was deleted (`None` if nothing
|
||||
/// matched) so the caller can unlink the files after the commit.
|
||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes",
|
||||
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some((user_id, bytes)) = row {
|
||||
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
@@ -146,31 +160,34 @@ impl Upload {
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(DeletedPaths { original, preview, thumbnail })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
|
||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||
/// can return a clean 404 instead of silently no-op'ing.
|
||||
/// can return a clean 404, and the artifact paths otherwise.
|
||||
pub async fn soft_delete_in_event(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
event_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"UPDATE upload
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
|
||||
RETURNING user_id, original_size_bytes",
|
||||
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(event_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let deleted = if let Some((user_id, bytes)) = row {
|
||||
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\"
|
||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||
@@ -180,12 +197,12 @@ impl Upload {
|
||||
.bind(bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
true
|
||||
Some(DeletedPaths { original, preview, thumbnail })
|
||||
} else {
|
||||
false
|
||||
None
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(deleted)
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub async fn update_caption(
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
||||
//! accumulate).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::PgPool;
|
||||
@@ -76,6 +77,7 @@ pub fn spawn_periodic_tasks(
|
||||
pool: PgPool,
|
||||
rate_limiter: RateLimiter,
|
||||
sse_tickets: SseTicketStore,
|
||||
media_path: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||
@@ -86,6 +88,9 @@ pub fn spawn_periodic_tasks(
|
||||
cleanup_sessions(&pool).await;
|
||||
rate_limiter.prune();
|
||||
sse_tickets.prune();
|
||||
// Reclaim disk for uploads soft-deleted more than the grace period
|
||||
// ago, and hard-delete those rows (FKs cascade).
|
||||
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
73
backend/src/services/media_fs.rs
Normal file
73
backend/src/services/media_fs.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Filesystem lifecycle for media artifacts.
|
||||
//!
|
||||
//! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on
|
||||
//! a fixed-size disk until something removes them. This module is that
|
||||
//! something: best-effort unlinking on delete, plus a periodic reaper that
|
||||
//! sweeps files belonging to soft-deleted rows (catching anything an in-process
|
||||
//! unlink missed — e.g. a crash between the DB commit and the unlink).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::models::upload::DeletedPaths;
|
||||
|
||||
/// Best-effort removal of an upload's three on-disk artifacts. A missing file is
|
||||
/// not an error (it may already be gone, or never existed for videos without a
|
||||
/// preview); anything else is logged but never propagated — losing the bytes
|
||||
/// must not fail the user's delete.
|
||||
pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) {
|
||||
let candidates = [
|
||||
Some(&paths.original),
|
||||
paths.preview.as_ref(),
|
||||
paths.thumbnail.as_ref(),
|
||||
];
|
||||
for rel in candidates.into_iter().flatten() {
|
||||
let abs = media_path.join(rel);
|
||||
match tokio::fs::remove_file(&abs).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaper: remove on-disk files for uploads that were soft-deleted more than
|
||||
/// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly
|
||||
/// DB-row-driven — it never walks the filesystem, so it can never remove a file
|
||||
/// belonging to a live upload.
|
||||
pub async fn reap_deleted(pool: &PgPool, media_path: &Path) {
|
||||
let rows: Vec<DeletedPaths> = match sqlx::query_as::<_, DeletedPaths>(
|
||||
"SELECT original_path AS original, preview_path AS preview, thumbnail_path AS thumbnail
|
||||
FROM upload
|
||||
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "media reaper: query failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for paths in &rows {
|
||||
unlink_media(media_path, paths).await;
|
||||
}
|
||||
|
||||
match sqlx::query(
|
||||
"DELETE FROM upload
|
||||
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()),
|
||||
Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"),
|
||||
}
|
||||
}
|
||||
122
backend/src/services/media_token.rs
Normal file
122
backend/src/services/media_token.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Stateless signed URLs for the authenticated media gateway.
|
||||
//!
|
||||
//! Media (`<img>`/`<video>` sources) cannot carry an `Authorization` header, so
|
||||
//! access is granted by an HMAC-SHA256 signature embedded in the URL. The
|
||||
//! feed/upload DTOs are serialized for an already-authenticated event member, so
|
||||
//! that is where fresh signatures are minted; the gateway handler verifies them
|
||||
//! without any DB/session state.
|
||||
//!
|
||||
//! HMAC is implemented over the in-tree `sha2` crate (no new dependency). The
|
||||
//! key is the app's `jwt_secret`.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Validity window of a signed URL.
|
||||
const TTL_SECS: i64 = 24 * 3600;
|
||||
/// Issue-time bucket. Expiry (and therefore the URL) is stable within this
|
||||
/// window so the browser caches image bytes across feed polls instead of
|
||||
/// re-downloading on every refresh.
|
||||
const BUCKET_SECS: i64 = 3600;
|
||||
|
||||
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
|
||||
const BLOCK: usize = 64;
|
||||
let mut key_block = [0u8; BLOCK];
|
||||
if key.len() > BLOCK {
|
||||
let digest = Sha256::digest(key);
|
||||
key_block[..32].copy_from_slice(&digest);
|
||||
} else {
|
||||
key_block[..key.len()].copy_from_slice(key);
|
||||
}
|
||||
|
||||
let mut ipad = [0x36u8; BLOCK];
|
||||
let mut opad = [0x5cu8; BLOCK];
|
||||
for i in 0..BLOCK {
|
||||
ipad[i] ^= key_block[i];
|
||||
opad[i] ^= key_block[i];
|
||||
}
|
||||
|
||||
let mut inner = Sha256::new();
|
||||
inner.update(ipad);
|
||||
inner.update(msg);
|
||||
let inner_digest = inner.finalize();
|
||||
|
||||
let mut outer = Sha256::new();
|
||||
outer.update(opad);
|
||||
outer.update(inner_digest);
|
||||
outer.finalize().into()
|
||||
}
|
||||
|
||||
fn sign(secret: &str, kind: &str, id: Uuid, exp: i64) -> String {
|
||||
let msg = format!("{kind}:{id}:{exp}");
|
||||
let mac = hmac_sha256(secret.as_bytes(), msg.as_bytes());
|
||||
let mut hex = String::with_capacity(64);
|
||||
for b in mac {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// Build a signed, time-boxed gateway URL for one of an upload's artifacts.
|
||||
/// `kind` is `original` | `preview` | `thumbnail`.
|
||||
pub fn signed_url(secret: &str, kind: &str, id: Uuid, now: i64) -> String {
|
||||
let exp = ((now / BUCKET_SECS) * BUCKET_SECS) + TTL_SECS;
|
||||
let sig = sign(secret, kind, id, exp);
|
||||
format!("/media/{kind}/{id}?exp={exp}&sig={sig}")
|
||||
}
|
||||
|
||||
/// Verify a signed URL: signature must match and the expiry must not have
|
||||
/// passed. Signature comparison is constant-time.
|
||||
pub fn verify(secret: &str, kind: &str, id: Uuid, exp: i64, sig: &str, now: i64) -> bool {
|
||||
if exp < now {
|
||||
return false;
|
||||
}
|
||||
let expected = sign(secret, kind, id, exp);
|
||||
constant_time_eq(expected.as_bytes(), sig.as_bytes())
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_verify_roundtrip() {
|
||||
let secret = "test_secret_at_least_32_chars_long_xxxx";
|
||||
let id = Uuid::new_v4();
|
||||
let now = 1_700_000_000;
|
||||
let url = signed_url(secret, "original", id, now);
|
||||
// Extract exp + sig from the query string.
|
||||
let (_, query) = url.split_once('?').unwrap();
|
||||
let mut exp = 0i64;
|
||||
let mut sig = String::new();
|
||||
for pair in query.split('&') {
|
||||
let (k, v) = pair.split_once('=').unwrap();
|
||||
match k {
|
||||
"exp" => exp = v.parse().unwrap(),
|
||||
"sig" => sig = v.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(verify(secret, "original", id, exp, &sig, now));
|
||||
// Tampered kind / id / sig must fail.
|
||||
assert!(!verify(secret, "preview", id, exp, &sig, now));
|
||||
assert!(!verify(secret, "original", Uuid::new_v4(), exp, &sig, now));
|
||||
assert!(!verify(secret, "original", id, exp, "deadbeef", now));
|
||||
// Wrong secret must fail.
|
||||
assert!(!verify("other_secret_at_least_32_chars_long_yy", "original", id, exp, &sig, now));
|
||||
// Expired must fail.
|
||||
assert!(!verify(secret, "original", id, exp, &sig, exp + 1));
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,7 @@ pub mod config;
|
||||
pub mod export;
|
||||
pub mod jobs;
|
||||
pub mod maintenance;
|
||||
pub mod media_fs;
|
||||
pub mod media_token;
|
||||
pub mod rate_limiter;
|
||||
pub mod sse_tickets;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
function tileUrl(upload: FeedUpload): string {
|
||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
||||
if (upload.preview_url) return upload.preview_url;
|
||||
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
||||
return $dataMode === 'original' ? (upload.original_url ?? '') : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
|
||||
// the full file via the auth-gated `/api/v1/upload/{id}/original` endpoint.
|
||||
// the full file via the signed `original_url` from the media gateway.
|
||||
//
|
||||
// Stored per-device in localStorage (not per-user) because data plans are a property
|
||||
// of the device the guest is currently holding, not their identity.
|
||||
@@ -40,17 +40,24 @@ if (browser) {
|
||||
* Build the URL for a feed upload given the current data mode and the URL variants
|
||||
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
|
||||
* follows the same fallback rule:
|
||||
* Original mode → original API route. Falls back to preview if no upload id is
|
||||
* available (defensive — shouldn't happen in practice).
|
||||
* Original mode → signed original URL, falling back to preview/thumbnail.
|
||||
* Saver mode → preview URL (compressed), falling back to thumbnail and then
|
||||
* original.
|
||||
* the signed original.
|
||||
*
|
||||
* All URLs are minted (and signed) by the backend; the client never constructs
|
||||
* a media path itself.
|
||||
*/
|
||||
export function pickMediaUrl(
|
||||
mode: DataMode,
|
||||
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
|
||||
upload: {
|
||||
id: string;
|
||||
preview_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
original_url: string | null;
|
||||
}
|
||||
): string {
|
||||
if (mode === 'original') {
|
||||
return `/api/v1/upload/${upload.id}/original`;
|
||||
return upload.original_url ?? upload.preview_url ?? upload.thumbnail_url ?? '';
|
||||
}
|
||||
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
|
||||
return upload.preview_url ?? upload.thumbnail_url ?? upload.original_url ?? '';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface FeedUpload {
|
||||
uploader_name: string;
|
||||
preview_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
original_url: string | null;
|
||||
mime_type: string;
|
||||
caption: string | null;
|
||||
like_count: number;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
label: 'Original anzeigen',
|
||||
icon: '⤓',
|
||||
onClick: () => {
|
||||
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
|
||||
if (target.original_url) window.open(target.original_url, '_blank');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user