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:
MechaCat02
2026-06-27 15:30:30 +02:00
parent ab9f1d89b2
commit 8faf702208
16 changed files with 437 additions and 119 deletions

View File

@@ -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(