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>
84 lines
3.1 KiB
Rust
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"),
|
|
}
|
|
}
|