Files
EventSnap/backend/src/services/media_fs.rs
MechaCat02 2d7169e971 harden: low-severity bucket — unspoofable IP, admin floor, log/SSE hygiene
The cheap, real LOWs from the review (bucket 🅰); 🅱 items and won't-fix
decisions are recorded in docs/SECURITY-BACKLOG.md.

- XFF spoofing (highest-value): client_ip now prefers an unspoofable X-Real-IP
  (Caddy `header_up X-Real-IP {remote_host}`) and otherwise takes the *rightmost*
  X-Forwarded-For token, never the client-controlled leftmost. The join cap,
  recover throttle, and admin-login floor all keyed on this. Unit-tested.
  Caddyfile also drops the dead /media/previews|originals cache matchers (the
  gateway sets its own Cache-Control) and the false "only host can download"
  comment.
- admin_login: add a hard, non-disableable rate floor (30/5min/IP) so the DB
  rate-limit master toggle can't remove brute-force protection.
- SSE: the broadcast upload-error payload no longer includes the raw error
  (absolute filesystem paths) — generic message to clients, details logged
  server-side only.
- Access logs: the request span logs the path only, never the query string, so
  the replayable media `?sig=` capability isn't written to logs.
- Reaper TOCTOU: reap_deleted now deletes rows by the ids it actually unlinked
  (DELETE … WHERE id = ANY) instead of re-evaluating the time predicate, so a
  row crossing the grace line mid-sweep can't be row-deleted without its files
  unlinked.
- Dockerfile: run the backend as a non-root user; create /media owned by `app`
  so a fresh volume is writable.
- a11y: aria-labels on the upload caption and feed search inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:01:41 +02:00

84 lines
3.1 KiB
Rust

//! 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<String>, Option<String>)> = 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"),
}
}