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>
234 lines
7.3 KiB
Rust
234 lines
7.3 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::Serialize;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
pub struct Upload {
|
|
pub id: Uuid,
|
|
pub event_id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub original_path: String,
|
|
pub preview_path: Option<String>,
|
|
pub thumbnail_path: Option<String>,
|
|
pub mime_type: String,
|
|
pub original_size_bytes: i64,
|
|
pub caption: Option<String>,
|
|
pub compression_status: String,
|
|
pub created_at: DateTime<Utc>,
|
|
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,
|
|
pub user_id: Uuid,
|
|
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>,
|
|
pub like_count: i64,
|
|
pub comment_count: i64,
|
|
pub liked_by_me: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl Upload {
|
|
/// Generic over the executor so it can run inside the upload transaction
|
|
/// (`&mut *tx`) alongside the quota-counter reservation, keeping the
|
|
/// byte-counter and the row atomic (no quota leak on a mid-write crash).
|
|
pub async fn create<'e, E>(
|
|
executor: E,
|
|
event_id: Uuid,
|
|
user_id: Uuid,
|
|
original_path: &str,
|
|
mime_type: &str,
|
|
original_size_bytes: i64,
|
|
caption: Option<&str>,
|
|
) -> Result<Self, sqlx::Error>
|
|
where
|
|
E: sqlx::PgExecutor<'e>,
|
|
{
|
|
sqlx::query_as::<_, Self>(
|
|
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *",
|
|
)
|
|
.bind(event_id)
|
|
.bind(user_id)
|
|
.bind(original_path)
|
|
.bind(mime_type)
|
|
.bind(original_size_bytes)
|
|
.bind(caption)
|
|
.fetch_one(executor)
|
|
.await
|
|
}
|
|
|
|
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
|
sqlx::query_as::<_, Self>(
|
|
"SELECT * FROM upload WHERE id = $1 AND deleted_at IS NULL",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
}
|
|
|
|
/// Event-scoped lookup used by host endpoints so a host of event A cannot
|
|
/// reach uploads belonging to event B.
|
|
pub async fn find_by_id_and_event(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
event_id: Uuid,
|
|
) -> Result<Option<Self>, sqlx::Error> {
|
|
sqlx::query_as::<_, Self>(
|
|
"SELECT * FROM upload
|
|
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(id)
|
|
.bind(event_id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn set_preview_path(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
preview_path: &str,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query("UPDATE upload SET preview_path = $2 WHERE id = $1")
|
|
.bind(id)
|
|
.bind(preview_path)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_thumbnail_path(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
thumbnail_path: &str,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query("UPDATE upload SET thumbnail_path = $2 WHERE id = $1")
|
|
.bind(id)
|
|
.bind(thumbnail_path)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
|
|
/// Done in a single transaction so a crash between the two writes can't leave
|
|
/// the quota counter pointing at bytes the user has already deleted (which would
|
|
/// silently lock them out of future uploads).
|
|
///
|
|
/// No-op if the row is already deleted — protects against a double-tap on the
|
|
/// delete action double-decrementing the counter.
|
|
///
|
|
/// 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, 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, original_path, preview_path, thumbnail_path",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
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)
|
|
WHERE id = $1",
|
|
)
|
|
.bind(user_id)
|
|
.bind(bytes)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
Some(DeletedPaths { original, preview, thumbnail })
|
|
} else {
|
|
None
|
|
};
|
|
tx.commit().await?;
|
|
Ok(paths)
|
|
}
|
|
|
|
/// 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, and the artifact paths otherwise.
|
|
pub async fn soft_delete_in_event(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
event_id: Uuid,
|
|
) -> Result<Option<DeletedPaths>, sqlx::Error> {
|
|
let mut tx = pool.begin().await?;
|
|
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, original_path, preview_path, thumbnail_path",
|
|
)
|
|
.bind(id)
|
|
.bind(event_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
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)
|
|
WHERE id = $1",
|
|
)
|
|
.bind(user_id)
|
|
.bind(bytes)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
Some(DeletedPaths { original, preview, thumbnail })
|
|
} else {
|
|
None
|
|
};
|
|
tx.commit().await?;
|
|
Ok(paths)
|
|
}
|
|
|
|
pub async fn update_caption(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
caption: Option<&str>,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query("UPDATE upload SET caption = $2 WHERE id = $1")
|
|
.bind(id)
|
|
.bind(caption)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_compression_status(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
status: &str,
|
|
) -> Result<(), sqlx::Error> {
|
|
sqlx::query("UPDATE upload SET compression_status = $2 WHERE id = $1")
|
|
.bind(id)
|
|
.bind(status)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|