All 40 backend tests were pure-function tests — not one line of SQL ran under `cargo test`,
even though the riskiest code in the repo is SQL. Add 12 DB-backed `#[sqlx::test]` tests
(fresh throwaway DB per test, real migrations) pinning the invariants that code is
load-bearing for, each mutation-verified to fail on broken code:
export_epoch.rs (8): epoch is post-increment on release (a worker born pre-increment is
inert); epoch strictly monotonic across reopen; a retired-epoch worker's writes are
no-ops; export_current is EXACTLY the invariant (8-case table); the ViewerOnly
carry-forward carries a done ZIP forward AND matches nothing when the ZIP is unfinished
(the af997a8 strand bug); boot recovery doesn't clobber a live ZIP.
upload_concurrency.rs (4): the atomic quota UPDATE stops two stale-snapshot uploads from
both committing (sequential AND concurrent-tx via EPQ); the FOR SHARE upload lock
serializes against release, blocking a photo from committing after the export snapshot.
Deleting FOR SHARE, or the carry-forward's `status='done'`, each makes a test fail.
Test isolation: TRUNCATE now also resets disk_cache and sse_tickets. The stale disk
reading was harmless only while quotas were globally off in e2e (they no longer are — see
the new quota spec), i.e. two holes were masking each other.
clippy: `cargo clippy --all-targets -- -D warnings` now passes (it did not). Deleted the
dead jobs.rs (an unused BackgroundJob sketch) and unused model methods; `#[allow(dead_code)]`
+ comment on the sqlx FromRow field-sets that ARE populated by the DB. No SQL or logic
changed. `cargo test` requires DATABASE_URL by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
8.3 KiB
Rust
253 lines
8.3 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 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.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_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(())
|
|
}
|
|
}
|