Files
EventSnap/backend/src/services/compression.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

225 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use sqlx::PgPool;
use tokio::sync::{broadcast, Semaphore};
use uuid::Uuid;
use crate::models::upload::Upload;
use crate::state::SseEvent;
#[derive(Clone)]
pub struct CompressionWorker {
/// Separate permit pools for images and videos so a couple of slow/large
/// videos (each holding a permit across the full ffmpeg wait) can never
/// starve image-preview generation, and vice versa.
image_sem: Arc<Semaphore>,
video_sem: Arc<Semaphore>,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
}
impl CompressionWorker {
pub fn new(
pool: PgPool,
media_path: PathBuf,
image_concurrency: usize,
video_concurrency: usize,
sse_tx: broadcast::Sender<SseEvent>,
) -> Self {
Self {
image_sem: Arc::new(Semaphore::new(image_concurrency)),
video_sem: Arc::new(Semaphore::new(video_concurrency)),
pool,
media_path,
sse_tx,
}
}
/// Spawn a background task to process an uploaded file.
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone();
tokio::spawn(async move {
let is_video = mime_type.starts_with("video/");
let sem = if is_video { &worker.video_sem } else { &worker.image_sem };
let _permit = sem.acquire().await;
match worker.do_process(upload_id, &original_path, &mime_type).await {
Ok(_) => {
tracing::info!("compression completed for upload {upload_id}");
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-processed".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
}
Err(e) => {
// Log the detailed error (incl. paths) server-side only; the
// SSE channel is broadcast to every client, so send a generic
// message — never leak absolute filesystem paths.
tracing::error!("compression failed for upload {upload_id}: {e:#}");
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-error".to_string(),
data: serde_json::json!({
"upload_id": upload_id,
"error": "Verarbeitung fehlgeschlagen."
}).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
}
}
});
}
async fn do_process(
&self,
upload_id: Uuid,
original_path: &str,
mime_type: &str,
) -> Result<()> {
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
let original = self.media_path.join(original_path);
if mime_type.starts_with("image/") {
let preview_rel = self.generate_image_preview(upload_id, &original, mime_type).await?;
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
tracing::info!("preview generated for upload {upload_id}");
} else if mime_type.starts_with("video/") {
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?;
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
tracing::info!("thumbnail generated for upload {upload_id}");
}
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
Ok(())
}
async fn generate_image_preview(
&self,
upload_id: Uuid,
original: &Path,
mime_type: &str,
) -> Result<String> {
let previews_dir = self.media_path.join("previews");
tokio::fs::create_dir_all(&previews_dir).await?;
let preview_filename = format!("{upload_id}.jpg");
let preview_path = previews_dir.join(&preview_filename);
let original = original.to_path_buf();
let preview_path_clone = preview_path.clone();
let mime_owned = mime_type.to_string();
// Run blocking image operations in a spawn_blocking task, bounded by a
// hard timeout (mirrors the ffmpeg guard) so a pathological decode can't
// hold the permit forever.
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
use image::ImageReader;
// Reject decompression bombs *before* fully decoding: a small file can
// otherwise expand to enormous dimensions and a very expensive resize.
// 12000×12000 covers any real phone photo; max_alloc caps memory.
let mut reader = ImageReader::open(&original)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
let img = reader.decode().context("failed to decode image")?;
// Resize to max 800px wide, preserving aspect ratio
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
preview.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
.context("failed to save preview")?;
// If the original is PNG, try lossless compression in-place
if mime_owned == "image/png" {
let opts = oxipng::Options::from_preset(2);
let _ = oxipng::optimize(
&oxipng::InFile::Path(original),
&oxipng::OutFile::Path {
path: None,
preserve_attrs: true,
},
&opts,
);
}
Ok(())
});
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
Ok(join) => join.context("image task panicked")??,
Err(_) => anyhow::bail!("image processing timeout after 120s"),
}
Ok(format!("previews/{preview_filename}"))
}
async fn generate_video_thumbnail(
&self,
upload_id: Uuid,
original: &Path,
) -> Result<String> {
let thumbs_dir = self.media_path.join("thumbnails");
tokio::fs::create_dir_all(&thumbs_dir).await?;
let thumb_filename = format!("{upload_id}.jpg");
let thumb_path = thumbs_dir.join(&thumb_filename);
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a
// cap, the held compression-worker semaphore permit is never released and the
// pool eventually deadlocks (no further uploads ever processed). 120s is well
// above the time to extract one frame from any sane input.
let mut child = tokio::process::Command::new("ffmpeg")
.args([
"-i",
original.to_str().unwrap_or_default(),
"-vframes",
"1",
"-ss",
"00:00:01",
"-vf",
"scale=800:-1",
"-y",
thumb_path.to_str().unwrap_or_default(),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.context("failed to spawn ffmpeg")?;
let status = match tokio::time::timeout(
std::time::Duration::from_secs(120),
child.wait(),
)
.await
{
Ok(res) => res.context("ffmpeg wait failed")?,
Err(_) => {
let _ = child.kill().await;
anyhow::bail!("ffmpeg timeout after 120s");
}
};
if !status.success() {
// Best-effort: drain stderr for the log.
let mut stderr = Vec::new();
if let Some(mut handle) = child.stderr.take() {
use tokio::io::AsyncReadExt;
let _ = handle.read_to_end(&mut stderr).await;
}
anyhow::bail!(
"ffmpeg failed: {}",
String::from_utf8_lossy(&stderr)
);
}
Ok(format!("thumbnails/{thumb_filename}"))
}
}