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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user