//! Filesystem lifecycle for media artifacts. //! //! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on //! a fixed-size disk until something removes them. This module is that //! something: best-effort unlinking on delete, plus a periodic reaper that //! sweeps files belonging to soft-deleted rows (catching anything an in-process //! unlink missed — e.g. a crash between the DB commit and the unlink). use std::path::Path; use sqlx::PgPool; use crate::models::upload::DeletedPaths; /// Best-effort removal of an upload's three on-disk artifacts. A missing file is /// not an error (it may already be gone, or never existed for videos without a /// preview); anything else is logged but never propagated — losing the bytes /// must not fail the user's delete. pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) { let candidates = [ Some(&paths.original), paths.preview.as_ref(), paths.thumbnail.as_ref(), ]; for rel in candidates.into_iter().flatten() { let abs = media_path.join(rel); match tokio::fs::remove_file(&abs).await { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"), } } } /// Reaper: remove on-disk files for uploads that were soft-deleted more than /// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly /// DB-row-driven — it never walks the filesystem, so it can never remove a file /// belonging to a live upload. pub async fn reap_deleted(pool: &PgPool, media_path: &Path) { // Snapshot the exact rows (id + paths) we are about to unlink, and delete by // those ids — NOT by re-evaluating the `deleted_at < now - 1 day` predicate. // A row that crosses the 1-day line *during* the unlink loop would otherwise // be hard-deleted by the second predicate without ever having its files // unlinked → a permanent orphan. let rows: Vec<(uuid::Uuid, String, Option, Option)> = match sqlx::query_as( "SELECT id, original_path, preview_path, thumbnail_path FROM upload WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'", ) .fetch_all(pool) .await { Ok(rows) => rows, Err(e) => { tracing::warn!(error = ?e, "media reaper: query failed"); return; } }; if rows.is_empty() { return; } let mut ids = Vec::with_capacity(rows.len()); for (id, original, preview, thumbnail) in &rows { unlink_media(media_path, &DeletedPaths { original: original.clone(), preview: preview.clone(), thumbnail: thumbnail.clone(), }) .await; ids.push(*id); } match sqlx::query("DELETE FROM upload WHERE id = ANY($1)") .bind(&ids) .execute(pool) .await { Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()), Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"), } }