Files
EventSnap/backend/src/models/upload.rs
fabi 117c0c547f chore(backend): satisfy cargo fmt
`checks.yml` runs `cargo fmt --check`, and it has been failing since the round-1
audit fixes: I gated those on `cargo build` and `cargo clippy` but never ran fmt,
so three files drifted then and eight more this round. Pure formatting — no
behaviour change; clippy stays at zero and all 70 backend tests still pass.

Worth noting for next time: clippy passing is not evidence fmt does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:26:28 +02:00

278 lines
9.1 KiB
Rust

use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `upload`: every field is populated by sqlx from `RETURNING *` in `create`. Callers
// mostly use `id` and hand the rest to the compression/feed queries, so most fields are never read
// through this struct — they stay here so it keeps mirroring the table.
#[allow(dead_code)]
#[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>>,
}
#[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>,
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>,
}
/// Minimal projection of an upload's on-disk file paths, used by the visibility-gated
/// media aliases so they don't hydrate the entire `Upload` row per request.
#[derive(Debug, sqlx::FromRow)]
pub struct VisibleMedia {
pub original_path: String,
pub preview_path: Option<String>,
pub thumbnail_path: Option<String>,
pub display_path: Option<String>,
pub mime_type: String,
}
impl Upload {
/// Takes any executor so the caller can run it inside a transaction (atomic
/// quota + insert) or standalone against the pool.
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
}
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
/// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
/// from the feed also stops its original/preview/thumbnail from being pulled by UUID.
///
/// Selects four columns instead of the whole `Upload` row: this runs once per image
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
/// never uses.
pub async fn find_visible_media(
pool: &PgPool,
id: Uuid,
) -> Result<Option<VisibleMedia>, sqlx::Error> {
sqlx::query_as::<_, VisibleMedia>(
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.display_path, up.mime_type
FROM upload up
JOIN \"user\" u ON u.id = up.user_id
WHERE up.id = $1 AND up.deleted_at IS NULL
AND u.uploads_hidden = false AND u.is_banned = false",
)
.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_display_path(
pool: &PgPool,
id: Uuid,
display_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET display_path = $2 WHERE id = $1")
.bind(id)
.bind(display_path)
.execute(pool)
.await?;
Ok(())
}
/// Stamp which revision of the derivative pipeline produced this row's preview/display,
/// so the startup backfill can find rows generated by an older one exactly once.
pub async fn set_derivatives_rev(pool: &PgPool, id: Uuid, rev: i16) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE upload SET derivatives_rev = $2 WHERE id = $1")
.bind(id)
.bind(rev)
.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.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
if let Some((user_id, bytes)) = 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?;
}
tx.commit().await?;
Ok(())
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` 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.
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
/// transaction. They must be atomic: if the delete commits and the regeneration doesn't (a
/// dropped handler future, a failed second tx), the taken-down photo stays in the downloadable
/// archive forever, and recovery can't tell — the keepsake still looks complete at the current
/// epoch, and the host can no longer even find the upload to retry.
pub async fn soft_delete_in_event(
conn: &mut sqlx::PgConnection,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let tx = conn;
let row: Option<(Uuid, i64)> = 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",
)
.bind(id)
.bind(event_id)
.fetch_optional(&mut *tx)
.await?;
let deleted = if let Some((user_id, bytes)) = 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?;
true
} else {
false
};
Ok(deleted)
}
pub async fn update_caption<'e, E>(
executor: E,
id: Uuid,
caption: Option<&str>,
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query("UPDATE upload SET caption = $2 WHERE id = $1")
.bind(id)
.bind(caption)
.execute(executor)
.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(())
}
}