From 97fe186fa8a446cc29ef4bc98da37a4f90744447 Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 11 Mar 2026 11:24:29 -0700 Subject: [PATCH 1/8] gitignore --- .gitignore | 1 + docs/clippy.sh | 55 -------------------------------------------------- 2 files changed, 1 insertion(+), 55 deletions(-) delete mode 100755 docs/clippy.sh diff --git a/.gitignore b/.gitignore index ca934f1..6b244bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store dev-check.sh +docs/clippy.sh diff --git a/docs/clippy.sh b/docs/clippy.sh deleted file mode 100755 index cc492c3..0000000 --- a/docs/clippy.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -set -o pipefail - -OUTPUT_DIR="clippy_reports" -RAW_FILE="$OUTPUT_DIR/clippy_raw.txt" -CLUSTER_DIR="$OUTPUT_DIR/clusters" - -mkdir -p "$OUTPUT_DIR" -mkdir -p "$CLUSTER_DIR" - -echo "Running cargo clippy..." - -cargo clippy --all-targets --all-features -- \ --D warnings \ --W clippy::pedantic \ --W clippy::nursery \ -2>&1 | tee "$RAW_FILE" - -echo "Parsing Clippy output..." - -while IFS= read -r line -do - # Extract file path patterns like src/db/file.rs:12:5 - if [[ $line =~ ([a-zA-Z0-9_\/.-]+\.rs):[0-9]+:[0-9]+ ]]; then - - FILE="${BASH_REMATCH[1]}" - - # Determine folder cluster - DIR=$(dirname "$FILE") - - # Normalize root files into their own cluster - if [[ "$DIR" == "." ]]; then - CLUSTER="root" - else - CLUSTER=$(echo "$DIR" | tr '/' '_') - fi - - OUTFILE="$CLUSTER_DIR/${CLUSTER}.txt" - - echo "" >> "$OUTFILE" - echo "----------------------------------------" >> "$OUTFILE" - echo "FILE: $FILE" >> "$OUTFILE" - echo "----------------------------------------" >> "$OUTFILE" - fi - - # Append the line to the current cluster if defined - if [[ -n "$OUTFILE" ]]; then - echo "$line" >> "$OUTFILE" - fi - -done < "$RAW_FILE" - -echo "Cluster reports generated in:" -echo "$CLUSTER_DIR" \ No newline at end of file From e9e3fbe8fbb57ca2d34b180c688c158ee11c9189 Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 11 Mar 2026 11:53:38 -0700 Subject: [PATCH 2/8] Full clippy compatability even in very rare cases --- src/config.rs | 18 ++++++++++++------ src/db/admin.rs | 5 +++-- src/db/mod.rs | 2 +- src/db/posts.rs | 2 +- src/db/threads.rs | 6 +++--- src/detect.rs | 4 ++++ src/handlers/admin.rs | 18 +++++++++++++++++- src/handlers/board.rs | 5 +++-- src/handlers/mod.rs | 3 +++ src/handlers/thread.rs | 2 ++ src/main.rs | 7 +++++++ src/middleware/mod.rs | 1 + src/models.rs | 9 +++++++-- src/templates/mod.rs | 6 +++--- src/templates/thread.rs | 4 ++-- src/utils/crypto.rs | 5 ++++- src/utils/files.rs | 4 +++- src/utils/sanitize.rs | 13 +++++++++++++ src/utils/tripcode.rs | 4 ++++ src/workers/mod.rs | 1 + 20 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/config.rs b/src/config.rs index 9e5b716..5fe03d8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -340,9 +340,15 @@ impl Config { initial_site_subtitle, initial_default_theme, port, - max_image_size: (max_image_mb as usize) * 1024 * 1024, - max_video_size: (max_video_mb as usize) * 1024 * 1024, - max_audio_size: (max_audio_mb as usize) * 1024 * 1024, + max_image_size: (max_image_mb as usize) + .saturating_mul(1024) + .saturating_mul(1024), + max_video_size: (max_video_mb as usize) + .saturating_mul(1024) + .saturating_mul(1024), + max_audio_size: (max_audio_mb as usize) + .saturating_mul(1024) + .saturating_mul(1024), enable_tor_support: env_bool("CHAN_TOR_SUPPORT", s.enable_tor_support.unwrap_or(true)), require_ffmpeg: env_bool("CHAN_REQUIRE_FFMPEG", s.require_ffmpeg.unwrap_or(false)), @@ -376,7 +382,7 @@ impl Config { "CHAN_DB_WARN_THRESHOLD_MB", s.db_warn_threshold_mb.unwrap_or(2048), ); - mb * 1024 * 1024 + mb.saturating_mul(1024).saturating_mul(1024) }, job_queue_capacity: env_parse( "CHAN_JOB_QUEUE_CAPACITY", @@ -395,7 +401,7 @@ impl Config { "CHAN_WAVEFORM_CACHE_MAX_MB", s.waveform_cache_max_mb.unwrap_or(200), ); - mb * 1024 * 1024 + mb.saturating_mul(1024).saturating_mul(1024) }, blocking_threads: { let cpus = std::thread::available_parallelism() @@ -404,7 +410,7 @@ impl Config { let configured = env_parse("CHAN_BLOCKING_THREADS", s.blocking_threads.unwrap_or(0)); if configured == 0 { - cpus * 4 + cpus.saturating_mul(4) } else { configured } diff --git a/src/db/admin.rs b/src/db/admin.rs index 503964c..62f83ed 100644 --- a/src/db/admin.rs +++ b/src/db/admin.rs @@ -450,6 +450,7 @@ pub fn open_report_count(conn: &rusqlite::Connection) -> Result { /// /// # Errors /// Returns an error if the database operation fails. +#[allow(clippy::too_many_arguments)] pub fn log_mod_action( conn: &rusqlite::Connection, admin_id: i64, @@ -634,7 +635,7 @@ pub fn open_appeal_count(conn: &rusqlite::Connection) -> Result { /// # Errors /// Returns an error if the database operation fails. pub fn has_recent_appeal(conn: &rusqlite::Connection, ip_hash: &str) -> Result { - let cutoff = chrono::Utc::now().timestamp() - 86400; + let cutoff = chrono::Utc::now().timestamp().saturating_sub(86400); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM ban_appeals WHERE ip_hash=?1 AND created_at > ?2", params![ip_hash, cutoff], @@ -746,7 +747,7 @@ pub fn run_wal_checkpoint(conn: &rusqlite::Connection) -> Result<(i64, i64, i64) pub fn get_db_size_bytes(conn: &rusqlite::Connection) -> Result { let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?; let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?; - Ok(page_count * page_size) + Ok(page_count.saturating_mul(page_size)) } /// Run VACUUM on the database, rebuilding it into a minimal file. diff --git a/src/db/mod.rs b/src/db/mod.rs index 84c6de5..a806dcd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -575,7 +575,7 @@ pub fn paths_safe_to_delete(conn: &rusqlite::Connection, candidates: Vec let placeholders: String = unique .iter() .enumerate() - .map(|(i, _)| format!("(?{})", i + 1)) + .map(|(i, _)| format!("(?{})", i.saturating_add(1))) .collect::>() .join(", "); let sql = format!( diff --git a/src/db/posts.rs b/src/db/posts.rs index aeef18f..97975c1 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -406,7 +406,7 @@ pub fn edit_post( } let now = chrono::Utc::now().timestamp(); - if now - created_at > window { + if now.saturating_sub(created_at) > window { return Ok(false); } diff --git a/src/db/threads.rs b/src/db/threads.rs index 58bb718..fde1096 100644 --- a/src/db/threads.rs +++ b/src/db/threads.rs @@ -90,7 +90,7 @@ fn collect_thread_file_paths( let placeholders: String = thread_ids .iter() .enumerate() - .map(|(i, _)| format!("?{}", i + 1)) + .map(|(i, _)| format!("?{}", i.saturating_add(1))) .collect::>() .join(", "); let sql = format!( @@ -404,7 +404,7 @@ pub fn archive_old_threads(conn: &rusqlite::Connection, board_id: i64, max: i64) let placeholders: String = ids .iter() .enumerate() - .map(|(i, _)| format!("?{}", i + 1)) + .map(|(i, _)| format!("?{}", i.saturating_add(1))) .collect::>() .join(", "); let sql = format!("UPDATE threads SET archived = 1, locked = 1 WHERE id IN ({placeholders})"); @@ -477,7 +477,7 @@ pub fn prune_old_threads( let placeholders: String = ids .iter() .enumerate() - .map(|(i, _)| format!("?{}", i + 1)) + .map(|(i, _)| format!("?{}", i.saturating_add(1))) .collect::>() .join(", "); let sql = format!("DELETE FROM threads WHERE id IN ({placeholders})"); diff --git a/src/detect.rs b/src/detect.rs index 8bd2774..80ba174 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -113,6 +113,8 @@ pub fn detect_ffmpeg(require_ffmpeg: bool) -> ToolStatus { // Fix #7: function now returns ToolStatus so callers can branch on whether // Tor is actually running. #[allow(clippy::too_many_lines)] +#[allow(clippy::expect_used)] +#[allow(clippy::arithmetic_side_effects)] pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> ToolStatus { if !enable_tor_support { return ToolStatus::Missing; @@ -338,6 +340,8 @@ pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> // ─── Hostname polling ───────────────────────────────────────────────────────── +#[allow(clippy::expect_used)] +#[allow(clippy::arithmetic_side_effects)] fn poll_for_hostname( hostname_path: &Path, // Fix #4: child handle passed in so crashes mid-poll are detected promptly diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index f1b3068..d047ef2 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -89,6 +89,7 @@ fn is_login_locked(ip_key: &str) -> bool { } /// Record a failed login attempt; returns the new failure count. +#[allow(clippy::arithmetic_side_effects)] fn record_login_fail(ip_key: &str) -> u32 { let now = login_now_secs(); let mut binding = ADMIN_LOGIN_FAILS @@ -191,6 +192,7 @@ pub struct LoginForm { } #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] pub async fn admin_login( State(state): State, jar: CookieJar, @@ -897,6 +899,7 @@ pub struct AddBanForm { csrf: Option, } +#[allow(clippy::arithmetic_side_effects)] pub async fn add_ban( State(state): State, jar: CookieJar, @@ -991,6 +994,7 @@ pub struct BanDeleteForm { csrf: Option, } +#[allow(clippy::arithmetic_side_effects)] pub async fn admin_ban_and_delete( State(state): State, jar: CookieJar, @@ -1355,6 +1359,7 @@ pub async fn update_board_settings( /// MEM-FIX: The zip is built to a `NamedTempFile` on disk (not a Vec in /// RAM), so peak heap usage is O(compression-buffer) not O(zip-size). /// The response body is streamed from disk in 64 KiB chunks via `ReaderStream`. +#[allow(clippy::arithmetic_side_effects)] pub async fn admin_backup(State(state): State, jar: CookieJar) -> Result { let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); @@ -1484,6 +1489,7 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu /// Count regular files (not directories) under `dir` recursively. /// Used to initialise the progress bar's `files_total` before compression starts. +#[allow(clippy::arithmetic_side_effects)] fn count_files_in_dir(dir: &std::path::Path) -> u64 { if !dir.is_dir() { return 0; @@ -1581,6 +1587,7 @@ fn add_dir_to_zip( /// • The uploaded DB is written to a temp file then opened read-only as the /// backup source; it is deleted on success or failure. #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] pub async fn admin_restore( State(state): State, jar: CookieJar, @@ -1855,6 +1862,7 @@ pub async fn admin_restore( // Rewrite in-board `>>{old_id}` references in the raw post body. // `pairs` must be pre-sorted by old-ID string length descending. +#[allow(clippy::arithmetic_side_effects)] fn remap_body_quotelinks(body: &str, pairs: &[(String, String)]) -> String { // Avoid cloning when there is nothing to change. if pairs.is_empty() { @@ -1937,6 +1945,7 @@ const ZIP_ENTRY_MAX_BYTES: u64 = 16 * 1024 * 1024 * 1024; /// Like `std::io::copy` but returns `InvalidData` if more than `max_bytes` /// would be written. Reads in 64 KiB chunks; aborts as soon as the limit /// is exceeded so disk space is not wasted. +#[allow(clippy::arithmetic_side_effects)] fn copy_limited( reader: &mut R, writer: &mut W, @@ -2065,6 +2074,7 @@ fn list_backup_files(dir: &std::path::Path) -> Vec { /// `BufWriter`, so peak RAM usage is O(compression-buffer) not O(zip-size). /// A `.tmp` suffix is used during writing; the file is renamed on success so /// the backup list never shows a partial/corrupt zip. +#[allow(clippy::arithmetic_side_effects)] pub async fn create_full_backup( State(state): State, jar: CookieJar, @@ -2442,7 +2452,7 @@ pub async fn create_board_backup( let file_count = count_files_in_dir(&board_upload_path); progress.reset(crate::middleware::backup_phase::COMPRESS); // +1 for board.json manifest - progress.files_total.store(file_count + 1, Ordering::Relaxed); + progress.files_total.store(file_count.saturating_add(1), Ordering::Relaxed); { let out_file = std::io::BufWriter::new( @@ -2691,6 +2701,7 @@ pub struct RestoreSavedForm { /// Restore a full backup from a saved file in full-backups/. #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] pub async fn restore_saved_full_backup( State(state): State, jar: CookieJar, @@ -2845,12 +2856,14 @@ pub async fn restore_saved_full_backup( /// Restore a board backup from a saved file in board-backups/. #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] pub async fn restore_saved_board_backup( State(state): State, jar: CookieJar, Form(form): Form, ) -> Result { fn encode_q(s: &str) -> String { + #[allow(clippy::arithmetic_side_effects)] const fn nibble(n: u8) -> char { match n { 0..=9 => (b'0' + n) as char, @@ -3321,6 +3334,7 @@ mod board_backup_types { /// MEM-FIX: Same approach as `admin_backup` — build zip into a `NamedTempFile` on /// disk, then stream the result in 64 KiB chunks. #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] pub async fn board_backup( State(state): State, jar: CookieJar, @@ -3569,11 +3583,13 @@ pub async fn board_backup( /// CSRF failures and multipart parse errors — redirect to the admin panel /// with a flash message instead of producing a blank crash page. #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] pub async fn board_restore( State(state): State, jar: CookieJar, mut multipart: Multipart, ) -> Response { + #[allow(clippy::arithmetic_side_effects)] const fn nibble(n: u8) -> char { match n { 0..=9 => (b'0' + n) as char, diff --git a/src/handlers/board.rs b/src/handlers/board.rs index 499fe62..e0c7f84 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -87,6 +87,7 @@ pub async fn index( // ─── GET /:board/ — board index ─────────────────────────────────────────────── +#[allow(clippy::arithmetic_side_effects)] pub async fn board_index( State(state): State, Path(board_short): Path, @@ -372,7 +373,7 @@ pub async fn create_thread( ) })?; let secs = secs.clamp(60, 30 * 24 * 3600); // clamp 1 min..30 days - let expires_at = chrono::Utc::now().timestamp() + secs; + let expires_at = chrono::Utc::now().timestamp().saturating_add(secs); db::create_poll(&conn, thread_id, &q, &valid_opts, expires_at)?; } @@ -717,7 +718,7 @@ pub async fn serve_board_media( .is_some_and(|ext| ext.eq_ignore_ascii_case("mp4")) { // MP4 was transcoded away — redirect permanently to the .webm sibling. - let webm_path_str = format!("{}.webm", &media_path[..media_path.len() - 4]); + let webm_path_str = format!("{}.webm", &media_path[..media_path.len().saturating_sub(4)]); let webm_abs = base.join(&webm_path_str); if webm_abs.exists() { Redirect::permanent(&format!("/boards/{webm_path_str}")).into_response() diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 796d09c..13f4640 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -27,6 +27,7 @@ use axum::extract::Multipart; // Text fields (CSRF token, post body, …) are routed through `field.text()` // which is bounded by axum's body length limit set in the router layer. +#[allow(clippy::arithmetic_side_effects)] async fn read_field_bytes( mut field: axum::extract::multipart::Field<'_>, max_bytes: usize, @@ -249,6 +250,8 @@ use crate::models::Board; /// /// Returns `Ok(None)` when `file_data` is `None` (no file attached). /// Must be called from inside a `spawn_blocking` closure. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::arithmetic_side_effects)] pub fn process_primary_upload( file_data: Option<(Vec, String)>, board: &Board, diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 6f44dd8..a9b7004 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -350,6 +350,7 @@ pub struct EditQuery { pub token: Option, } +#[allow(clippy::arithmetic_side_effects)] pub async fn edit_post_get( State(state): State, Path((board_short, post_id)): Path<(String, i64)>, @@ -428,6 +429,7 @@ enum EditOutcome { ErrorPage(String), } +#[allow(clippy::arithmetic_side_effects)] pub async fn edit_post_post( State(state): State, Path((board_short, post_id)): Path<(String, i64)>, diff --git a/src/main.rs b/src/main.rs index 0d446d5..2954fbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,6 +176,8 @@ enum AdminAction { // logical CPUs × 4 but can be tuned via `blocking_threads` in settings.toml // or the CHAN_BLOCKING_THREADS environment variable. +#[allow(clippy::arithmetic_side_effects)] +#[allow(clippy::expect_used)] fn main() -> anyhow::Result<()> { fmt::fmt() .with_env_filter( @@ -214,6 +216,7 @@ fn main() -> anyhow::Result<()> { // ─── Server mode ───────────────────────────────────────────────────────────── #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] async fn run_server(port_override: Option) -> anyhow::Result<()> { let early_data_dir = { let exe = std::env::current_exe() @@ -926,6 +929,7 @@ struct TermStats { } #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { // Uptime let uptime = start.elapsed(); @@ -1157,6 +1161,7 @@ fn walkdir_size(path: &std::path::Path) -> u64 { // ─── Startup banner ────────────────────────────────────────────────────────── +#[allow(clippy::arithmetic_side_effects)] fn print_banner() { // Fix #3: All dynamic values (forum_name, bind_addr, paths, MiB sizes) are // padded/truncated to exactly fill the fixed inner width, so the right-hand @@ -1460,6 +1465,7 @@ async fn shutdown_signal() { // ─── Admin CLI mode ─────────────────────────────────────────────────────────── #[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] fn run_admin(action: AdminAction) -> anyhow::Result<()> { use crate::{db, utils::crypto}; use chrono::TimeZone; @@ -1647,6 +1653,7 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { /// Only files inside `{upload_dir}/{board}/thumbs/` are considered — original /// uploads are never touched. Deletion is best-effort: individual failures /// are logged and skipped rather than aborting the whole pass. +#[allow(clippy::arithmetic_side_effects)] fn evict_thumb_cache(upload_dir: &str, max_bytes: u64) { // Collect (mtime_secs, path, size) for every file inside any thumbs/ dir. let mut files: Vec<(u64, std::path::PathBuf, u64)> = Vec::new(); diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index b87d064..7d1c902 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -159,6 +159,7 @@ fn now_secs() -> u64 { /// shows an in-page toast notification and then navigates the browser back /// to the previous page — matching the "inline" behaviour of the POST /// cooldown errors rather than stranding the user on a bare error page. +#[allow(clippy::arithmetic_side_effects)] pub async fn rate_limit_middleware(req: Request, next: Next) -> Response { // Only rate-limit GET; skip POST and all other methods entirely. if req.method() != axum::http::Method::GET { diff --git a/src/models.rs b/src/models.rs index 9329d1d..84a711b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -322,17 +322,21 @@ impl Pagination { /// Total number of pages. Always returns at least 1 so templates can /// safely display "page 1 of 1" even on empty result sets. #[must_use] + #[allow(clippy::arithmetic_side_effects)] pub fn total_pages(&self) -> i64 { // per_page is guaranteed >= 1 by new(), but defend against manual // construction just in case. let pp = self.per_page.max(1); let t = self.total.max(0); - ((t.saturating_add(pp - 1)) / pp).max(1) + ((t + pp - 1) / pp).max(1) } #[must_use] pub fn offset(&self) -> i64 { - (self.page.max(1) - 1).saturating_mul(self.per_page.max(1)) + self.page + .max(1) + .saturating_sub(1) + .saturating_mul(self.per_page.max(1)) } #[must_use] @@ -437,6 +441,7 @@ mod tests { // ── MediaType serde ↔ DB string parity ──────────────────────────────── #[test] + #[allow(clippy::expect_used)] fn media_type_serde_matches_db_str() { for mt in [MediaType::Image, MediaType::Video, MediaType::Audio] { let json = diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 2022895..246ac8c 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -265,7 +265,7 @@ pub fn render_pagination(p: &Pagination, base_url: &str) -> String { html, r#"[prev] "#, safe_base, - p.page - 1, + p.page.saturating_sub(1), sep = sep ); } @@ -275,7 +275,7 @@ pub fn render_pagination(p: &Pagination, base_url: &str) -> String { html, r#" [next]"#, safe_base, - p.page + 1, + p.page.saturating_add(1), sep = sep ); } @@ -288,7 +288,7 @@ pub fn render_pagination(p: &Pagination, base_url: &str) -> String { // RFC 3986 percent-encoding operates on bytes. #[must_use] pub fn urlencoding_simple(s: &str) -> String { - let mut out = String::with_capacity(s.len() * 3); + let mut out = String::with_capacity(s.len().saturating_mul(3)); for &byte in s.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { diff --git a/src/templates/thread.rs b/src/templates/thread.rs index a4e01d2..a47468f 100644 --- a/src/templates/thread.rs +++ b/src/templates/thread.rs @@ -260,7 +260,7 @@ fn render_poll( csrf_token: &str, ) -> String { let now = chrono::Utc::now().timestamp(); - let time_left = pd.poll.expires_at - now; + let time_left = pd.poll.expires_at.saturating_sub(now); let expires_str = if pd.is_expired { "closed".to_string() } else if time_left < 3600 { @@ -563,7 +563,7 @@ pub fn render_post( // The previous guard had `> 0 && …` which suppressed the edit link // entirely when the board used the no-limit setting. let within_edit_window = edit_window_secs == 0 - || (edit_window_secs > 0 && now - post.created_at <= edit_window_secs); + || (edit_window_secs > 0 && now.saturating_sub(post.created_at) <= edit_window_secs); let edit_link = if allow_editing && within_edit_window { format!( r#" edit"#, diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 33181be..5bd4989 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -197,11 +197,13 @@ pub fn verify_pow(board_short: &str, nonce: &str) -> bool { let now_minutes = now / 60; // Prune stale entries to bound memory usage. + #[allow(clippy::arithmetic_side_effects)] SEEN_NONCES.retain(|_, ts| now - *ts < POW_WINDOW_SECS); // Try current minute and the prior (POW_GRACE_MINUTES - 1) minutes. let cache_key = format!("{board_short}:{nonce}"); for delta in 0..POW_GRACE_MINUTES { + #[allow(clippy::arithmetic_side_effects)] let challenge = pow_challenge(board_short, (now_minutes - delta) * 60); let input = format!("{challenge}:{nonce}"); let hash = Sha256::digest(input.as_bytes()); @@ -230,7 +232,7 @@ fn leading_zero_bits(bytes: &[u8]) -> u32 { let mut count = 0u32; for &byte in bytes { let lz = byte.leading_zeros(); - count += lz; + count = count.saturating_add(lz); if lz < 8 { break; } @@ -240,6 +242,7 @@ fn leading_zero_bits(bytes: &[u8]) -> u32 { #[cfg(test)] mod tests { + #![allow(clippy::expect_used)] use super::*; // ── Password hashing ───────────────────────────────────────────── diff --git a/src/utils/files.rs b/src/utils/files.rs index 675d00d..df3f5d0 100644 --- a/src/utils/files.rs +++ b/src/utils/files.rs @@ -66,6 +66,7 @@ pub struct UploadedFile { /// /// # Errors /// Returns an error if the data is empty or the file type is not on the allowlist. +#[allow(clippy::arithmetic_side_effects)] pub fn detect_mime_type(data: &[u8]) -> Result<&'static str> { if data.is_empty() { return Err(anyhow::anyhow!("File is empty.")); @@ -211,7 +212,7 @@ fn check_disk_space(dir: &Path, needed_bytes: usize) -> Result<()> { if libc::statvfs(path_cstr.as_ptr(), &raw mut stat) == 0 { #[allow(clippy::unnecessary_cast)] #[allow(clippy::useless_conversion, clippy::cast_lossless)] - let free_bytes = u64::from(stat.f_bavail) * u64::from(stat.f_frsize); + let free_bytes = u64::from(stat.f_bavail).saturating_mul(u64::from(stat.f_frsize)); let needed = (needed_bytes as u64).saturating_mul(2); if free_bytes < needed { return Err(anyhow::anyhow!( @@ -1146,6 +1147,7 @@ pub fn gen_waveform_png( #[cfg(test)] mod tests { + #![allow(clippy::expect_used)] use super::*; // ── format_file_size ───────────────────────────────────────────────────── diff --git a/src/utils/sanitize.rs b/src/utils/sanitize.rs index 982b3ab..25185c6 100644 --- a/src/utils/sanitize.rs +++ b/src/utils/sanitize.rs @@ -20,20 +20,27 @@ use rand_core::{OsRng, RngCore}; use regex::Regex; use std::sync::LazyLock; +#[allow(clippy::expect_used)] static RE_REPLY: LazyLock = LazyLock::new(|| Regex::new(r">>(\d+)").expect("RE_REPLY is valid")); +#[allow(clippy::expect_used)] static RE_CROSSLINK: LazyLock = LazyLock::new(|| { Regex::new(r">>>/([a-z0-9]+)/(\d+)?").expect("RE_CROSSLINK is valid") }); +#[allow(clippy::expect_used)] static RE_URL: LazyLock = LazyLock::new(|| Regex::new(r"(https?://[^\s&<>]{3,300})").expect("RE_URL is valid")); +#[allow(clippy::expect_used)] static RE_BOLD: LazyLock = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").expect("RE_BOLD is valid")); +#[allow(clippy::expect_used)] static RE_ITALIC: LazyLock = LazyLock::new(|| Regex::new(r"__([^_]+)__").expect("RE_ITALIC is valid")); +#[allow(clippy::expect_used)] static RE_SPOILER: LazyLock = LazyLock::new(|| { Regex::new(r"\[spoiler\]([\s\S]*?)\[/spoiler\]").expect("RE_SPOILER is valid") }); +#[allow(clippy::expect_used)] static RE_DICE: LazyLock = LazyLock::new(|| Regex::new(r"\[dice (\d{1,2})d(\d{1,3})\]").expect("RE_DICE is valid")); @@ -74,6 +81,7 @@ pub fn extract_video_embed(url: &str) -> Option<(&'static str, String)> { None } +#[allow(clippy::arithmetic_side_effects)] fn extract_yt_id(url: &str) -> Option { // youtu.be/ID if let Some(pos) = url.find("youtu.be/") { @@ -103,6 +111,7 @@ fn extract_yt_id(url: &str) -> Option { extract_yt_id_from_watch_param(url) } +#[allow(clippy::arithmetic_side_effects)] fn extract_yt_id_from_watch_param(url: &str) -> Option { for prefix in &["?v=", "&v="] { if let Some(pos) = url.find(prefix) { @@ -120,6 +129,7 @@ fn extract_yt_id_from_watch_param(url: &str) -> Option { None } +#[allow(clippy::arithmetic_side_effects)] fn extract_streamable_id(url: &str) -> Option { // streamable.com/CODE — code is alphanumeric, typically 6 chars if let Some(pos) = url.find("streamable.com/") { @@ -150,6 +160,7 @@ const fn d6_face(n: u32) -> char { } /// Roll `count` dice each with `sides` faces, return (rolls, sum). +#[allow(clippy::arithmetic_side_effects)] fn roll_dice(count: u32, sides: u32) -> (Vec, u32) { let mut rolls = Vec::with_capacity(count as usize); let mut sum = 0u32; @@ -251,6 +262,7 @@ fn apply_emoji(text: &str) -> String { /// intermediate allocations, unlike the chained `.replace()` approach which /// produces up to five heap-allocated intermediates per call. #[must_use] +#[allow(clippy::arithmetic_side_effects)] pub fn escape_html(s: &str) -> String { // Pre-allocate with a small headroom for the most common entities. let mut out = String::with_capacity(s.len() + s.len() / 8); @@ -296,6 +308,7 @@ const MAX_BODY_BYTES: usize = 32 * 1024; // 32 KiB /// the client side (JS removes the `open` attribute when the page-level /// `data-collapse-greentext` attribute is present on ``). #[must_use] +#[allow(clippy::arithmetic_side_effects)] pub fn render_post_body(escaped: &str) -> String { // Hard length guard before touching any regex. Must be enforced here // (not only at the HTTP layer) because the sanitizer is also called diff --git a/src/utils/tripcode.rs b/src/utils/tripcode.rs index 4f7c7e6..0a6636a 100644 --- a/src/utils/tripcode.rs +++ b/src/utils/tripcode.rs @@ -77,6 +77,7 @@ pub fn parse_name_tripcode(raw: &str) -> (String, Option) { /// Truncate `s` to at most `max_bytes` bytes, rounding down to the nearest /// UTF-8 character boundary so the result is always valid `&str`. +#[allow(clippy::arithmetic_side_effects)] fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { return s; @@ -94,6 +95,7 @@ fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str { /// /// Returns a string like `"!Ab3Xy7Kp2Q"` — a `'!'` prefix followed by /// [`TRIPCODE_ENCODED_LEN`] base64url characters. +#[allow(clippy::expect_used)] fn compute_tripcode(password: &str) -> String { let hash = Sha256::digest(password.as_bytes()); @@ -126,6 +128,7 @@ fn compute_tripcode(password: &str) -> String { /// - `ALPHABET[x]` where `x` is produced by 6-bit masking (0‥63) into a /// 64-element array — always in bounds. #[allow(clippy::indexing_slicing)] +#[allow(clippy::arithmetic_side_effects)] fn base64url_encode(input: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; @@ -164,6 +167,7 @@ fn base64url_encode(input: &[u8]) -> String { #[cfg(test)] mod tests { + #![allow(clippy::expect_used)] use super::*; #[test] diff --git a/src/workers/mod.rs b/src/workers/mod.rs index 10f8160..4c264d1 100644 --- a/src/workers/mod.rs +++ b/src/workers/mod.rs @@ -315,6 +315,7 @@ async fn worker_loop(id: usize, queue: Arc, ffmpeg_available: bool) { /// Base: 500 ms × 2^n, capped at 60 s. /// Jitter: uniform random 0–500 ms added to spread simultaneous retries /// across all workers so they do not storm the DB at the same instant. +#[allow(clippy::arithmetic_side_effects)] fn backoff_duration(consecutive_errors: u32) -> Duration { const BASE_MS: u64 = 500; const MAX_MS: u64 = 60_000; From 0084ff2d7082e73eee4c57de5ffe26c58f9d0464 Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 11 Mar 2026 16:44:21 -0700 Subject: [PATCH 3/8] fixed content violation policy preventing reporting from proper function, would send to an error 403 page --- .gitignore | 1 + src/handlers/board.rs | 2 ++ src/handlers/thread.rs | 2 ++ 3 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 6b244bc..7622331 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store dev-check.sh docs/clippy.sh +dev-check-strict.sh diff --git a/src/handlers/board.rs b/src/handlers/board.rs index e0c7f84..34fd012 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -600,6 +600,7 @@ pub struct ReportForm { pub thread_id: i64, pub board: String, pub reason: Option, + #[serde(rename = "_csrf")] pub csrf: Option, } @@ -854,6 +855,7 @@ pub async fn redirect_to_post( #[derive(serde::Deserialize)] pub struct AppealForm { pub reason: String, + #[serde(rename = "_csrf")] pub csrf: Option, } diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index a9b7004..d6a280c 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -416,6 +416,7 @@ pub async fn edit_post_get( #[derive(Deserialize)] pub struct EditForm { + #[serde(rename = "_csrf")] pub csrf: Option, pub deletion_token: String, pub body: String, @@ -537,6 +538,7 @@ pub async fn edit_post_post( #[derive(Deserialize)] pub struct VoteForm { + #[serde(rename = "_csrf")] pub csrf: Option, pub option_id: i64, } From 1bc15e73d6f3f495ca9939becd1472a559a19bfe Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 11 Mar 2026 17:38:00 -0700 Subject: [PATCH 4/8] version bump to v1.1.0 and fixed mobile start new thread and reply to thread buttons not responding --- Cargo.toml | 2 +- src/templates/thread.rs | 14 ----- static/main.js | 30 ++------- static/style.css | 132 ++++++++-------------------------------- 4 files changed, 31 insertions(+), 147 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9d74064..27bbdbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustchan" -version = "1.0.13" +version = "1.1.0" edition = "2021" license = "MIT" # FIX[LOW-1]: Removed hardware-specific description. This binary is portable diff --git a/src/templates/thread.rs b/src/templates/thread.rs index a47468f..0bac6f6 100644 --- a/src/templates/thread.rs +++ b/src/templates/thread.rs @@ -180,20 +180,6 @@ pub fn thread_page( - - -
- ✏ Reply -
-
-
- reply to thread - -
-
- {form_html} -
"# ); } diff --git a/static/main.js b/static/main.js index ac1905d..fed3eae 100644 --- a/static/main.js +++ b/static/main.js @@ -21,32 +21,11 @@ function togglePostForm() { } } -function toggleMobileDrawer() { - var drawer = document.getElementById('mobile-reply-drawer'); - var fab = document.getElementById('mobile-reply-fab'); - if (!drawer) return; - var opening = !drawer.classList.contains('open'); - drawer.classList.toggle('open', opening); - if (fab) fab.classList.toggle('hidden', opening); - if (opening) { - var ta = drawer.querySelector('textarea'); - if (ta) { setTimeout(function () { ta.focus(); }, 120); } - } -} - function appendReply(id) { var wrap = document.getElementById('post-form-wrap'); - var isMobile = window.matchMedia('(max-width: 767px)').matches; - if (isMobile) { - var drawer = document.getElementById('mobile-reply-drawer'); - if (drawer && !drawer.classList.contains('open')) toggleMobileDrawer(); - var ta = drawer ? drawer.querySelector('textarea') : null; - if (ta) { ta.value += '>>' + id + '\n'; ta.focus(); } - } else { - if (wrap && wrap.style.display === 'none') togglePostForm(); - var ta2 = document.getElementById('reply-body'); - if (ta2) { ta2.value += '>>' + id + '\n'; ta2.focus(); } - } + if (wrap && wrap.style.display === 'none') togglePostForm(); + var ta = document.getElementById('reply-body'); + if (ta) { ta.value += '>>' + id + '\n'; ta.focus(); } return false; } @@ -585,7 +564,7 @@ function closeReportModal() { wireFormTracking(); document.addEventListener('click', function (e) { - if (e.target && (e.target.id === 'mobile-reply-fab' || e.target.classList.contains('post-toggle-btn'))) { + if (e.target && e.target.classList.contains('post-toggle-btn')) { setTimeout(wireFormTracking, 150); } }); @@ -1088,7 +1067,6 @@ document.addEventListener('click', function (e) { if (t) { switch (t.dataset.action) { case 'toggle-post-form': togglePostForm(); break; - case 'toggle-mobile-drawer': toggleMobileDrawer(); break; case 'dismiss-compress': dismissCompressModal(); break; case 'start-compress': startCompress(); break; case 'close-report': closeReportModal(); break; diff --git a/static/style.css b/static/style.css index 5f3e6c7..478cfeb 100644 --- a/static/style.css +++ b/static/style.css @@ -1439,12 +1439,34 @@ main { /* ── Responsive ───────────────────────────────────────────────────────────── */ @media (max-width: 600px) { - main { padding: 8px 6px; } - .reply { margin-left: 10px; } - .catalog-grid { grid-template-columns: repeat(3, 1fr); } + main { padding: 8px 4px; } + .reply { margin-left: 6px; } + .catalog-grid { grid-template-columns: repeat(2, 1fr); } .board-cards { grid-template-columns: repeat(2, 1fr); } - .thumb { max-width: 120px; max-height: 120px; } + .thumb { max-width: 90px; max-height: 90px; } .post-form td:first-child { display: none; } + + /* Header: shrink site name, wrap board list below */ + .site-header { flex-wrap: wrap; gap: 4px; padding: 5px 8px; } + .site-name { font-size: 1.2rem; } + .board-list { font-size: 0.78rem; width: 100%; order: 3; } + .header-search { order: 2; flex: 1; } + .header-search input { width: 100%; box-sizing: border-box; } + .admin-header-link { font-size: 0.75rem; } + .home-btn { font-size: 0.85rem; padding: 3px 7px; } + + /* Board header */ + .board-header h1 { font-size: 1.1rem; } + .board-desc { font-size: 0.8rem; } + + /* Thread / post layout */ + .op { padding: 8px 6px; } + .post-header { flex-wrap: wrap; gap: 3px; font-size: 0.78rem; } + .post-body { font-size: 0.88rem; line-height: 1.5; } + + /* Catalog header row: stack sort below title */ + .catalog-header-row { flex-direction: column; gap: 6px; } + .catalog-sort-wrap { align-self: flex-start; } } /* ═══════════════════════════════════════════════════════════════════════════════ @@ -2503,108 +2525,6 @@ details.greentext-block[open] > summary { color: var(--green-pale); } cursor: default; } -/* ── Mobile reply drawer ──────────────────────────────────────────────────── */ - -.mobile-reply-fab { - display: none; -} - -.mobile-reply-drawer { - display: none; - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 900; - background: var(--bg-post, #111); - border-top: 2px solid var(--green-bright, #00c840); - max-height: 80vh; - overflow-y: auto; - transform: translateY(100%); - transition: transform 0.22s ease; -} -.mobile-reply-drawer.open { - transform: translateY(0); -} -.mobile-drawer-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 12px; - border-bottom: 1px solid var(--border, #333); - font-family: var(--font); - font-size: 0.78rem; - color: var(--text-dim, #888); - background: var(--bg-panel, #0a0a0a); - position: sticky; - top: 0; -} -.mobile-drawer-close { - background: none; - border: none; - color: var(--green-bright, #00c840); - font-size: 1rem; - cursor: pointer; - padding: 2px 6px; - font-family: var(--font); -} -.mobile-drawer-body { - padding: 8px; -} - -@media (max-width: 767px) { - .post-toggle-bar.reply { - display: none; - } - .post-form-wrap { - display: none !important; - } - .mobile-reply-fab { - display: flex; - align-items: center; - justify-content: center; - position: fixed; - bottom: 18px; - right: 18px; - z-index: 910; - background: var(--green-bright, #00c840); - color: #000; - font-family: var(--font); - font-size: 0.85rem; - font-weight: bold; - border: none; - border-radius: 24px; - padding: 10px 20px; - cursor: pointer; - box-shadow: 0 3px 14px rgba(0,200,64,0.35); - transition: opacity 0.15s; - } - .mobile-reply-fab.hidden { - opacity: 0; - pointer-events: none; - } - .mobile-reply-drawer { - display: block; - } - .mobile-drawer-body .post-form-container { - border: none; - padding: 0; - margin: 0; - } - .mobile-drawer-body .post-form table { - width: 100%; - } - .mobile-drawer-body .post-form td:first-child { - width: 60px; - font-size: 0.72rem; - } - .mobile-drawer-body textarea { - width: 100%; - box-sizing: border-box; - min-height: 80px; - } -} - /* ── Archive page ─────────────────────────────────────────────────────────── */ .archive-subtext { font-size: 0.78rem; From 914676db4a7eefd4a6b26d0b168d84df9f934989 Mon Sep 17 00:00:00 2001 From: csd113 Date: Wed, 11 Mar 2026 19:27:20 -0700 Subject: [PATCH 5/8] all images are now converted to webp if ffmpeg is installed this should greatly improve delivery and keep file size smaller --- CHANGELOG.md | 82 ++++ Cargo.lock | 75 +++- Cargo.toml | 2 +- src/lib.rs | 1 + src/main.rs | 1 + src/media/convert.rs | 364 ++++++++++++++++++ src/media/exif.rs | 64 ++++ src/media/ffmpeg.rs | 291 ++++++++++++++ src/media/mod.rs | 256 +++++++++++++ src/media/thumbnail.rs | 272 +++++++++++++ src/models.rs | 4 +- src/utils/files.rs | 848 +++++++++++------------------------------ src/workers/mod.rs | 67 ++-- static/main.js | 3 +- 14 files changed, 1667 insertions(+), 663 deletions(-) create mode 100644 src/media/convert.rs create mode 100644 src/media/exif.rs create mode 100644 src/media/ffmpeg.rs create mode 100644 src/media/mod.rs create mode 100644 src/media/thumbnail.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 295afdc..3e35b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,88 @@ All notable changes to RustChan will be documented in this file. --- +## [1.1.0] + +## New Module: src/media/ + +### media/ffmpeg.rs — FFmpeg detection and subprocess execution + +- Added detect_ffmpeg() for checking FFmpeg availability (synchronous, suitable for spawn_blocking) +- Added run_ffmpeg() shared executor used by all FFmpeg calls +- Added ffmpeg_image_to_webp() with quality 85 and metadata stripping +- Added ffmpeg_gif_to_webm() using VP9 codec, CRF 30, zero bitrate target, metadata stripped +- Added ffmpeg_thumbnail() extracting first frame as WebP at quality 80 with aspect-preserving scale +- Added probe_video_codec() via ffprobe subprocess (moved from utils/files.rs) +- Added ffmpeg_transcode_to_webm() using path-based API (replaces old bytes-in/bytes-out version) +- Added ffmpeg_audio_waveform() using path-based API (same refactor as above) + +### media/convert.rs — Per-format conversion logic + +- Added ConversionAction enum: ToWebp, ToWebm, ToWebpIfSmaller, KeepAsIs +- Added conversion_action() mapping each MIME type to the correct action +- Added convert_file() as the main entry point for all conversions +- PNG to WebP is attempted but original PNG is kept if WebP is larger +- All conversions use atomic temp-then-rename strategy +- FFmpeg failures fall back to original file with a warning (never panics, never returns 500) + +### media/thumbnail.rs — WebP thumbnail generation + +- All thumbnails output as .webp +- SVG placeholders used for video without FFmpeg, audio, and SVG sources +- Added generate_thumbnail() as unified entry point +- Added image crate fallback path for when FFmpeg is unavailable (decode, resize, save as WebP) +- Added thumbnail_output_path() for determining correct output path and extension +- Added write_placeholder() for generating static SVG placeholders by kind + +### media/exif.rs — EXIF orientation handling (new file) + +- Moved read_exif_orientation and apply_exif_orientation from utils/files.rs + +### media/mod.rs — Public API + +- Added ProcessedMedia struct with file_path, thumbnail_path, mime_type, was_converted, original_size, final_size +- Added MediaProcessor::new() with FFmpeg detection and warning log if not found +- Added MediaProcessor::new_with_ffmpeg() as lightweight constructor for request handlers +- Added MediaProcessor::process_upload() for conversion and thumbnail generation (never propagates FFmpeg errors) +- Added MediaProcessor::generate_thumbnail() for standalone thumbnail regeneration +- Registered submodules: convert, ffmpeg, thumbnail, exif + +--- + +## Modified Files + +### src/utils/files.rs + +- Extended detect_mime_type with BMP, TIFF (LE and BE), and SVG detection including BOM stripping +- Rewrote save_upload to delegate conversion and thumbnailing to MediaProcessor +- GIF to WebM conversions now set processing_pending = false (converted inline, no background job) +- MP4 and WebM uploads still set processing_pending = true as before +- Removed dead functions: generate_video_thumb, ffmpeg_first_frame, generate_video_placeholder, generate_audio_placeholder, generate_image_thumb +- Removed relocated functions: ffprobe_video_codec, probe_video_codec, ffmpeg_transcode_webm, transcode_to_webm, ffmpeg_audio_waveform, gen_waveform_png +- EXIF functions kept as thin private delegates to crate::media::exif for backward compatibility +- Added mime_to_ext_pub() public wrapper for use by media/convert.rs +- Added apply_thumb_exif_orientation() for post-hoc EXIF correction on image crate thumbnails +- Added tests for BMP, TIFF LE, TIFF BE, SVG detection and new mime_to_ext mappings + +### src/models.rs + +- Updated from_ext to include bmp, tiff, tif, and svg + +### src/lib.rs and src/main.rs + +- Registered new media module + +### src/workers/mod.rs + +- Updated probe_video_codec call to use crate::media::ffmpeg::probe_video_codec +- Replaced in-memory transcode_to_webm with path-based ffmpeg_transcode_to_webm using temp file persist +- Replaced in-memory gen_waveform_png with path-based ffmpeg_audio_waveform using temp file persist +- File bytes now read from disk only for SHA-256 dedup step + +### Cargo.toml + +- Added bmp and tiff features to the image crate dependency + ## [1.0.13] — 2026-03-08 diff --git a/Cargo.lock b/Cargo.lock index e8e1340..484a3ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -439,6 +439,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -526,6 +532,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -668,6 +694,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -844,8 +881,9 @@ dependencies = [ "moxcms", "num-traits", "png", - "zune-core", - "zune-jpeg", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", ] [[package]] @@ -1351,7 +1389,7 @@ dependencies = [ [[package]] name = "rustchan" -version = "1.0.13" +version = "1.1.0" dependencies = [ "anyhow", "argon2", @@ -1663,6 +1701,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + [[package]] name = "time" version = "0.3.47" @@ -2388,17 +2440,32 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ - "zune-core", + "zune-core 0.5.1", ] diff --git a/Cargo.toml b/Cargo.toml index 27bbdbd..bf6210e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ hex = "0.4" # shares the exact same crate instance as argon2's transitive dep. rand_core = { version = "0.6", features = ["getrandom"] } -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "tiff"] } # EXIF orientation correction: read Orientation tag from JPEG uploads and # apply the corresponding rotation before thumbnailing so that photos taken diff --git a/src/lib.rs b/src/lib.rs index 9828009..23b8925 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod db; pub mod error; +pub mod media; pub mod models; pub mod templates; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 2954fbd..b847174 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,7 @@ mod db; mod detect; mod error; mod handlers; +mod media; mod middleware; mod models; mod templates; diff --git a/src/media/convert.rs b/src/media/convert.rs new file mode 100644 index 0000000..21ff3fc --- /dev/null +++ b/src/media/convert.rs @@ -0,0 +1,364 @@ +// media/convert.rs +// +// Per-format conversion logic. +// +// Conversion rules (from project spec): +// jpg / jpeg → WebP (quality 85, metadata stripped) +// gif → WebM (VP9 codec, preserves animation) +// bmp → WebP +// tiff → WebP +// png → WebP ONLY if the WebP output is smaller; otherwise keep PNG +// svg → keep as-is (no conversion) +// webp → keep as-is +// webm → keep as-is +// all audio → keep as-is +// mp4 → keep as-is (background worker handles MP4→WebM separately) +// +// All conversion functions require ffmpeg. Callers must check +// `MediaProcessor::ffmpeg_available` before calling into this module. +// On failure, all functions log a warning and the caller falls back to +// storing the original bytes. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +use super::ffmpeg; + +/// Describes what action the conversion pipeline should take for a given +/// source MIME type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversionAction { + /// Convert to WebP (JPEG, BMP, TIFF). + ToWebp, + /// Convert to WebM/VP9 (GIF animation). + ToWebm, + /// Attempt WebP; keep original if WebP is not smaller (PNG). + ToWebpIfSmaller, + /// No conversion; store file as-is. + KeepAsIs, +} + +/// Determine the conversion action for a given MIME type. +/// +/// Returns `KeepAsIs` for any MIME type not explicitly handled so that +/// unknown or new formats are stored without modification. +#[must_use] +pub fn conversion_action(mime: &str) -> ConversionAction { + match mime { + "image/jpeg" | "image/bmp" | "image/tiff" => ConversionAction::ToWebp, + "image/gif" => ConversionAction::ToWebm, + "image/png" => ConversionAction::ToWebpIfSmaller, + // Keep these formats as-is + "image/svg+xml" | "image/webp" | "video/webm" | "audio/webm" | "video/mp4" + | "audio/mpeg" | "audio/ogg" | "audio/flac" | "audio/wav" | "audio/mp4" | "audio/aac" => { + ConversionAction::KeepAsIs + } + _ => ConversionAction::KeepAsIs, + } +} + +/// Result of a conversion operation. +pub struct ConversionResult { + /// Absolute path to the final file on disk. + pub final_path: PathBuf, + /// MIME type of the final file (may differ from source if converted). + pub final_mime: &'static str, + /// `true` when the file was actually converted to a new format. + pub was_converted: bool, + /// Size of the final file in bytes. + pub final_size: u64, +} + +/// Convert `input_path` according to its MIME type and write the output to +/// `output_dir` using `file_stem` as the base name. +/// +/// If `ffmpeg_available` is `false`, no conversion is attempted and the +/// input file is copied to the output directory with its original extension. +/// +/// # Arguments +/// * `input_path` — Temporary file containing the original upload bytes. +/// * `mime` — Detected MIME type of the input. +/// * `output_dir` — Directory where the final file should be placed. +/// * `file_stem` — UUID-based stem (no extension) for the output filename. +/// * `ffmpeg_available`— Whether the ffmpeg binary was detected at startup. +/// +/// # Errors +/// Returns an error only for I/O failures (copy / rename). ffmpeg failures +/// are logged as warnings and the function falls back to the original file. +pub fn convert_file( + input_path: &Path, + mime: &str, + output_dir: &Path, + file_stem: &str, + ffmpeg_available: bool, +) -> Result { + let action = if ffmpeg_available { + conversion_action(mime) + } else { + ConversionAction::KeepAsIs + }; + + match action { + ConversionAction::ToWebp => convert_to_webp(input_path, output_dir, file_stem), + ConversionAction::ToWebm => convert_gif_to_webm(input_path, output_dir, file_stem), + ConversionAction::ToWebpIfSmaller => { + convert_png_if_smaller(input_path, output_dir, file_stem) + } + ConversionAction::KeepAsIs => copy_as_is(input_path, mime, output_dir, file_stem), + } +} + +// ─── Internal conversion helpers ────────────────────────────────────────────── + +/// Convert any ffmpeg-readable image to WebP at quality 85. +/// +/// On ffmpeg failure, logs a warning and falls back to copying the original +/// file unchanged (so the post still succeeds). +fn convert_to_webp(input: &Path, output_dir: &Path, file_stem: &str) -> Result { + let output = output_dir.join(format!("{file_stem}.webp")); + let tmp_out = temp_sibling(&output); + + match ffmpeg::ffmpeg_image_to_webp(input, &tmp_out) { + Ok(()) => { + atomic_rename(&tmp_out, &output)?; + let final_size = file_size(&output)?; + Ok(ConversionResult { + final_path: output, + final_mime: "image/webp", + was_converted: true, + final_size, + }) + } + Err(e) => { + let _ = std::fs::remove_file(&tmp_out); + tracing::warn!("ffmpeg image→webp failed ({}); storing original", e); + // Fall back: copy input to its original extension destination + copy_as_is_with_ext(input, output_dir, file_stem, ext_for_original_mime(input)) + } + } +} + +/// Convert a GIF to WebM/VP9. +/// +/// On ffmpeg failure, stores the original GIF. +fn convert_gif_to_webm( + input: &Path, + output_dir: &Path, + file_stem: &str, +) -> Result { + let output = output_dir.join(format!("{file_stem}.webm")); + let tmp_out = temp_sibling(&output); + + match ffmpeg::ffmpeg_gif_to_webm(input, &tmp_out) { + Ok(()) => { + atomic_rename(&tmp_out, &output)?; + let final_size = file_size(&output)?; + Ok(ConversionResult { + final_path: output, + final_mime: "video/webm", + was_converted: true, + final_size, + }) + } + Err(e) => { + let _ = std::fs::remove_file(&tmp_out); + tracing::warn!("ffmpeg gif→webm failed ({}); storing original GIF", e); + copy_as_is_with_ext(input, output_dir, file_stem, "gif") + } + } +} + +/// Attempt PNG → WebP conversion; keep the PNG if WebP is not smaller. +fn convert_png_if_smaller( + input: &Path, + output_dir: &Path, + file_stem: &str, +) -> Result { + let webp_path = output_dir.join(format!("{file_stem}.webp")); + let tmp_webp = temp_sibling(&webp_path); + + // Try conversion first + match ffmpeg::ffmpeg_image_to_webp(input, &tmp_webp) { + Ok(()) => { + let original_size = file_size(input)?; + let webp_size = file_size(&tmp_webp)?; + + if webp_size < original_size { + // WebP wins — keep the converted file + atomic_rename(&tmp_webp, &webp_path)?; + Ok(ConversionResult { + final_path: webp_path, + final_mime: "image/webp", + was_converted: true, + final_size: webp_size, + }) + } else { + // PNG is already optimal + let _ = std::fs::remove_file(&tmp_webp); + tracing::debug!("PNG→WebP skipped: webp ({webp_size}B) ≥ png ({original_size}B)"); + copy_as_is_with_ext(input, output_dir, file_stem, "png") + } + } + Err(e) => { + let _ = std::fs::remove_file(&tmp_webp); + tracing::warn!("ffmpeg png→webp failed ({}); storing original PNG", e); + copy_as_is_with_ext(input, output_dir, file_stem, "png") + } + } +} + +/// Copy the input file to `output_dir/{file_stem}.{ext}` without conversion. +fn copy_as_is( + input: &Path, + mime: &str, + output_dir: &Path, + file_stem: &str, +) -> Result { + let ext = crate::utils::files::mime_to_ext_pub(mime); + copy_as_is_with_ext(input, output_dir, file_stem, ext) +} + +/// Copy `input` to `output_dir/{file_stem}.{ext}`, returning a `ConversionResult`. +fn copy_as_is_with_ext( + input: &Path, + output_dir: &Path, + file_stem: &str, + ext: &str, +) -> Result { + let output = output_dir.join(format!("{file_stem}.{ext}")); + std::fs::copy(input, &output) + .with_context(|| format!("failed to copy upload to {}", output.display()))?; + let final_size = file_size(&output)?; + // Determine MIME from extension for reporting + let final_mime = ext_to_static_mime(ext); + Ok(ConversionResult { + final_path: output, + final_mime, + was_converted: false, + final_size, + }) +} + +// ─── Path and size utilities ────────────────────────────────────────────────── + +/// Create a UUID-named sibling path for use as an atomic temp output. +fn temp_sibling(target: &Path) -> PathBuf { + let tmp_name = format!(".tmp_{}", Uuid::new_v4().simple()); + target + .parent() + .map_or_else(|| PathBuf::from(&tmp_name), |p| p.join(&tmp_name)) +} + +/// Rename `src` to `dst` atomically (same filesystem assumed). +fn atomic_rename(src: &Path, dst: &Path) -> Result<()> { + std::fs::rename(src, dst) + .with_context(|| format!("failed to rename {} → {}", src.display(), dst.display())) +} + +/// Return the size of a file in bytes. +fn file_size(path: &Path) -> Result { + std::fs::metadata(path) + .map(|m| m.len()) + .with_context(|| format!("failed to stat {}", path.display())) +} + +/// Best-guess extension for a file whose extension we preserved but whose +/// original MIME is no longer in scope. Used only in fallback paths. +fn ext_for_original_mime(path: &Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()) { + Some("jpg" | "jpeg") => "jpg", + Some("png") => "png", + Some("gif") => "gif", + Some("bmp") => "bmp", + Some("tiff" | "tif") => "tiff", + Some("webp") => "webp", + Some("webm") => "webm", + Some("svg") => "svg", + _ => "bin", + } +} + +/// Map a file extension back to a `'static` MIME string for `ConversionResult`. +fn ext_to_static_mime(ext: &str) -> &'static str { + match ext { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "bmp" => "image/bmp", + "tiff" | "tif" => "image/tiff", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "webm" => "video/webm", + "mp4" => "video/mp4", + "mp3" => "audio/mpeg", + "ogg" => "audio/ogg", + "flac" => "audio/flac", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "aac" => "audio/aac", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jpeg_maps_to_webp() { + assert_eq!(conversion_action("image/jpeg"), ConversionAction::ToWebp); + } + + #[test] + fn gif_maps_to_webm() { + assert_eq!(conversion_action("image/gif"), ConversionAction::ToWebm); + } + + #[test] + fn png_maps_to_try_webp() { + assert_eq!( + conversion_action("image/png"), + ConversionAction::ToWebpIfSmaller + ); + } + + #[test] + fn webp_is_keep_as_is() { + assert_eq!(conversion_action("image/webp"), ConversionAction::KeepAsIs); + } + + #[test] + fn webm_is_keep_as_is() { + assert_eq!(conversion_action("video/webm"), ConversionAction::KeepAsIs); + } + + #[test] + fn bmp_maps_to_webp() { + assert_eq!(conversion_action("image/bmp"), ConversionAction::ToWebp); + } + + #[test] + fn tiff_maps_to_webp() { + assert_eq!(conversion_action("image/tiff"), ConversionAction::ToWebp); + } + + #[test] + fn audio_is_keep_as_is() { + for mime in &["audio/mpeg", "audio/ogg", "audio/flac", "audio/wav"] { + assert_eq!( + conversion_action(mime), + ConversionAction::KeepAsIs, + "expected KeepAsIs for {mime}" + ); + } + } + + #[test] + fn unknown_mime_is_keep_as_is() { + assert_eq!( + conversion_action("application/octet-stream"), + ConversionAction::KeepAsIs + ); + } +} diff --git a/src/media/exif.rs b/src/media/exif.rs new file mode 100644 index 0000000..b1f00ce --- /dev/null +++ b/src/media/exif.rs @@ -0,0 +1,64 @@ +// media/exif.rs +// +// EXIF orientation helpers for decoded images. +// +// These functions are called from `utils/files.rs` during upload processing +// to ensure thumbnails are rendered upright regardless of camera orientation. +// They operate purely on in-memory pixel data — no I/O. + +/// Read the EXIF Orientation tag from JPEG bytes. +/// +/// Returns the orientation value (1–8) or 1 (no rotation) if the tag is +/// absent or unreadable. Only JPEG files carry reliable EXIF orientation; +/// PNG/WebP/GIF do not use this tag. +/// +/// Values follow the EXIF spec: +/// 1 = normal (0°), 2 = flip-H, 3 = 180°, 4 = flip-V, +/// 5 = transpose, 6 = 90° CW, 7 = transverse, 8 = 90° CCW +/// +/// NOTE: This must be called on the ORIGINAL bytes before any EXIF-stripping +/// re-encode. Once EXIF has been stripped, this function will always return 1. +#[must_use] +pub fn read_exif_orientation(data: &[u8]) -> u32 { + use std::io::Cursor; + let Ok(exif) = exif::Reader::new().read_from_container(&mut Cursor::new(data)) else { + return 1; + }; + exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) + .and_then(|f| { + if let exif::Value::Short(ref v) = f.value { + v.first().copied().map(u32::from) + } else { + None + } + }) + .unwrap_or(1) +} + +/// Apply an EXIF orientation transformation to a decoded `DynamicImage`. +/// +/// This corrects the pixel layout so that thumbnails appear upright regardless +/// of which way the camera was held when the photo was taken. The `image` +/// crate operations used here are pure in-memory pixel transforms — no I/O. +#[must_use] +pub fn apply_exif_orientation(img: image::DynamicImage, orientation: u32) -> image::DynamicImage { + use image::imageops; + match orientation { + 2 => image::DynamicImage::ImageRgba8(imageops::flip_horizontal(&img)), + 3 => image::DynamicImage::ImageRgba8(imageops::rotate180(&img)), + 4 => image::DynamicImage::ImageRgba8(imageops::flip_vertical(&img)), + 5 => { + // Transpose = rotate 90° CW then flip horizontally + let rot = imageops::rotate90(&img); + image::DynamicImage::ImageRgba8(imageops::flip_horizontal(&rot)) + } + 6 => image::DynamicImage::ImageRgba8(imageops::rotate90(&img)), + 7 => { + // Transverse = rotate 90° CW then flip vertically + let rot = imageops::rotate90(&img); + image::DynamicImage::ImageRgba8(imageops::flip_vertical(&rot)) + } + 8 => image::DynamicImage::ImageRgba8(imageops::rotate270(&img)), + _ => img, // 1 = normal, or unknown value — no transform + } +} diff --git a/src/media/ffmpeg.rs b/src/media/ffmpeg.rs new file mode 100644 index 0000000..34afc1c --- /dev/null +++ b/src/media/ffmpeg.rs @@ -0,0 +1,291 @@ +// media/ffmpeg.rs +// +// FFmpeg binary detection and subprocess execution helpers. +// +// Design notes: +// • Detection is done by running `ffmpeg -version` and checking the exit code. +// • All subprocess invocations use `std::process::Command` with explicit +// argument arrays — never shell strings — to eliminate injection surfaces. +// • This module is called from synchronous contexts (spawn_blocking), so +// std::process::Command is used instead of tokio::process::Command. +// • Temp files passed to ffmpeg are created by the caller; this module only +// executes the subprocess and reports success or failure. +// • If ffmpeg exits non-zero, the error includes the trimmed stderr so +// operators can diagnose codec or format issues without reading raw logs. + +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Probe whether the `ffmpeg` binary is reachable on the current PATH. +/// +/// Runs `ffmpeg -version` and returns `true` if the process exits successfully. +/// This is a synchronous, blocking call and is intended to be invoked at +/// startup (or lazily on first upload) inside a `spawn_blocking` task. +/// +/// A `false` return means conversion and video-thumbnail features will be +/// unavailable; all callers must degrade gracefully. +#[must_use] +pub fn detect_ffmpeg() -> bool { + Command::new("ffmpeg") + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Execute `ffmpeg` with the given argument slice. +/// +/// All arguments must be pre-constructed, static strings — no user-supplied +/// data may appear in `args` directly. File paths passed to ffmpeg must be +/// UUID-based names generated by the server. +/// +/// Returns `Ok(())` on exit code 0, or an `Err` containing the trimmed +/// stderr output on any non-zero exit. +/// +/// # Errors +/// Returns an error if ffmpeg cannot be spawned (binary missing, permission +/// denied) or if the process exits with a non-zero status code. +pub fn run_ffmpeg(args: &[&str]) -> Result<()> { + let output = Command::new("ffmpeg") + .args(args) + .output() + .context("failed to spawn ffmpeg — is it installed and on PATH?")?; + + if output.status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "ffmpeg exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +/// Convert an image file to WebP using ffmpeg. +/// +/// Uses quality 85 and strips all metadata (`-map_metadata -1`). +/// Input can be any format ffmpeg understands (JPEG, PNG, BMP, TIFF, …). +/// +/// # Errors +/// Returns an error if ffmpeg exits non-zero or cannot be spawned. +pub fn ffmpeg_image_to_webp(input: &Path, output: &Path) -> Result<()> { + let in_str = path_to_str(input)?; + let out_str = path_to_str(output)?; + + run_ffmpeg(&[ + "-loglevel", + "error", + "-i", + in_str, + "-map_metadata", + "-1", + "-quality", + "85", + "-y", + out_str, + ]) + .with_context(|| format!("image→webp conversion failed for {in_str}")) +} + +/// Convert a GIF animation to `WebM` (VP9 codec) using ffmpeg. +/// +/// Uses constant-quality VP9 encoding (`-crf 30 -b:v 0`) which preserves +/// animation while producing browser-compatible `WebM`. All metadata is +/// stripped. +/// +/// # Errors +/// Returns an error if ffmpeg exits non-zero or cannot be spawned. +pub fn ffmpeg_gif_to_webm(input: &Path, output: &Path) -> Result<()> { + let in_str = path_to_str(input)?; + let out_str = path_to_str(output)?; + + run_ffmpeg(&[ + "-loglevel", + "error", + "-i", + in_str, + "-c:v", + "libvpx-vp9", + "-crf", + "30", + "-b:v", + "0", + "-map_metadata", + "-1", + "-y", + out_str, + ]) + .with_context(|| format!("gif→webm conversion failed for {in_str}")) +} + +/// Generate a WebP thumbnail from an image or video by extracting the first +/// frame and scaling to fit within `max_dim × max_dim`. +/// +/// The `-2` height modifier ensures the scaled height is rounded to an even +/// number, required by many video codecs. Aspect ratio is always preserved. +/// Thumbnail quality is fixed at 80 per project spec. +/// +/// Works for both static images and video/animation sources. +/// +/// # Errors +/// Returns an error if ffmpeg exits non-zero or cannot be spawned. +pub fn ffmpeg_thumbnail(input: &Path, output: &Path, max_dim: u32) -> Result<()> { + let in_str = path_to_str(input)?; + let out_str = path_to_str(output)?; + let scale = format!("scale='if(gt(iw,ih),{max_dim},-2)':'if(gt(iw,ih),-2,{max_dim})'"); + + run_ffmpeg(&[ + "-loglevel", + "error", + "-i", + in_str, + "-vframes", + "1", + "-vf", + &scale, + "-quality", + "80", + "-y", + out_str, + ]) + .with_context(|| format!("thumbnail generation failed for {in_str}")) +} + +/// Probe the video codec of the first video stream in a file. +/// +/// Returns the codec name in lower-case (e.g. `"av1"`, `"vp9"`, `"vp8"`). +/// Called by the `VideoTranscode` background worker to decide whether a `WebM` +/// upload needs AV1 → VP9 re-encoding. +/// +/// Security: arguments are passed as a separate array — no shell invocation. +/// `file_path` must be a server-controlled UUID-based path. +/// +/// # Errors +/// Returns an error if ffprobe cannot be run or returns no codec. +pub fn probe_video_codec(file_path: &str) -> Result { + let out = Command::new("ffprobe") + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "csv=p=0", + file_path, + ]) + .output() + .context("ffprobe not found or failed to spawn")?; + + if !out.status.success() { + return Err(anyhow::anyhow!( + "ffprobe exit {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + let codec = String::from_utf8_lossy(&out.stdout) + .trim() + .to_ascii_lowercase(); + + if codec.is_empty() { + return Err(anyhow::anyhow!( + "ffprobe returned no codec for: {file_path}" + )); + } + + Ok(codec) +} + +/// Transcode a video file to `WebM` (VP9/CRF-33 + Opus/96k). +/// +/// `input` and `output` must be on the same filesystem so the caller can +/// atomically rename `output` into its final location after this returns. +/// +/// Encoding settings: +/// VP9 — `-deadline good -cpu-used 4` for a good quality/speed balance. +/// CRF 33 with unconstrained average bitrate (`-b:v 0`) — pure CRF +/// mode is the correct libvpx-vp9 approach. +/// `-row-mt 1` enables row-based multithreading. +/// `-tile-columns 2` improves encode and decode parallelism. +/// Opus — `-b:a 96k` — transparent quality for speech and music. +/// +/// # Errors +/// Returns an error if ffmpeg cannot be spawned or exits non-zero. +pub fn ffmpeg_transcode_to_webm(input: &Path, output: &Path) -> Result<()> { + let in_str = path_to_str(input)?; + let out_str = path_to_str(output)?; + + run_ffmpeg(&[ + "-loglevel", + "error", + "-i", + in_str, + "-c:v", + "libvpx-vp9", + "-crf", + "33", + "-b:v", + "0", + "-deadline", + "good", + "-cpu-used", + "4", + "-row-mt", + "1", + "-tile-columns", + "2", + "-threads", + "0", + "-c:a", + "libopus", + "-b:a", + "96k", + "-y", + out_str, + ]) + .with_context(|| format!("video transcode failed for {in_str}")) +} + +/// Generate a waveform PNG from an audio file using ffmpeg's `showwavespic` filter. +/// +/// `input` is a path to the audio file on disk (UUID-named, server-controlled). +/// `output` receives the PNG. Caller is responsible for atomically renaming +/// `output` into its final location. +/// +/// # Errors +/// Returns an error if ffmpeg cannot be spawned or exits non-zero. +pub fn ffmpeg_audio_waveform(input: &Path, output: &Path, width: u32, height: u32) -> Result<()> { + let in_str = path_to_str(input)?; + let out_str = path_to_str(output)?; + let vf = format!("showwavespic=s={width}x{height}:colors=#00c840|#007020:split_channels=0"); + + run_ffmpeg(&[ + "-loglevel", + "error", + "-i", + in_str, + "-lavfi", + &vf, + "-frames:v", + "1", + "-y", + out_str, + ]) + .with_context(|| format!("audio waveform generation failed for {in_str}")) +} + +// ─── Internal helpers ────────────────────────────────────────────────────────── + +/// Convert a `Path` to a UTF-8 `&str`, returning a descriptive error if the +/// path contains non-UTF-8 bytes (rare on all supported platforms). +fn path_to_str(p: &Path) -> Result<&str> { + p.to_str() + .ok_or_else(|| anyhow::anyhow!("path contains non-UTF-8 characters: {}", p.display())) +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..851b3f2 --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,256 @@ +// media/mod.rs +// +// Public interface for the media processing pipeline. +// +// Usage from the upload pipeline: +// +// let processor = MediaProcessor::new(); // detects ffmpeg once +// let result = processor.process_upload( +// &temp_path, mime, &dest_dir, &file_stem, &thumbs_dir, thumb_max, +// )?; +// // result.file_path — final file on disk (converted if applicable) +// // result.thumbnail_path — WebP thumbnail (or SVG placeholder) +// // result.mime_type — final MIME (may differ from original for gif→webm) +// // result.was_converted — true when format changed +// // result.original_size — bytes of input file +// // result.final_size — bytes of output file +// +// FFmpeg detection: +// `MediaProcessor::new()` calls `ffmpeg::detect_ffmpeg()` exactly once and +// stores the result in `ffmpeg_available`. Alternatively, use +// `MediaProcessor::new_with_ffmpeg(bool)` to supply a pre-detected value +// (e.g. from the startup check stored in `AppState`). +// +// Graceful degradation: +// If ffmpeg is not found, `process_upload` stores files as-is and +// `generate_thumbnail` writes a static SVG placeholder for video; for +// images the `image` crate is used as a fallback thumbnail generator. +// No error is returned to the user in either case. + +pub mod convert; +pub mod exif; +pub mod ffmpeg; +pub mod thumbnail; + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +// ─── ProcessedMedia ─────────────────────────────────────────────────────────── + +/// Outcome of a single upload processed through the media pipeline. +/// +/// Returned by [`MediaProcessor::process_upload`]. All paths are absolute. +#[derive(Debug)] +pub struct ProcessedMedia { + /// Absolute path to the (possibly converted) file on disk. + pub file_path: PathBuf, + /// Absolute path to the generated thumbnail (WebP) or SVG placeholder. + pub thumbnail_path: PathBuf, + /// MIME type of the final stored file. May differ from the uploaded + /// MIME when conversion changes the format (e.g. `image/gif` → `video/webm`). + pub mime_type: String, + /// `true` when the file was converted to a different format. + pub was_converted: bool, + /// Size of the original input in bytes. + #[allow(dead_code)] + pub original_size: u64, + /// Size of the final stored file in bytes. + pub final_size: u64, +} + +// ─── MediaProcessor ─────────────────────────────────────────────────────────── + +/// Stateless processor that converts uploaded media and generates thumbnails. +/// +/// Holds a single boolean indicating whether the `ffmpeg` binary was found on +/// the current `PATH`. All conversion and thumbnail operations consult this +/// flag and degrade gracefully when ffmpeg is absent. +/// +/// ## Construction +/// ```rust,no_run +/// # use chan::media::MediaProcessor; +/// // Detect ffmpeg now (blocking): +/// let processor = MediaProcessor::new(); +/// +/// // Re-use a flag detected at startup (preferred in request handlers): +/// let processor = MediaProcessor::new_with_ffmpeg(true); +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct MediaProcessor { + /// Whether the `ffmpeg` binary was detected on startup. + pub ffmpeg_available: bool, +} + +impl MediaProcessor { + /// Create a new `MediaProcessor`, probing for `ffmpeg` immediately. + /// + /// This performs a blocking process spawn (`ffmpeg -version`). For + /// request handlers, prefer [`MediaProcessor::new_with_ffmpeg`] with the + /// flag pre-detected at startup to avoid redundant spawns. + #[must_use] + pub fn new() -> Self { + let available = ffmpeg::detect_ffmpeg(); + if !available { + tracing::warn!( + "ffmpeg not found — media conversion and video thumbnails are disabled. \ + Install ffmpeg to enable optimal format conversion." + ); + } + Self { + ffmpeg_available: available, + } + } + + /// Create a `MediaProcessor` with a pre-detected ffmpeg availability flag. + /// + /// Use this in request handlers to avoid re-detecting ffmpeg on every upload. + /// The flag should come from `AppState::ffmpeg_available` which is set once + /// at server startup via `detect::detect_ffmpeg`. + #[must_use] + pub const fn new_with_ffmpeg(ffmpeg_available: bool) -> Self { + Self { ffmpeg_available } + } + + /// Process an uploaded file: convert to an optimal web format and generate + /// a thumbnail. + /// + /// The `input_path` must be a temporary file written by the caller; the + /// processor may rename or delete it after processing. The final output + /// is placed at `output_dir / {file_stem}.{ext}` where `ext` is + /// determined by the conversion rules. + /// + /// # Arguments + /// * `input_path` — Temp file holding the original upload bytes. + /// * `mime` — Detected MIME type of the upload. + /// * `output_dir` — Directory for the final converted file. + /// * `file_stem` — UUID stem (no extension) for output file names. + /// * `thumb_dir` — Directory for the generated thumbnail. + /// * `thumb_max` — Maximum thumbnail dimension (pixels, aspect preserved). + /// + /// # Errors + /// Returns an error only for unrecoverable I/O failures (disk full, no + /// permissions). Conversion failures are logged as warnings and the + /// original file is kept instead — the function never propagates ffmpeg + /// errors to the caller. + pub fn process_upload( + self, + input_path: &Path, + mime: &str, + output_dir: &Path, + file_stem: &str, + thumb_dir: &Path, + thumb_max: u32, + ) -> Result { + let original_size = std::fs::metadata(input_path) + .map(|m| m.len()) + .context("failed to stat upload temp file")?; + + // ── Step 1: Convert file ────────────────────────────────────────── + let conv = convert::convert_file( + input_path, + mime, + output_dir, + file_stem, + self.ffmpeg_available, + ) + .context("conversion step failed")?; + + tracing::debug!( + "media: {} → {} (converted={}, {}→{}B)", + mime, + conv.final_mime, + conv.was_converted, + original_size, + conv.final_size, + ); + + // ── Step 2: Generate thumbnail ──────────────────────────────────── + let thumb_path = thumbnail::thumbnail_output_path( + thumb_dir, + file_stem, + conv.final_mime, + self.ffmpeg_available, + ); + + if let Err(e) = thumbnail::generate_thumbnail( + &conv.final_path, + conv.final_mime, + &thumb_path, + thumb_max, + self.ffmpeg_available, + ) { + // Thumbnail failure must never abort an upload. Log and continue; + // the placeholder will already have been written or the path left + // empty — callers must handle a missing thumbnail gracefully. + tracing::warn!("thumbnail generation failed: {e}"); + } + + Ok(ProcessedMedia { + file_path: conv.final_path, + thumbnail_path: thumb_path, + mime_type: conv.final_mime.to_string(), + was_converted: conv.was_converted, + original_size, + final_size: conv.final_size, + }) + } + + /// Generate a thumbnail for an already-processed file. + /// + /// Useful when you need to re-generate a thumbnail separately from the + /// conversion step (e.g. background workers regenerating after manual + /// admin replacement). + /// + /// Writes a WebP file (or SVG placeholder) to `thumb_dir / {file_stem}.{ext}`. + /// + /// # Errors + /// Returns an error only if both ffmpeg and the image-crate fallback fail + /// AND writing the placeholder also fails. + #[allow(dead_code)] + pub fn generate_thumbnail( + self, + input_path: &Path, + mime: &str, + thumb_dir: &Path, + file_stem: &str, + thumb_max: u32, + ) -> Result { + let thumb_path = + thumbnail::thumbnail_output_path(thumb_dir, file_stem, mime, self.ffmpeg_available); + + thumbnail::generate_thumbnail( + input_path, + mime, + &thumb_path, + thumb_max, + self.ffmpeg_available, + )?; + + Ok(thumb_path) + } +} + +impl Default for MediaProcessor { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Constructing `MediaProcessor` with ffmpeg=false should not panic. + #[test] + fn new_with_ffmpeg_false_does_not_panic() { + let p = MediaProcessor::new_with_ffmpeg(false); + assert!(!p.ffmpeg_available); + } + + /// Constructing `MediaProcessor` with ffmpeg=true should not panic. + #[test] + fn new_with_ffmpeg_true_does_not_panic() { + let p = MediaProcessor::new_with_ffmpeg(true); + assert!(p.ffmpeg_available); + } +} diff --git a/src/media/thumbnail.rs b/src/media/thumbnail.rs new file mode 100644 index 0000000..23698df --- /dev/null +++ b/src/media/thumbnail.rs @@ -0,0 +1,272 @@ +// media/thumbnail.rs +// +// Thumbnail generation for all media types. +// +// Rules (from project spec): +// • All thumbnails are WebP, regardless of source format. +// • For video and converted-GIF (WebM) sources: extract the first frame +// via ffmpeg and save as WebP. +// • Max dimension: 250 × 250, aspect ratio preserved. +// • WebP quality: 80. +// • If ffmpeg is unavailable, write a static SVG placeholder for video; +// for images, fall back to the `image` crate (no ffmpeg required). + +use anyhow::{Context, Result}; +use image::{imageops::FilterType, GenericImageView, ImageFormat}; +use std::path::{Path, PathBuf}; + +use super::ffmpeg; + +// ─── Static placeholder SVGs ────────────────────────────────────────────────── + +// Note: these SVG strings contain `"#` sequences (e.g. fill="#0a0f0a") which +// would terminate a `r#"..."#` raw string early. We use `r##"..."##` so the +// closing delimiter requires two consecutive `#` signs, which never appear in +// the SVG body. +const VIDEO_PLACEHOLDER_SVG: &str = r##" + + + + VIDEO +"##; + +const AUDIO_PLACEHOLDER_SVG: &str = r##" + + + + AUDIO +"##; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/// What kind of static placeholder to write when the real thumbnail cannot be +/// generated. +#[derive(Debug, Clone, Copy)] +pub enum PlaceholderKind { + Video, + Audio, +} + +/// Generate a thumbnail for a media file and write it to `output_path`. +/// +/// All thumbnails are produced as WebP. The strategy depends on the MIME +/// type and whether ffmpeg is available: +/// +/// | Source MIME | ffmpeg present | Action | +/// |-------------------|----------------|---------------------------------| +/// | `image/*` | yes | ffmpeg first-frame + WebP | +/// | `image/*` | no | `image` crate → resize → WebP | +/// | `video/webm` | yes | ffmpeg first-frame + WebP | +/// | `video/webm` | no | static SVG placeholder | +/// | `image/svg+xml` | either | static SVG placeholder | +/// | `audio/*` | either | static SVG placeholder | +/// +/// # Arguments +/// * `input_path` — Absolute path to the (already converted) media file. +/// * `mime` — Final MIME type of the input file. +/// * `output_path` — Where to write the thumbnail (WebP or SVG). +/// * `max_dim` — Maximum width and height in pixels (aspect preserved). +/// * `ffmpeg_available`— Whether ffmpeg was detected at startup. +/// +/// # Errors +/// Returns an error only if all strategies (including placeholder writing) +/// fail. Individual strategy failures are demoted to warnings so that a +/// thumbnail failure never causes the upload to fail. +pub fn generate_thumbnail( + input_path: &Path, + mime: &str, + output_path: &Path, + max_dim: u32, + ffmpeg_available: bool, +) -> Result<()> { + match mime { + // ── SVG and audio: always use static placeholder ────────────────── + "image/svg+xml" => write_placeholder(output_path, PlaceholderKind::Video), + m if m.starts_with("audio/") => write_placeholder(output_path, PlaceholderKind::Audio), + + // ── Video (WebM): requires ffmpeg ───────────────────────────────── + "video/webm" | "audio/webm" => { + if ffmpeg_available { + match ffmpeg::ffmpeg_thumbnail(input_path, output_path, max_dim) { + Ok(()) => Ok(()), + Err(e) => { + tracing::warn!("ffmpeg video thumbnail failed ({}); using placeholder", e); + write_placeholder(output_path, PlaceholderKind::Video) + } + } + } else { + write_placeholder(output_path, PlaceholderKind::Video) + } + } + + // ── Images: try ffmpeg, fall back to image crate ────────────────── + _ if mime.starts_with("image/") => { + if ffmpeg_available { + match ffmpeg::ffmpeg_thumbnail(input_path, output_path, max_dim) { + Ok(()) => Ok(()), + Err(e) => { + tracing::warn!( + "ffmpeg image thumbnail failed ({}); falling back to image crate", + e + ); + image_crate_thumbnail(input_path, mime, output_path, max_dim) + } + } + } else { + image_crate_thumbnail(input_path, mime, output_path, max_dim) + } + } + + // ── Unknown MIME: placeholder ───────────────────────────────────── + _ => write_placeholder(output_path, PlaceholderKind::Video), + } +} + +/// Determine the correct output path for a thumbnail given the media MIME type. +/// +/// Always returns a `.webp` path, except for types that produce an +/// SVG placeholder (video without ffmpeg, audio, svg source). +/// +/// # Arguments +/// * `thumb_dir` — The `thumbs/` directory (absolute path). +/// * `file_stem` — UUID stem shared with the media file. +/// * `mime` — Final MIME of the converted media file. +/// * `ffmpeg_available` — Whether ffmpeg was detected. +#[must_use] +pub fn thumbnail_output_path( + thumb_dir: &Path, + file_stem: &str, + mime: &str, + ffmpeg_available: bool, +) -> PathBuf { + let ext = thumbnail_extension(mime, ffmpeg_available); + thumb_dir.join(format!("{file_stem}.{ext}")) +} + +/// Write a static SVG placeholder for video or audio media. +/// +/// # Errors +/// Returns an error if the file cannot be written to `output_path`. +pub fn write_placeholder(output_path: &Path, kind: PlaceholderKind) -> Result<()> { + let svg = match kind { + PlaceholderKind::Video => VIDEO_PLACEHOLDER_SVG, + PlaceholderKind::Audio => AUDIO_PLACEHOLDER_SVG, + }; + std::fs::write(output_path, svg).with_context(|| { + format!( + "failed to write SVG placeholder to {}", + output_path.display() + ) + }) +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/// Generate a thumbnail using the `image` crate (no ffmpeg required). +/// +/// Decodes `input_path`, resizes to fit within `max_dim × max_dim` (aspect +/// preserved), and saves as WebP. This path is taken for image uploads when +/// ffmpeg is unavailable. +fn image_crate_thumbnail( + input_path: &Path, + mime: &str, + output_path: &Path, + max_dim: u32, +) -> Result<()> { + let format = mime_to_image_format(mime) + .ok_or_else(|| anyhow::anyhow!("unsupported image MIME for thumbnail: {mime}"))?; + + let data = std::fs::read(input_path) + .with_context(|| format!("failed to read {} for thumbnailing", input_path.display()))?; + + let img = image::load_from_memory_with_format(&data, format) + .context("failed to decode image for thumbnail")?; + + let (w, h) = img.dimensions(); + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let (tw, th) = if w > h { + let r = max_dim as f32 / w as f32; + (max_dim, (h as f32 * r) as u32) + } else { + let r = max_dim as f32 / h as f32; + ((w as f32 * r) as u32, max_dim) + }; + + let thumb = if w <= tw && h <= th { + img + } else { + img.resize(tw, th, FilterType::Triangle) + }; + + thumb + .save_with_format(output_path, ImageFormat::WebP) + .with_context(|| format!("failed to save WebP thumbnail to {}", output_path.display())) +} + +/// Map a MIME type to an `image::ImageFormat` for decoding. +/// +/// Returns `None` for types the `image` crate cannot decode (video, SVG, +/// audio). +fn mime_to_image_format(mime: &str) -> Option { + match mime { + "image/jpeg" => Some(ImageFormat::Jpeg), + "image/png" => Some(ImageFormat::Png), + "image/gif" => Some(ImageFormat::Gif), + "image/webp" => Some(ImageFormat::WebP), + "image/bmp" => Some(ImageFormat::Bmp), + "image/tiff" => Some(ImageFormat::Tiff), + _ => None, + } +} + +/// Return the file extension to use for a thumbnail. +/// +/// All thumbnails are `.webp` unless the source requires a static SVG +/// placeholder (video without ffmpeg, audio, SVG sources). +fn thumbnail_extension(mime: &str, ffmpeg_available: bool) -> &'static str { + match mime { + "image/svg+xml" => "svg", + m if m.starts_with("audio/") => "svg", + "video/webm" | "audio/webm" if !ffmpeg_available => "svg", + _ => "webp", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thumbnail_ext_is_webp_for_images_no_ffmpeg() { + // Images use image-crate fallback, so webp even without ffmpeg + assert_eq!(thumbnail_extension("image/jpeg", false), "webp"); + assert_eq!(thumbnail_extension("image/png", false), "webp"); + assert_eq!(thumbnail_extension("image/webp", false), "webp"); + } + + #[test] + fn thumbnail_ext_is_svg_for_video_without_ffmpeg() { + assert_eq!(thumbnail_extension("video/webm", false), "svg"); + } + + #[test] + fn thumbnail_ext_is_webp_for_video_with_ffmpeg() { + assert_eq!(thumbnail_extension("video/webm", true), "webp"); + } + + #[test] + fn thumbnail_ext_is_svg_for_audio() { + assert_eq!(thumbnail_extension("audio/mpeg", true), "svg"); + assert_eq!(thumbnail_extension("audio/mpeg", false), "svg"); + } + + #[test] + fn thumbnail_ext_is_svg_for_svg_source() { + assert_eq!(thumbnail_extension("image/svg+xml", true), "svg"); + assert_eq!(thumbnail_extension("image/svg+xml", false), "svg"); + } +} diff --git a/src/models.rs b/src/models.rs index 84a711b..d3debe6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -44,7 +44,9 @@ impl MediaType { #[allow(dead_code)] pub fn from_ext(ext: &str) -> Option { match ext { - "jpg" | "jpeg" | "png" | "gif" | "webp" => Some(Self::Image), + "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp" | "tiff" | "tif" | "svg" => { + Some(Self::Image) + } "mp4" | "webm" => Some(Self::Video), "mp3" | "ogg" | "flac" | "wav" | "m4a" | "aac" | "opus" => Some(Self::Audio), _ => None, diff --git a/src/utils/files.rs b/src/utils/files.rs index df3f5d0..b39fa04 100644 --- a/src/utils/files.rs +++ b/src/utils/files.rs @@ -19,17 +19,17 @@ // the audio thumbnail (see `save_audio_with_image_thumb`). // 9. Write thumbnail to thumbs/ subdirectory // +// All ffmpeg/ffprobe operations are delegated to `crate::media`. +// // Security notes: // • We NEVER trust the Content-Type header alone — we check magic bytes. // • Filenames are never used as filesystem paths — UUIDs only. // • Files are stored flat (no user-supplied path components). // • We keep the original filename only for display purposes. -// • ffmpeg is called with an explicit argument array (not via shell). -// • Audio files that fail magic-byte detection are rejected. // • delete_file validates the relative path to prevent directory traversal. +// • Audio files that fail magic-byte detection are rejected. use anyhow::{Context, Result}; -use image::{imageops::FilterType, GenericImageView, ImageFormat}; use std::path::{Path, PathBuf}; use uuid::Uuid; @@ -161,6 +161,37 @@ pub fn detect_mime_type(data: &[u8]) -> Result<&'static str> { if header.starts_with(b"GIF8") { return Ok("image/gif"); } + // ── BMP ─────────────────────────────────────────────────────────────────── + // Magic: 'BM' (0x42 0x4D). BMP uploads are immediately converted to WebP + // by the media pipeline when ffmpeg is available. + if header.starts_with(b"BM") { + return Ok("image/bmp"); + } + // ── TIFF (little-endian and big-endian) ─────────────────────────────────── + // LE: 49 49 2A 00 ("II*\0") + // BE: 4D 4D 00 2A ("MM\0*") + // TIFF uploads are converted to WebP by the media pipeline when ffmpeg is + // available. + if header.starts_with(b"\x49\x49\x2a\x00") || header.starts_with(b"\x4d\x4d\x00\x2a") { + return Ok("image/tiff"); + } + // ── SVG (text XML) ──────────────────────────────────────────────────────── + // SVG files start with either ` Result<&'static str> { } Err(anyhow::anyhow!( - "File type not allowed. Accepted: JPEG, PNG, GIF, WebP, MP4, WebM, \ - MP3, OGG, FLAC, WAV, M4A, AAC" + "File type not allowed. Accepted: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG, \ + MP4, WebM, MP3, OGG, FLAC, WAV, M4A, AAC" )) } @@ -240,9 +271,13 @@ fn check_disk_space(_dir: &Path, _needed_bytes: usize) -> Result<()> { /// under `{boards_dir}/{board_short}/thumbs/`. /// The returned paths are relative to `boards_dir` (e.g. `"b/abc123.webm"`). /// -/// When `ffmpeg_available` is true, uploaded MP4/WebM files are flagged for -/// background transcoding. Audio files receive an SVG placeholder immediately; -/// the waveform PNG is generated by the background worker. +/// When `ffmpeg_available` is true, media files are converted to optimal web +/// formats (JPEG/BMP/TIFF → WebP, GIF → WebM/VP9, PNG → WebP if smaller). +/// MP4/WebM files are flagged for background transcoding (existing pipeline). +/// +/// All thumbnails are produced as WebP. If ffmpeg is unavailable, image +/// thumbnails use the `image` crate as a fallback; video/audio thumbnails +/// fall back to static SVG placeholders. #[allow(clippy::too_many_arguments, clippy::too_many_lines)] /// # Errors /// Returns an error if the file type is unsupported, too large, disk space is @@ -291,21 +326,18 @@ pub fn save_upload( // bytes (as the previous code did) we always get orientation=1 (no rotation) // and phone photos appear sideways in thumbnails. let jpeg_orientation = if mime_type == "image/jpeg" { - read_exif_orientation(data) + crate::media::exif::read_exif_orientation(data) } else { 1 }; - // Use `Uuid::simple()` to produce a 32-char hex string directly, avoiding the - // intermediate hyphenated string and the extra allocation of `.replace('-', "")`. let file_id = Uuid::new_v4().simple().to_string(); // ── JPEG EXIF stripping ─────────────────────────────────────────────────── // Re-encoding a JPEG through the `image` crate produces a clean output with // no EXIF, IPTC, XMP, or any other metadata segment — only the pixel data - // is retained. This is the recommended approach when the crate is already - // in the dependency tree. We replace `data` with the stripped bytes so - // every downstream step (transcoding, size check, disk write) sees clean data. + // is retained. We replace `data` with the stripped bytes so every + // downstream step (MediaProcessor, size check, disk write) sees clean data. let stripped_jpeg: Option> = if mime_type == "image/jpeg" { match strip_jpeg_exif(data) { Ok(clean) => { @@ -333,111 +365,147 @@ pub fn save_upload( // background worker pool so HTTP responses return immediately. // • Video (MP4) + ffmpeg → save as-is now; worker transcodes to WebM. // • Audio + ffmpeg → use SVG placeholder now; worker adds PNG waveform. - // The handler enqueues the correct Job after db::create_post gives it post_id. + // GIF→WebM conversion is done inline by MediaProcessor (not deferred) because + // GIFs are images not videos, and the spec requires immediate conversion. let processing_pending = ffmpeg_available && matches!( media_type, - // MP4 always needs transcoding to WebM. - // WebM is flagged pending so the worker can probe the codec and - // transcode AV1 → VP9 when necessary. VP8/VP9 WebM files are - // detected and skipped cheaply inside the worker. crate::models::MediaType::Video | crate::models::MediaType::Audio ) && (media_type != crate::models::MediaType::Video || mime_type == "video/mp4" || mime_type == "video/webm"); - // Always save the original bytes — no inline transcoding. - let (final_data, final_mime): (&[u8], &'static str) = (data, mime_type); - - // File size is derived from the data we're actually saving. - let file_size = i64::try_from(final_data.len()).context("File size overflows i64")?; - - let ext = mime_to_ext(final_mime); - let filename = format!("{file_id}.{ext}"); - - // Ensure per-board directories exist: boards_dir/{board_short}/ and thumbs/ + // ── Ensure per-board directories ────────────────────────────────────────── let dest_dir = PathBuf::from(boards_dir).join(board_short); let thumbs_dir = dest_dir.join("thumbs"); std::fs::create_dir_all(&thumbs_dir).context("Failed to create board thumbs directory")?; - let file_path_abs = dest_dir.join(&filename); - - // Disk-space pre-check: verify at least 2× the file size is available - // before writing. Uses statvfs on Unix; skipped on other platforms. - check_disk_space(&dest_dir, final_data.len())?; + // Disk-space pre-check: verify at least 2× the file size is available. + check_disk_space(&dest_dir, data.len())?; - // Write via a temp file in the same directory, then atomically rename. - // This guarantees no partial/corrupt file survives a crash or OOM mid-write. - // tempfile::NamedTempFile::new_in writes to a UUID-named .tmp in the same - // directory, ensuring the rename is always on the same filesystem (POSIX atomic). - { + // ── Write upload bytes to a temp file for MediaProcessor ───────────────── + // MediaProcessor works on disk paths so ffmpeg can read/write files. + // The temp file is in the same directory so any in-place rename is atomic + // (same filesystem partition guaranteed). + let tmp_input = { use std::io::Write as _; let mut tmp = tempfile::NamedTempFile::new_in(&dest_dir) - .context("Failed to create temp file for upload")?; - tmp.write_all(final_data) - .context("Failed to write upload data to temp file")?; - tmp.persist(&file_path_abs) - .context("Failed to atomically rename upload temp file")?; + .context("Failed to create temp input file for media processing")?; + tmp.write_all(data) + .context("Failed to write upload bytes to temp file")?; + tmp.flush().context("Failed to flush temp input file")?; + tmp + }; + + // ── Run media conversion + thumbnail generation via MediaProcessor ──────── + let processor = crate::media::MediaProcessor::new_with_ffmpeg(ffmpeg_available); + + let processed = processor.process_upload( + tmp_input.path(), + mime_type, + &dest_dir, + &file_id, + &thumbs_dir, + thumb_size, + ); + + // The temp input file is no longer needed after process_upload; drop it + // (NamedTempFile auto-deletes the underlying file on drop). + drop(tmp_input); + + let processed = processed.context("Media processing pipeline failed")?; + + // ── Apply EXIF orientation to the stored image thumbnail ───────────────── + // When ffmpeg is unavailable and the image crate generated the thumbnail, + // EXIF orientation is applied by `apply_exif_orientation` below. + // When ffmpeg generated the thumbnail it reads EXIF automatically. + // If orientation != 1 and the thumbnail is WebP and ffmpeg was NOT used, + // we need to re-orient the generated thumbnail. + if jpeg_orientation > 1 + && !ffmpeg_available + && processed.thumbnail_path.exists() + && processed + .thumbnail_path + .extension() + .and_then(|e| e.to_str()) + == Some("webp") + { + apply_thumb_exif_orientation(&processed.thumbnail_path, jpeg_orientation); } - // Paths relative to boards_dir (e.g. "b/abc123.webm", "b/thumbs/abc123.jpg") + // ── Determine final MIME and media type ─────────────────────────────────── + // GIF → WebM changes the media type from Image to Video. + let final_mime: String = processed.mime_type.clone(); + let final_media_type = crate::models::MediaType::from_mime(&final_mime).unwrap_or(media_type); + + // ── File size from actual bytes on disk ─────────────────────────────────── + let file_size = i64::try_from(processed.final_size).context("File size overflows i64")?; + + // ── Build relative paths for DB storage ─────────────────────────────────── + // Paths are relative to `boards_dir`, e.g. "b/abc123.webp". + let filename = processed + .file_path + .file_name() + .and_then(|n| n.to_str()) + .context("Converted file has non-UTF-8 name")?; let rel_file = format!("{board_short}/{filename}"); - let board_dir_str = dest_dir.to_string_lossy().into_owned(); - - // Generate thumbnail / placeholder based on media type. - let thumb_rel = match media_type { - crate::models::MediaType::Video => { - // Use ffmpeg for first-frame thumbnail; SVG fallback if unavailable. - let (name, _) = generate_video_thumb( - final_data, - &board_dir_str, - board_short, - &file_id, - thumb_size, - ffmpeg_available, - ); - name - } - crate::models::MediaType::Audio => { - // When ffmpeg is available the background worker will generate a - // waveform PNG and update this post's thumb_path. For now write - // the SVG placeholder so the post is immediately renderable. - let svg_rel = format!("{board_short}/thumbs/{file_id}.svg"); - let svg_path = PathBuf::from(boards_dir).join(&svg_rel); - if let Err(e) = generate_audio_placeholder(&svg_path) { - tracing::warn!("Failed to write audio SVG placeholder: {}", e); - } - svg_rel - } - crate::models::MediaType::Image => { - let thumb_ext = match mime_type { - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - _ => "jpg", - }; - let thumb_rel_name = format!("{board_short}/thumbs/{file_id}.{thumb_ext}"); - let thumb_abs = PathBuf::from(boards_dir).join(&thumb_rel_name); - // Pass the pre-read orientation so the thumbnail reflects the - // camera's physical orientation even after EXIF has been stripped. - generate_image_thumb(data, mime_type, &thumb_abs, thumb_size, jpeg_orientation) - .context("Failed to generate image thumbnail")?; - thumb_rel_name - } + + let thumb_filename = processed + .thumbnail_path + .file_name() + .and_then(|n| n.to_str()) + .context("Thumbnail file has non-UTF-8 name")?; + let rel_thumb = format!("{board_short}/thumbs/{thumb_filename}"); + + // ── processing_pending: always false for inline-converted GIF→WebM ──────── + // The media pipeline converted GIF→WebM synchronously, so no background job + // is needed. MP4/WebM still use the existing background transcoding path. + let final_processing_pending = if processed.was_converted { + false + } else { + processing_pending }; + // ── Audio SVG placeholder path (not affected by MediaProcessor) ─────────── + // Audio files are handled as a special case: the media processor emits an + // SVG placeholder thumbnail, and the background AudioWaveform worker + // replaces it later. The rel_thumb already points to the SVG placeholder. + Ok(UploadedFile { file_path: rel_file, - thumb_path: thumb_rel, + thumb_path: rel_thumb, original_name: crate::utils::sanitize::sanitize_filename(original_filename), - mime_type: final_mime.to_string(), + mime_type: final_mime, file_size, - media_type, - processing_pending, + media_type: final_media_type, + processing_pending: final_processing_pending, }) } +// ─── EXIF orientation for image-crate thumbnails ────────────────────────────── + +/// Re-apply EXIF orientation to an already-written thumbnail using the +/// `image` crate. Called when ffmpeg was unavailable and the thumbnail was +/// produced by `image_crate_thumbnail` in `media/thumbnail.rs`. +/// +/// Silently ignores errors (thumbnail orientation is best-effort). +fn apply_thumb_exif_orientation(thumb_path: &Path, orientation: u32) { + if orientation <= 1 { + return; + } + let Ok(data) = std::fs::read(thumb_path) else { + return; + }; + let Ok(img) = image::load_from_memory_with_format(&data, image::ImageFormat::WebP) else { + return; + }; + let rotated = crate::media::exif::apply_exif_orientation(img, orientation); + if let Err(e) = rotated.save_with_format(thumb_path, image::ImageFormat::WebP) { + tracing::warn!("failed to re-orient thumbnail: {e}"); + } +} + // ─── Image+audio combo: save audio with an existing image as its thumbnail ─── /// Save an audio file to disk for an image+audio combo post. @@ -541,456 +609,16 @@ fn strip_jpeg_exif(data: &[u8]) -> Result> { Ok(cursor.into_inner()) } -// ─── Audio waveform thumbnail ───────────────────────────────────────────────── - -/// Generate a waveform PNG thumbnail for an audio file using ffmpeg's -/// `showwavespic` filter. -/// -/// The output is a `width × height` greyscale-on-dark PNG that gives the -/// post a visual identity without revealing anything about the audio content -/// beyond its amplitude envelope. -/// -/// Security: arguments are passed as an explicit array — no shell invocation. -/// -/// Temp files: the input is written via `tempfile::NamedTempFile` so it is -/// automatically deleted on drop even if the function returns early. -fn ffmpeg_audio_waveform( - audio_data: &[u8], - output_path: &Path, - width: u32, - height: u32, -) -> Result<()> { - use std::io::Write as _; - use std::process::Command; - - // Auto-cleaned temp input (deleted when `temp_in` is dropped). - let mut temp_in = tempfile::Builder::new() - .prefix("chan_aud_") - .suffix(".tmp") - .tempfile() - .context("Failed to create temp audio input file")?; - temp_in - .write_all(audio_data) - .context("Failed to write temp audio for waveform")?; - temp_in.flush().context("Failed to flush temp audio file")?; - - let temp_in_str = temp_in - .path() - .to_str() - .context("Temp audio path contains non-UTF-8 characters")?; - - // Output uses a UUID-named path; cleaned up manually after rename. - let temp_dir = std::env::temp_dir(); - let tmp_id = Uuid::new_v4().simple().to_string(); - let temp_out = temp_dir.join(format!("chan_wav_{tmp_id}.png")); - let temp_out_str = temp_out - .to_str() - .context("Temp waveform output path contains non-UTF-8 characters")?; - - // showwavespic: renders the entire file as a single static image. - // split_channels=0 → mono composite. - let vf = format!("showwavespic=s={width}x{height}:colors=#00c840|#007020:split_channels=0"); - - let output = Command::new("ffmpeg") - .args([ - "-loglevel", - "error", - "-i", - temp_in_str, - "-lavfi", - &vf, - "-frames:v", - "1", - "-y", - temp_out_str, - ]) - .output(); - - // `temp_in` (NamedTempFile) is dropped here, auto-deleting the input file. - drop(temp_in); - - let out = output.context("ffmpeg not found or failed to spawn")?; - - if out.status.success() && temp_out.exists() { - std::fs::rename(&temp_out, output_path).context("Failed to move waveform PNG")?; - Ok(()) - } else { - let _ = std::fs::remove_file(&temp_out); - Err(anyhow::anyhow!( - "ffmpeg waveform exit {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - )) - } -} - -// ─── Video transcoding ─────────────────────────────────────────────────────── - -/// Transcode any video file to `WebM` (VP9 + Opus) using ffmpeg. -/// -/// Returns the transcoded `WebM` bytes on success, or an error on failure. -/// The caller is responsible for falling back to the original data on error. -/// -/// Encoding settings: -/// VP9 — `-deadline good -cpu-used 4` for a good quality/speed balance. -/// CRF 33 with unconstrained average bitrate (`-b:v 0`) — pure CRF -/// mode is the correct libvpx-vp9 approach; combining `-b:v 0` with -/// `-maxrate` / `-bufsize` is not supported by the encoder and causes -/// exit-234 "Rate control parameters set without a bitrate". -/// `-row-mt 1` enables row-based multithreading (significant speedup -/// on multi-core hosts, no quality cost). -/// `-tile-columns 2` improves both encode parallelism and decode -/// performance on multi-core playback devices (including mobile). -/// Opus — `-b:a 96k` — transparent quality for speech and music. -/// -/// Security: all arguments passed as separate array elements — no shell. -/// -/// Temp files: the input is written via `tempfile::NamedTempFile` so it is -/// automatically deleted on drop even if the function returns early. -fn ffmpeg_transcode_webm(video_data: &[u8]) -> Result> { - use std::io::Write as _; - use std::process::Command; - - // Auto-cleaned temp input. - let mut temp_in = tempfile::Builder::new() - .prefix("chan_in_") - .suffix(".tmp") - .tempfile() - .context("Failed to create temp video input file")?; - temp_in - .write_all(video_data) - .context("Failed to write temp video for transcoding")?; - temp_in.flush().context("Failed to flush temp video file")?; - - let in_str = temp_in - .path() - .to_str() - .context("Temp input path is non-UTF-8")?; - - let temp_dir = std::env::temp_dir(); - let tmp_id = Uuid::new_v4().simple().to_string(); - let temp_out = temp_dir.join(format!("chan_out_{tmp_id}.webm")); - let out_str = temp_out.to_str().context("Temp output path is non-UTF-8")?; - - let output = Command::new("ffmpeg") - .args([ - "-loglevel", - "error", - "-i", - in_str, - "-c:v", - "libvpx-vp9", - "-crf", - "33", - "-b:v", - "0", - "-deadline", - "good", - "-cpu-used", - "4", - "-row-mt", - "1", - "-tile-columns", - "2", - "-threads", - "0", - "-c:a", - "libopus", - "-b:a", - "96k", - "-y", - out_str, - ]) - .output(); - - // Drop the NamedTempFile to auto-delete the input. - drop(temp_in); - - let out = output.context("ffmpeg not found or failed to spawn")?; - - if out.status.success() && temp_out.exists() { - let webm = std::fs::read(&temp_out).context("Failed to read transcoded WebM output")?; - let _ = std::fs::remove_file(&temp_out); - Ok(webm) - } else { - let _ = std::fs::remove_file(&temp_out); - Err(anyhow::anyhow!( - "ffmpeg transcode exit {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - )) - } -} - -// ─── Video thumbnail ────────────────────────────────────────────────────────── - -/// Try ffmpeg first-frame extraction; fall back to SVG placeholder on failure. -/// Returns (boards_dir-relative thumb path, absolute path). -fn generate_video_thumb( - video_data: &[u8], - board_dir: &str, - board_short: &str, - file_id: &str, - thumb_size: u32, - ffmpeg_available: bool, -) -> (String, PathBuf) { - // Only attempt ffmpeg if it was detected at startup. - if ffmpeg_available { - let jpg_rel = format!("{board_short}/thumbs/{file_id}.jpg"); - let jpg_path = PathBuf::from(board_dir).join(format!("thumbs/{file_id}.jpg")); - - match ffmpeg_first_frame(video_data, &jpg_path, thumb_size) { - Ok(()) => { - tracing::debug!("ffmpeg thumbnail generated for {}", file_id); - return (jpg_rel, jpg_path); - } - Err(e) => { - tracing::warn!("ffmpeg thumbnail failed ({}), using SVG placeholder", e); - } - } - } else { - tracing::debug!( - "ffmpeg not available — using video SVG placeholder for {}", - file_id - ); - } - - // Fall back to SVG placeholder - let svg_rel = format!("{board_short}/thumbs/{file_id}.svg"); - let svg_path = PathBuf::from(board_dir).join(format!("thumbs/{file_id}.svg")); - if let Err(e) = generate_video_placeholder(&svg_path) { - tracing::error!("Failed to write video SVG placeholder: {}", e); - } - (svg_rel, svg_path) -} - -/// Shell out to ffmpeg to extract the first frame as a scaled JPEG. -/// -/// Security: all arguments are passed as separate array elements — no shell -/// invocation, no injection surface. -/// -/// The video bytes are written to a temp file via `tempfile::NamedTempFile` -/// (auto-deleted on drop), ffmpeg runs on that file, the JPEG output is moved -/// to `output_path`, and the temp file is cleaned up. -fn ffmpeg_first_frame(video_data: &[u8], output_path: &Path, thumb_size: u32) -> Result<()> { - use std::io::Write as _; - use std::process::Command; - - // Auto-cleaned temp input. - let mut temp_in = tempfile::Builder::new() - .prefix("chan_vid_") - .suffix(".tmp") - .tempfile() - .context("Failed to create temp video input file")?; - temp_in - .write_all(video_data) - .context("Failed to write temp video for ffmpeg")?; - temp_in.flush().context("Failed to flush temp video file")?; - - let temp_in_str = temp_in - .path() - .to_str() - .context("Temp video path contains non-UTF-8 characters")?; - - let temp_dir = std::env::temp_dir(); - let tmp_id = Uuid::new_v4().simple().to_string(); - let temp_out = temp_dir.join(format!("chan_thm_{tmp_id}.jpg")); - let temp_out_str = temp_out - .to_str() - .context("Temp output path contains non-UTF-8 characters")?; - - // scale=W:-2 : scale width to thumb_size, height to nearest even number - let vf = format!("scale={thumb_size}:-2"); - - // No shell invocation — explicit argument array only. - let output = Command::new("ffmpeg") - .args([ - "-loglevel", - "error", - "-i", - temp_in_str, - "-vframes", - "1", - "-ss", - "0", - "-vf", - &vf, - "-y", - temp_out_str, - ]) - .output(); - - // Drop NamedTempFile — auto-deletes the temp input regardless of outcome. - drop(temp_in); - - let out = output.context("ffmpeg not found or failed to spawn")?; - - if out.status.success() && temp_out.exists() { - std::fs::rename(&temp_out, output_path).context("Failed to move ffmpeg output")?; - Ok(()) - } else { - let _ = std::fs::remove_file(&temp_out); - Err(anyhow::anyhow!( - "ffmpeg exit {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - )) - } -} - -/// Minimal SVG play-button fallback (used when ffmpeg is unavailable). -fn generate_video_placeholder(output_path: &Path) -> Result<()> { - let svg = r##" - - - - VIDEO -"##; - std::fs::write(output_path, svg)?; - Ok(()) -} - -// ─── Audio placeholder ──────────────────────────────────────────────────────── - -/// SVG music-note placeholder written for every audio upload. -/// No real thumbnail is generated for audio files. -fn generate_audio_placeholder(output_path: &Path) -> Result<()> { - let svg = r##" - - - - AUDIO -"##; - std::fs::write(output_path, svg)?; - Ok(()) -} - -// ─── Image thumbnail ────────────────────────────────────────────────────────── - -/// Read the EXIF Orientation tag from JPEG bytes. -/// -/// Returns the orientation value (1–8) or 1 (no rotation) if the tag is -/// absent or unreadable. Only JPEG files carry reliable EXIF orientation; -/// PNG/WebP/GIF do not use this tag. -/// -/// Values follow the EXIF spec: -/// 1 = normal (0°), 2 = flip-H, 3 = 180°, 4 = flip-V, -/// 5 = transpose, 6 = 90° CW, 7 = transverse, 8 = 90° CCW -/// -/// NOTE: This must be called on the ORIGINAL bytes before any EXIF-stripping -/// re-encode. Once EXIF has been stripped, this function will always return 1. -fn read_exif_orientation(data: &[u8]) -> u32 { - use std::io::Cursor; - let Ok(exif) = exif::Reader::new().read_from_container(&mut Cursor::new(data)) else { - return 1; - }; - exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) - .and_then(|f| { - if let exif::Value::Short(ref v) = f.value { - v.first().copied().map(u32::from) - } else { - None - } - }) - .unwrap_or(1) -} - -/// Apply an EXIF orientation transformation to a decoded `DynamicImage`. -/// -/// This corrects the pixel layout so that thumbnails appear upright regardless -/// of which way the camera was held when the photo was taken. The `image` -/// crate operations used here are pure in-memory pixel transforms — no I/O. -fn apply_exif_orientation(img: image::DynamicImage, orientation: u32) -> image::DynamicImage { - use image::imageops; - match orientation { - 2 => image::DynamicImage::ImageRgba8(imageops::flip_horizontal(&img)), - 3 => image::DynamicImage::ImageRgba8(imageops::rotate180(&img)), - 4 => image::DynamicImage::ImageRgba8(imageops::flip_vertical(&img)), - 5 => { - // Transpose = rotate 90° CW then flip horizontally - let rot = imageops::rotate90(&img); - image::DynamicImage::ImageRgba8(imageops::flip_horizontal(&rot)) - } - 6 => image::DynamicImage::ImageRgba8(imageops::rotate90(&img)), - 7 => { - // Transverse = rotate 90° CW then flip vertically - let rot = imageops::rotate90(&img); - image::DynamicImage::ImageRgba8(imageops::flip_vertical(&rot)) - } - 8 => image::DynamicImage::ImageRgba8(imageops::rotate270(&img)), - _ => img, // 1 = normal, or unknown value — no transform - } -} +// ─── Helpers ────────────────────────────────────────────────────────────────── -/// Generate a scaled thumbnail for an image file. +/// Map a MIME type string to the canonical file extension used on disk. /// -/// `exif_orientation` must be pre-read from the ORIGINAL (pre-strip) bytes so -/// that JPEG thumbnails are oriented correctly even after EXIF has been removed -/// from the stored file. Pass `1` for non-JPEG formats. -fn generate_image_thumb( - data: &[u8], - mime_type: &str, - output_path: &Path, - max_dim: u32, - exif_orientation: u32, -) -> Result<()> { - let format = match mime_type { - "image/jpeg" => ImageFormat::Jpeg, - "image/png" => ImageFormat::Png, - "image/gif" => ImageFormat::Gif, // NOTE: first frame only for animated GIFs - "image/webp" => ImageFormat::WebP, - _ => return Err(anyhow::anyhow!("Unsupported image format")), - }; - - let img = - image::load_from_memory_with_format(data, format).context("Failed to decode image")?; - - // Apply EXIF orientation using the value read from the original bytes before - // EXIF stripping. Only JPEG carries reliable orientation data; for other - // formats exif_orientation will always be 1 (no-op). - let img = if exif_orientation > 1 { - tracing::debug!("EXIF orientation {} applied to thumbnail", exif_orientation); - apply_exif_orientation(img, exif_orientation) - } else { - img - }; - - let (w, h) = img.dimensions(); - #[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss - )] - let (tw, th) = if w > h { - let r = max_dim as f32 / w as f32; - (max_dim, (h as f32 * r) as u32) - } else { - let r = max_dim as f32 / h as f32; - ((w as f32 * r) as u32, max_dim) - }; - - let thumb = if w <= tw && h <= th { - img - } else { - img.resize(tw, th, FilterType::Triangle) - }; - - let save_format = match mime_type { - "image/png" => ImageFormat::Png, - "image/gif" => ImageFormat::Gif, - "image/webp" => ImageFormat::WebP, - _ => ImageFormat::Jpeg, - }; - - thumb - .save_with_format(output_path, save_format) - .context("Failed to save thumbnail")?; - - Ok(()) +/// Public wrapper used by `crate::media::convert` when writing fallback files. +#[must_use] +pub fn mime_to_ext_pub(mime: &str) -> &'static str { + mime_to_ext(mime) } -// ─── Helpers ────────────────────────────────────────────────────────────────── - /// Map a MIME type string to the canonical file extension used on disk. fn mime_to_ext(mime: &str) -> &'static str { match mime { @@ -998,6 +626,9 @@ fn mime_to_ext(mime: &str) -> &'static str { "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp", + "image/bmp" => "bmp", + "image/tiff" => "tiff", + "image/svg+xml" => "svg", "video/mp4" => "mp4", "video/webm" | "audio/webm" => "webm", // Audio formats @@ -1011,66 +642,6 @@ fn mime_to_ext(mime: &str) -> &'static str { } } -// ─── Video codec probing ────────────────────────────────────────────────────── - -/// Use ffprobe to determine the video codec of the first video stream in a -/// file. Returns the codec name in lower-case (e.g. `"av1"`, `"vp9"`, -/// `"vp8"`, `"h264"`). -/// -/// Security: arguments are passed as a separate array — no shell invocation. -/// `file_path` must be a path we control (UUID-based); it is never derived -/// from user input. -fn ffprobe_video_codec(file_path: &str) -> Result { - use std::process::Command; - - let out = Command::new("ffprobe") - .args([ - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=codec_name", - "-of", - "csv=p=0", - file_path, - ]) - .output() - .context("ffprobe not found or failed to spawn")?; - - if !out.status.success() { - return Err(anyhow::anyhow!( - "ffprobe exit {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - )); - } - - let codec = String::from_utf8_lossy(&out.stdout) - .trim() - .to_ascii_lowercase(); - - if codec.is_empty() { - return Err(anyhow::anyhow!( - "ffprobe returned no codec for: {file_path}" - )); - } - - Ok(codec) -} - -/// Probe the video codec of a file on disk. -/// -/// Returns the codec name in lower-case (e.g. `"av1"`, `"vp9"`, `"vp8"`). -/// Called by the `VideoTranscode` background worker to decide whether a `WebM` -/// upload needs AV1 → VP9 re-encoding. -/// -/// # Errors -/// Returns an error if ffprobe cannot be run or returns no codec. -pub fn probe_video_codec(file_path: &str) -> anyhow::Result { - ffprobe_video_codec(file_path) -} - /// Delete a file from the filesystem, ignoring not-found errors. /// /// # Security @@ -1120,35 +691,13 @@ pub fn format_file_size(bytes: i64) -> String { } } -// ─── Public wrappers used by background workers ─────────────────────────────── - -/// Transcode any video (typically MP4) to `WebM` (VP9 + Opus) via ffmpeg. -/// Called by the `VideoTranscode` background worker. -/// -/// # Errors -/// Returns an error if ffmpeg cannot be run or transcoding fails. -pub fn transcode_to_webm(data: &[u8]) -> anyhow::Result> { - ffmpeg_transcode_webm(data) -} - -/// Generate a waveform PNG for an audio file via ffmpeg. -/// Called by the `AudioWaveform` background worker. -/// -/// # Errors -/// Returns an error if ffmpeg cannot be run or waveform generation fails. -pub fn gen_waveform_png( - data: &[u8], - output_path: &Path, - width: u32, - height: u32, -) -> anyhow::Result<()> { - ffmpeg_audio_waveform(data, output_path, width, height) -} - #[cfg(test)] mod tests { #![allow(clippy::expect_used)] use super::*; + // GenericImageView trait is needed for .width() / .height() on DynamicImage. + #[allow(unused_imports)] + use image::GenericImageView as _; // ── format_file_size ───────────────────────────────────────────────────── @@ -1322,7 +871,7 @@ mod tests { #[test] fn exif_orientation_1_is_noop() { let img = image::DynamicImage::new_rgba8(4, 6); - let out = apply_exif_orientation(img, 1); + let out = crate::media::exif::apply_exif_orientation(img, 1); assert_eq!(out.width(), 4); assert_eq!(out.height(), 6); } @@ -1331,7 +880,7 @@ mod tests { fn exif_orientation_3_rotates_180() { // 180° rotation keeps dimensions unchanged let img = image::DynamicImage::new_rgba8(4, 6); - let out = apply_exif_orientation(img, 3); + let out = crate::media::exif::apply_exif_orientation(img, 3); assert_eq!(out.width(), 4); assert_eq!(out.height(), 6); } @@ -1339,7 +888,7 @@ mod tests { #[test] fn exif_orientation_6_rotates_90cw_swaps_dims() { let img = image::DynamicImage::new_rgba8(4, 6); - let out = apply_exif_orientation(img, 6); + let out = crate::media::exif::apply_exif_orientation(img, 6); assert_eq!(out.width(), 6); assert_eq!(out.height(), 4); } @@ -1347,7 +896,7 @@ mod tests { #[test] fn exif_orientation_8_rotates_90ccw_swaps_dims() { let img = image::DynamicImage::new_rgba8(4, 6); - let out = apply_exif_orientation(img, 8); + let out = crate::media::exif::apply_exif_orientation(img, 8); assert_eq!(out.width(), 6); assert_eq!(out.height(), 4); } @@ -1355,8 +904,53 @@ mod tests { #[test] fn exif_orientation_unknown_value_is_noop() { let img = image::DynamicImage::new_rgba8(4, 6); - let out = apply_exif_orientation(img, 99); + let out = crate::media::exif::apply_exif_orientation(img, 99); assert_eq!(out.width(), 4); assert_eq!(out.height(), 6); } + + // ── New format detection: BMP, TIFF, SVG ───────────────────────────────── + + #[test] + fn detect_bmp() { + // BMP magic: 'BM' (0x42 0x4D) + let header = b"BM\x36\x00\x00\x00\x00\x00rest"; + assert_eq!(detect_mime_type(header).expect("bmp"), "image/bmp"); + } + + #[test] + fn detect_tiff_little_endian() { + // TIFF LE magic: 49 49 2A 00 + let header = b"\x49\x49\x2a\x00rest"; + assert_eq!(detect_mime_type(header).expect("tiff le"), "image/tiff"); + } + + #[test] + fn detect_tiff_big_endian() { + // TIFF BE magic: 4D 4D 00 2A + let header = b"\x4d\x4d\x00\x2arest"; + assert_eq!(detect_mime_type(header).expect("tiff be"), "image/tiff"); + } + + #[test] + fn detect_svg_direct() { + let data = b""; + assert_eq!(detect_mime_type(data).expect("svg"), "image/svg+xml"); + } + + #[test] + fn detect_svg_xml_declaration() { + let data = b""; + assert_eq!( + detect_mime_type(data).expect("svg xml decl"), + "image/svg+xml" + ); + } + + #[test] + fn mime_to_ext_new_types() { + assert_eq!(mime_to_ext("image/bmp"), "bmp"); + assert_eq!(mime_to_ext("image/tiff"), "tiff"); + assert_eq!(mime_to_ext("image/svg+xml"), "svg"); + } } diff --git a/src/workers/mod.rs b/src/workers/mod.rs index 4c264d1..f5a3c83 100644 --- a/src/workers/mod.rs +++ b/src/workers/mod.rs @@ -461,6 +461,8 @@ fn transcode_video_inner( board_short: &str, pool: &DbPool, ) -> Result<()> { + use anyhow::Context as _; + let upload_dir = &CONFIG.upload_dir; let src = PathBuf::from(upload_dir).join(file_path); @@ -489,7 +491,7 @@ fn transcode_video_inner( .to_str() .ok_or_else(|| anyhow::anyhow!("Source path is non-UTF-8: {}", src.display()))?; - match crate::utils::files::probe_video_codec(src_str) { + match crate::media::ffmpeg::probe_video_codec(src_str) { Ok(ref codec) if codec == "av1" => { info!("VideoTranscode: WebM/AV1 detected for post {post_id} — re-encoding to VP9"); } @@ -521,39 +523,32 @@ fn transcode_video_inner( post_id, file_path ); - let data = std::fs::read(&src)?; - let webm_bytes = crate::utils::files::transcode_to_webm(&data)?; - let board_dir = PathBuf::from(upload_dir).join(board_short); let webm_name = format!("{stem}.webm"); let webm_abs = board_dir.join(&webm_name); let webm_rel = format!("{board_short}/{webm_name}"); // FIX[ATOMIC-WRITE]: For AV1 WebM inputs, src and webm_abs resolve to the - // same path (same stem, same .webm extension). A direct fs::write would - // overwrite the source in-place; a crash or disk-full mid-write permanently - // corrupts the only copy of the file with no recovery path. - // - // We write to a uniquely named temp file in the same directory first, then - // atomically rename it into place. The rename is POSIX-atomic on the same - // filesystem, so readers always see either the old file or the new file — - // never a partial write. If anything fails before the rename, the source - // file is untouched and the job can be retried. - { - use std::io::Write as _; - let mut tmp = tempfile::NamedTempFile::new_in(&board_dir) - .map_err(|e| anyhow::anyhow!("Failed to create temp file for WebM output: {e}"))?; - tmp.write_all(&webm_bytes) - .map_err(|e| anyhow::anyhow!("Failed to write WebM transcode output: {e}"))?; - tmp.persist(&webm_abs) - .map_err(|e| anyhow::anyhow!("Failed to atomically rename WebM output: {e}"))?; - } + // same path (same stem, same .webm extension). Write to a temp file in the + // same directory first, then atomically rename into place. The rename is + // POSIX-atomic on the same filesystem, so readers always see either the old + // file or the new file — never a partial write. + let tmp = tempfile::NamedTempFile::new_in(&board_dir) + .map_err(|e| anyhow::anyhow!("Failed to create temp file for WebM output: {e}"))?; + + crate::media::ffmpeg::ffmpeg_transcode_to_webm(&src, tmp.path())?; + + tmp.persist(&webm_abs) + .map_err(|e| anyhow::anyhow!("Failed to atomically rename WebM output: {e}"))?; + + // Read the transcoded file for SHA-256 dedup and size logging. + let webm_bytes = + std::fs::read(&webm_abs).context("Failed to read transcoded WebM for dedup hash")?; let conn = pool.get()?; // FIX[LEAK]: If any DB call below fails we must clean up the WebM we just - // wrote, otherwise it leaks on disk across all retry attempts. We record - // the path and remove it in the error branch via a guard closure. + // wrote, otherwise it leaks on disk across all retry attempts. let db_result = (|| -> Result<()> { let updated = crate::db::update_all_posts_file_path(&conn, file_path, &webm_rel, "video/webm")?; @@ -631,6 +626,8 @@ fn generate_waveform_inner( board_short: &str, pool: &DbPool, ) -> Result<()> { + use anyhow::Context as _; + let upload_dir = &CONFIG.upload_dir; let src = PathBuf::from(upload_dir).join(file_path); @@ -647,6 +644,8 @@ fn generate_waveform_inner( .ok_or_else(|| anyhow::anyhow!("Malformed audio filename: {}", src.display()))? .to_string(); + // Read the audio file for SHA-256 dedup; the path is passed directly to + // ffmpeg so no redundant write-to-temp-and-read-back is needed. let data = std::fs::read(&src)?; let thumb_size = CONFIG.thumb_size; @@ -658,7 +657,19 @@ fn generate_waveform_inner( let png_abs = thumbs_dir.join(&png_name); let png_rel = format!("{board_short}/thumbs/{png_name}"); - crate::utils::files::gen_waveform_png(&data, &png_abs, thumb_size, thumb_size / 2)?; + // Write the waveform to a temp file in the same directory, then atomically + // rename it into place to avoid partial reads from concurrent web requests. + let tmp_png = tempfile::Builder::new() + .prefix("chan_wav_") + .suffix(".png") + .tempfile_in(&thumbs_dir) + .context("Failed to create temp file for waveform PNG")?; + + crate::media::ffmpeg::ffmpeg_audio_waveform(&src, tmp_png.path(), thumb_size, thumb_size / 2)?; + + tmp_png + .persist(&png_abs) + .context("Failed to atomically rename waveform PNG into place")?; let conn = pool.get()?; crate::db::update_post_thumb_path(&conn, post_id, &png_rel)?; @@ -670,10 +681,8 @@ fn generate_waveform_inner( // of the waveform PNG. We now update the dedup record so that all future // dedup hits for this audio file correctly inherit the waveform thumbnail. // - // We compute the SHA-256 of the audio data we already have in memory to - // identify the dedup row without a separate DB lookup, then refresh its - // thumb_path via a targeted UPDATE. An audio file may have been uploaded - // on a different board, so we match by file content (sha256) not by path. + // We compute the SHA-256 of the audio data to identify the dedup row without + // a separate DB lookup, then refresh its thumb_path via a targeted UPDATE. let audio_sha256 = crate::utils::crypto::sha256_hex(&data); let _ = conn.execute( "UPDATE file_hashes SET thumb_path = ?1 WHERE sha256 = ?2", diff --git a/static/main.js b/static/main.js index fed3eae..126104f 100644 --- a/static/main.js +++ b/static/main.js @@ -93,7 +93,8 @@ function collapseMedia(btn) { function expandVideoEmbed(preview, type, id, container) { var iframe = document.createElement('iframe'); if (type === 'youtube') { - iframe.src = 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0'; + iframe.src = 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0&playsinline=1'; + iframe.setAttribute('title', 'YouTube video player'); } else if (type === 'streamable') { iframe.src = 'https://streamable.com/e/' + id + '?autoplay=1'; } From 876408c518895a53a3e24f91ea831695e20a2c94 Mon Sep 17 00:00:00 2001 From: csd113 Date: Thu, 12 Mar 2026 12:09:30 -0700 Subject: [PATCH 6/8] misc fixes to upload and convet to webp system --- Cargo.lock | 12 --- Cargo.toml | 10 ++- deny.toml | 20 +++++ src/db/mod.rs | 14 ++- src/db/posts.rs | 9 +- src/detect.rs | 132 ++++++++++++++++++++++++++++ src/handlers/board.rs | 55 +++++++++++- src/handlers/mod.rs | 52 ++++++++--- src/handlers/thread.rs | 20 +++-- src/main.rs | 11 ++- src/media/convert.rs | 93 ++++++++++---------- src/media/ffmpeg.rs | 185 +++++++++++++++++++++++++++------------- src/media/mod.rs | 67 +++++++++++---- src/media/thumbnail.rs | 144 ++++++++++++++++++++++--------- src/middleware/mod.rs | 3 + src/templates/thread.rs | 8 +- src/utils/files.rs | 13 ++- src/workers/mod.rs | 34 +++++++- 18 files changed, 666 insertions(+), 216 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 484a3ba..ad30735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,17 +121,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -1393,7 +1382,6 @@ version = "1.1.0" dependencies = [ "anyhow", "argon2", - "async-trait", "axum", "axum-extra", "chrono", diff --git a/Cargo.toml b/Cargo.toml index bf6210e..62c09b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,11 +2,19 @@ name = "rustchan" version = "1.1.0" edition = "2021" +# axum 0.8 requires Rust 1.75; that is the effective floor for this project. +rust-version = "1.88.0" license = "MIT" # FIX[LOW-1]: Removed hardware-specific description. This binary is portable # and targets any architecture/OS that Rust supports. description = "Self-contained imageboard — single binary, zero runtime dependencies" +# cargo-msrv <0.16 reads package.metadata.msrv instead of the Cargo-standard +# rust-version field; keep both so `cargo msrv verify` works regardless of +# which version of cargo-msrv is installed. +[package.metadata] +msrv = "1.75" + [profile.release] opt-level = 3 lto = "thin" @@ -23,7 +31,6 @@ name = "chan" path = "src/lib.rs" [dependencies] -async-trait = "0.1" axum = { version = "0.8", features = ["multipart"] } axum-extra = { version = "0.12", features = ["cookie"] } tower = "0.5" @@ -46,7 +53,6 @@ sha2 = "0.10" hex = "0.4" # rand_core 0.6 replaces rand = "0.8" entirely. # We only need OsRng + RngCore, both of which live in rand_core. -# rand 0.9/0.10 bumps rand_core to an incompatible version (0.9) vs the # rand_core 0.6 that argon2 0.5 / password-hash 0.5 depends on, causing # OsRng type mismatches at compile time. Using rand_core 0.6 directly # shares the exact same crate instance as argon2's transitive dep. diff --git a/deny.toml b/deny.toml index 2cf30f5..ed610e5 100644 --- a/deny.toml +++ b/deny.toml @@ -139,6 +139,26 @@ version = "0.60" name = "windows-sys" version = "0.61" +# image directly uses zune-jpeg 0.5.x + zune-core 0.5.x, while its +# tiff sub-dependency requires zune-jpeg 0.4.x + zune-core 0.4.x. +# These are distinct semver ranges so Cargo cannot unify them without +# an upstream tiff release; skipping both versions here. +[[bans.skip]] +name = "zune-core" +version = "0.4" + +[[bans.skip]] +name = "zune-core" +version = "0.5" + +[[bans.skip]] +name = "zune-jpeg" +version = "0.4" + +[[bans.skip]] +name = "zune-jpeg" +version = "0.5" + # --------------------------------------------------------------------------- # Crate sources # --------------------------------------------------------------------------- diff --git a/src/db/mod.rs b/src/db/mod.rs index a806dcd..4787c05 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -613,10 +613,16 @@ pub fn paths_safe_to_delete(conn: &rusqlite::Connection, candidates: Vec ) .ok(); - if let Some((fp, tp)) = maybe_row { - // Only delete the hash entry if both paths are in the safe set — - // i.e. neither is referenced by any remaining post. - if safe_set.contains(fp.as_str()) && safe_set.contains(tp.as_str()) { + if let Some((fp, _tp)) = maybe_row { + // Delete the hash entry when file_path is unreferenced. We only + // check file_path (not thumb_path) because: + // 1. file_path is the dedup key — once no post holds this path, + // the entry can never match a future upload and is dead weight. + // 2. A post's thumb_path may be NULL (e.g. audio files whose + // thumbnail was never persisted), so thumb_path is absent from + // `candidates` / `safe_set` and the old two-sided check would + // spuriously skip deletion, leaving orphaned rows forever. + if safe_set.contains(fp.as_str()) { let _ = conn.execute("DELETE FROM file_hashes WHERE file_path = ?1", params![fp]); } } diff --git a/src/db/posts.rs b/src/db/posts.rs index 97975c1..5d33204 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -529,6 +529,13 @@ pub fn find_file_by_hash( /// Record a newly saved upload in the deduplication table. /// +/// Uses INSERT OR REPLACE so that if the same SHA-256 was previously stored +/// with an unconverted format (e.g. image/jpeg stored before WebP conversion +/// was enabled), re-uploading the same bytes will update the cache to point +/// at the converted file and mime type. Without OR REPLACE, the stale +/// cache entry would be returned on every subsequent upload of that image, +/// silently skipping conversion forever. +/// /// # Errors /// Returns an error if the database operation fails. pub fn record_file_hash( @@ -539,7 +546,7 @@ pub fn record_file_hash( mime_type: &str, ) -> Result<()> { conn.execute( - "INSERT OR IGNORE INTO file_hashes (sha256, file_path, thumb_path, mime_type) + "INSERT OR REPLACE INTO file_hashes (sha256, file_path, thumb_path, mime_type) VALUES (?1, ?2, ?3, ?4)", params![sha256, file_path, thumb_path, mime_type], )?; diff --git a/src/detect.rs b/src/detect.rs index 80ba174..f7b3c73 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -92,6 +92,138 @@ pub fn detect_ffmpeg(require_ffmpeg: bool) -> ToolStatus { } } +/// Probe whether the detected ffmpeg binary has `libwebp` compiled in, and +/// print actionable install instructions if it does not. +/// +/// Called immediately after `detect_ffmpeg` at server startup. Returns +/// `false` silently when `ffmpeg_ok` is `false` (no point checking encoders +/// when ffmpeg itself is absent). +pub fn detect_webp_encoder(ffmpeg_ok: bool) -> bool { + if !ffmpeg_ok { + return false; + } + + let has_webp = crate::media::ffmpeg::check_webp_encoder(); + + if has_webp { + println!("[INFO] ffmpeg libwebp encoder detected. Image→WebP conversion enabled."); + } else { + println!(); + println!("[WARN] ffmpeg is installed but the libwebp encoder is missing."); + println!(" JPEG / PNG / BMP / TIFF uploads will be stored in their"); + println!(" original format instead of being converted to WebP."); + println!(); + + // Detect platform and print the appropriate reinstall instructions. + #[cfg(target_os = "macos")] + { + println!(" ── macOS — reinstall ffmpeg with libwebp support ──────────────────"); + println!(" brew uninstall ffmpeg"); + println!(" brew tap homebrew-ffmpeg/ffmpeg"); + println!(" brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-webp"); + println!(); + println!(" Verify with:"); + println!(" ffmpeg -encoders 2>/dev/null | grep webp"); + println!(" You should see:"); + println!(" V..... libwebp libwebp WebP image (codec webp)"); + println!(" V..... libwebp_anim libwebp WebP image (codec webp)"); + } + + #[cfg(target_os = "linux")] + { + println!(" ── Linux — install ffmpeg with libwebp support ────────────────────"); + println!(" sudo apt update"); + println!(" sudo apt install ffmpeg libwebp-dev"); + println!(); + println!(" Verify with:"); + println!(" ffmpeg -encoders 2>/dev/null | grep webp"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + println!(" Reinstall ffmpeg with libwebp encoder support enabled."); + println!(" See: https://ffmpeg.org/download.html"); + } + + println!(); + } + + has_webp +} + +/// Probe whether the detected ffmpeg binary has both `libvpx-vp9` (video) and +/// `libopus` (audio) compiled in, and print actionable install instructions if +/// either is missing. +/// +/// Both encoders are required for MP4→WebM transcoding and WebM/AV1→WebM/VP9 +/// re-encoding. Called immediately after `detect_webp_encoder` at server +/// startup. Returns `false` silently when `ffmpeg_ok` is `false`. +pub fn detect_webm_encoder(ffmpeg_ok: bool) -> bool { + if !ffmpeg_ok { + return false; + } + + let has_vp9 = crate::media::ffmpeg::check_vp9_encoder(); + let has_opus = crate::media::ffmpeg::check_opus_encoder(); + let has_webm = has_vp9 && has_opus; + + if has_webm { + println!("[INFO] ffmpeg libvpx-vp9 + libopus encoders detected. MP4→WebM transcoding and WebM/AV1→VP9 re-encoding enabled."); + } else { + println!(); + println!("[WARN] ffmpeg is installed but required WebM encoders are missing."); + + if !has_vp9 { + println!(" Missing: libvpx-vp9 (VP9 video encoder)"); + } + if !has_opus { + println!(" Missing: libopus (Opus audio encoder)"); + } + + println!(); + println!(" MP4 uploads will be stored as MP4 instead of being"); + println!(" transcoded to WebM. WebM files encoded with AV1 will"); + println!(" not be re-encoded to the browser-compatible VP9 format."); + println!(); + + #[cfg(target_os = "macos")] + { + println!(" ── macOS — reinstall ffmpeg with VP9 + Opus support ───────────────"); + println!(" brew uninstall ffmpeg"); + println!(" brew install ffmpeg"); + println!(" # Or for a fully-featured build:"); + println!(" brew tap homebrew-ffmpeg/ffmpeg"); + println!(" brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-libvpx --with-opus"); + println!(); + println!(" Verify with:"); + println!(" ffmpeg -encoders 2>/dev/null | grep -E 'libvpx-vp9|libopus'"); + println!(" You should see:"); + println!(" V..... libvpx-vp9 libvpx VP9 (codec vp9)"); + println!(" A..... libopus libopus Opus (codec opus)"); + } + + #[cfg(target_os = "linux")] + { + println!(" ── Linux — install ffmpeg with VP9 + Opus support ─────────────────"); + println!(" sudo apt update"); + println!(" sudo apt install ffmpeg libvpx-dev libopus-dev"); + println!(); + println!(" Verify with:"); + println!(" ffmpeg -encoders 2>/dev/null | grep -E 'libvpx-vp9|libopus'"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + println!(" Reinstall ffmpeg with libvpx-vp9 and libopus encoder support enabled."); + println!(" See: https://ffmpeg.org/download.html"); + } + + println!(); + } + + has_webm +} + // ─── Tor ───────────────────────────────────────────────────────────────────── /// Set up and launch a Tor hidden-service instance. diff --git a/src/handlers/board.rs b/src/handlers/board.rs index 34fd012..f526041 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -126,7 +126,11 @@ pub async fn board_index( // combined with the page number. This is a cheap proxy for "has // anything on this page changed?". let max_bump = threads.iter().map(|t| t.bumped_at).max().unwrap_or(0); - let etag = format!("\"{max_bump}-{page}\""); + // Include is_admin in the ETag so admin and non-admin responses + // have distinct cache keys and the browser doesn't serve a cached + // non-admin page (missing delete controls) to a logged-in admin. + let admin_tag = if is_admin { "-a" } else { "" }; + let etag = format!("\"{max_bump}-{page}{admin_tag}\""); let mut summaries = Vec::with_capacity(threads.len()); for thread in threads { @@ -210,6 +214,7 @@ pub async fn create_thread( let max_video_size = CONFIG.max_video_size; let max_audio_size = CONFIG.max_audio_size; let ffmpeg_available = state.ffmpeg_available; + let ffmpeg_webp_available = state.ffmpeg_webp_available; let cookie_secret = CONFIG.cookie_secret.clone(); let file_data = form.file; let audio_file_data = form.audio_file; @@ -312,6 +317,7 @@ pub async fn create_thread( max_video_size, max_audio_size, ffmpeg_available, + ffmpeg_webp_available, )?; // ── Image+audio combo ───────────────────────────────────────────── @@ -675,7 +681,38 @@ pub async fn file_report( // ─── GET /boards/{*media_path} — serve media with mp4→webm redirect ────────── // + +// ─── Content-Type helper for board media ───────────────────────────────────── + +/// Return the correct `Content-Type` value for a board media file based solely +/// on its extension. Used to override whatever `mime_guess` / `ServeFile` +/// produces, because some builds of `mime_guess` do not include `.webp`, +/// `.svg`, or audio formats in their database and fall back to +/// `application/octet-stream`, which causes browsers to download the file +/// rather than display or play it inline. +fn media_content_type(path: &std::path::Path) -> Option<&'static str> { + match path.extension().and_then(|e| e.to_str()) { + Some("webp") => Some("image/webp"), + Some("jpg" | "jpeg") => Some("image/jpeg"), + Some("png") => Some("image/png"), + Some("gif") => Some("image/gif"), + Some("bmp") => Some("image/bmp"), + Some("tiff" | "tif") => Some("image/tiff"), + Some("svg") => Some("image/svg+xml"), + Some("webm") => Some("video/webm"), + Some("mp4") => Some("video/mp4"), + Some("mp3") => Some("audio/mpeg"), + Some("ogg") => Some("audio/ogg"), + Some("flac") => Some("audio/flac"), + Some("wav") => Some("audio/wav"), + Some("m4a") => Some("audio/mp4"), + Some("aac") => Some("audio/aac"), + _ => None, + } +} + // Replaces the former nest_service(ServeDir) so we can intercept stale .mp4 + // links (created before the background transcoder replaced them with .webm) // and issue a permanent redirect. All other paths are served via ServeFile. @@ -712,7 +749,21 @@ pub async fn serve_board_media( let req = req.map(|_| axum::body::Body::empty()); ServeFile::new(&target).oneshot(req).await.map_or_else( |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), - |resp| resp.map(axum::body::Body::new).into_response(), + |resp| { + use axum::http::header::{HeaderValue, CONTENT_TYPE}; + let mut resp = resp.map(axum::body::Body::new); + // Override Content-Type using our own extension→MIME map. + // tower-http delegates to `mime_guess` which may return + // `application/octet-stream` for formats like `.webp` or `.svg` + // on some builds, causing browsers to download the file instead + // of displaying it inline. An explicit map guarantees the + // correct type for every format we store. + if let Some(ct) = media_content_type(&target) { + resp.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(ct)); + } + resp.into_response() + }, ) } else if std::path::Path::new(&media_path) .extension() diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 13f4640..97e447e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -262,6 +262,7 @@ pub fn process_primary_upload( max_video_size: usize, max_audio_size: usize, ffmpeg_available: bool, + ffmpeg_webp_available: bool, ) -> Result> { let Some((data, fname)) = file_data else { return Ok(None); @@ -293,19 +294,47 @@ pub fn process_primary_upload( } // SHA-256 deduplication — serve the cached entry without re-saving. + // + // FIX[BUG]: Validate that both the cached file and thumbnail still exist + // on disk before returning the dedup hit. When a thread or board is + // deleted its files are removed from disk, but the file_hashes table is + // not pruned. Without this check, re-uploading the same image after its + // original thread/board was deleted would return stale paths pointing at + // deleted files, so the post would display no image and no thumbnail. + // + // If either path is missing we fall through to re-process the upload. + // record_file_hash uses INSERT OR REPLACE, so the cache entry is + // automatically refreshed to point at the newly saved files. let hash = crate::utils::crypto::sha256_hex(&data); if let Some(cached) = crate::db::find_file_by_hash(conn, &hash)? { - let cached_media = crate::models::MediaType::from_mime(&cached.mime_type) - .unwrap_or(crate::models::MediaType::Image); - return Ok(Some(crate::utils::files::UploadedFile { - file_path: cached.file_path, - thumb_path: cached.thumb_path, - original_name: crate::utils::sanitize::sanitize_filename(&fname), - mime_type: cached.mime_type, - file_size: i64::try_from(data.len()).unwrap_or(0), - media_type: cached_media, - processing_pending: false, - })); + let file_ok = std::path::Path::new(upload_dir) + .join(&cached.file_path) + .exists(); + let thumb_ok = cached.thumb_path.is_empty() + || std::path::Path::new(upload_dir) + .join(&cached.thumb_path) + .exists(); + + if file_ok && thumb_ok { + let cached_media = crate::models::MediaType::from_mime(&cached.mime_type) + .unwrap_or(crate::models::MediaType::Image); + return Ok(Some(crate::utils::files::UploadedFile { + file_path: cached.file_path, + thumb_path: cached.thumb_path, + original_name: crate::utils::sanitize::sanitize_filename(&fname), + mime_type: cached.mime_type, + file_size: i64::try_from(data.len()).unwrap_or(0), + media_type: cached_media, + processing_pending: false, + })); + } + + // One or both paths are gone — the entry is stale. Log and fall + // through so the file is re-saved and the cache is updated below. + tracing::debug!( + "dedup cache miss (files deleted): file_ok={file_ok} thumb_ok={thumb_ok}, \ + re-processing upload for hash {hash}" + ); } let f = crate::utils::files::save_upload( @@ -318,6 +347,7 @@ pub fn process_primary_upload( max_video_size, max_audio_size, ffmpeg_available, + ffmpeg_webp_available, ) .map_err(|e| classify_upload_error(&e))?; crate::db::record_file_hash(conn, &hash, &f.file_path, &f.thumb_path, &f.mime_type)?; diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index d6a280c..7e9463d 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -45,7 +45,7 @@ pub async fn view_thread( let pool = state.db.clone(); let csrf_clone = csrf.clone(); let jar_session = jar.get("chan_admin_session").map(|c| c.value().to_string()); - move || -> Result<(String, String)> { + move || -> Result<(String, String, bool)> { let conn = pool.get()?; let is_admin = jar_session @@ -62,13 +62,13 @@ pub async fn view_thread( return Err(AppError::NotFound("Thread not found in this board.".into())); } - // ETag derived from the thread's last-bump timestamp AND the - // current board-list version. The board version component ensures - // that adding or deleting a board invalidates cached thread pages, - // so the nav bar always reflects the current board list rather than - // showing stale/deleted boards until the thread receives a reply. + // ETag derived from the thread's last-bump timestamp, the current + // board-list version, and whether the viewer is an admin. Including + // admin status prevents a browser from serving a cached non-admin + // page (without delete controls) to a user who has since logged in. let boards_ver = crate::templates::live_boards_version(); - let etag = format!("\"{}-b{boards_ver}\"", thread.bumped_at); + let admin_tag = if is_admin { "-a" } else { "" }; + let etag = format!("\"{}-b{boards_ver}{admin_tag}\"", thread.bumped_at); let posts = db::get_posts_for_thread(&conn, thread_id)?; let all_boards = db::get_all_boards(&conn)?; @@ -89,13 +89,13 @@ pub async fn view_thread( None, collapse_greentext, ); - Ok((etag, html)) + Ok((etag, html, is_admin)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - let (etag, html) = result; + let (etag, html, _is_admin) = result; // 3.2: Return 304 Not Modified when client's cached copy is still current. let client_etag = req_headers @@ -147,6 +147,7 @@ pub async fn post_reply( let max_video_size = CONFIG.max_video_size; let max_audio_size = CONFIG.max_audio_size; let ffmpeg_available = state.ffmpeg_available; + let ffmpeg_webp_available = state.ffmpeg_webp_available; let cookie_secret = CONFIG.cookie_secret.clone(); let file_data = form.file; let audio_file_data = form.audio_file; @@ -258,6 +259,7 @@ pub async fn post_reply( max_video_size, max_audio_size, ffmpeg_available, + ffmpeg_webp_available, )?; // ── Image+audio combo ───────────────────────────────────────────── diff --git a/src/main.rs b/src/main.rs index b847174..0e6ac66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -336,6 +336,14 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { // ffmpeg: required for video thumbnails (optional — graceful degradation). let ffmpeg_status = detect::detect_ffmpeg(CONFIG.require_ffmpeg); let ffmpeg_available = ffmpeg_status == detect::ToolStatus::Available; + // libwebp encoder: needed for image→WebP conversion. Checked independently + // so that a stock ffmpeg build (missing libwebp) still enables video/audio + // features while image conversion degrades gracefully. + let ffmpeg_webp_available = detect::detect_webp_encoder(ffmpeg_available); + // libvpx-vp9 + libopus encoders: needed for MP4→WebM transcoding and + // WebM/AV1→VP9 re-encoding. Checked independently so that a build missing + // only these codecs still enables image conversion and thumbnail generation. + let ffmpeg_vp9_available = detect::detect_webm_encoder(ffmpeg_available); // Tor: create hidden-service directory + torrc, launch tor as a background // process, and poll for the hostname file (all non-blocking). @@ -353,9 +361,10 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { let state = AppState { db: pool.clone(), ffmpeg_available, + ffmpeg_webp_available, job_queue: { let q = std::sync::Arc::new(workers::JobQueue::new(pool.clone())); - workers::start_worker_pool(&q, ffmpeg_available); + workers::start_worker_pool(&q, ffmpeg_available, ffmpeg_vp9_available); q }, backup_progress: std::sync::Arc::new(middleware::BackupProgress::new()), diff --git a/src/media/convert.rs b/src/media/convert.rs index 21ff3fc..1a185a0 100644 --- a/src/media/convert.rs +++ b/src/media/convert.rs @@ -4,7 +4,7 @@ // // Conversion rules (from project spec): // jpg / jpeg → WebP (quality 85, metadata stripped) -// gif → WebM (VP9 codec, preserves animation) +// gif → WebP (quality 85, -loop 0 preserves animation if libwebp supports it) // bmp → WebP // tiff → WebP // png → WebP ONLY if the WebP output is smaller; otherwise keep PNG @@ -29,10 +29,8 @@ use super::ffmpeg; /// source MIME type. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConversionAction { - /// Convert to WebP (JPEG, BMP, TIFF). + /// Convert to WebP (JPEG, GIF, BMP, TIFF). ToWebp, - /// Convert to WebM/VP9 (GIF animation). - ToWebm, /// Attempt WebP; keep original if WebP is not smaller (PNG). ToWebpIfSmaller, /// No conversion; store file as-is. @@ -46,8 +44,11 @@ pub enum ConversionAction { #[must_use] pub fn conversion_action(mime: &str) -> ConversionAction { match mime { - "image/jpeg" | "image/bmp" | "image/tiff" => ConversionAction::ToWebp, - "image/gif" => ConversionAction::ToWebm, + // GIF → animated WebP: keeps the media type as Image so it renders in + // an tag rather than a