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

@@ -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;
}
});
}