diff --git a/.gitignore b/.gitignore index a3e3f61..69a6807 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ .DS_Store dev-check.sh +docs/clippy.sh +dev-check-strict.sh +/docs +ToDO.txt # Generated by Cargo # will have compiled files and executables debug diff --git a/CHANGELOG.md b/CHANGELOG.md index 295afdc..0dba5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,163 @@ All notable changes to RustChan will be documented in this file. --- +## [1.1.0] + +## Architecture Refactor + +This release restructures the codebase for maintainability. No user-facing +behavior has changed. Every route, every feature, every pixel is identical. +The only difference is where the code lives. + +### The problem + +`main.rs` had grown to 1,757 lines and owned everything from the HTTP router +to the ASCII startup banner. `handlers/admin.rs` hit 4,576 lines with 33 +handler functions covering auth, backups, bans, reports, settings, and more. +Both files were becoming difficult to navigate and risky to modify. + +### What changed + +**Phase 1 — Cleanup** + +- Removed unused `src/theme-init.js` (dead duplicate of `static/theme-init.js`) +- Moved `validate_password()` from `main.rs` to `utils/crypto.rs` alongside + the other credential helpers +- Moved `first_run_check()` and `get_per_board_stats()` from `main.rs` into + the `db` module, eliminating the only raw SQL that lived outside `db/` + +**Phase 2 — Background work** + +- Moved `evict_thumb_cache()` from `main.rs` to `workers/mod.rs` where it + belongs alongside the other background maintenance operations + +**Phase 3 — Console extraction** + +- Created `src/server/` directory for server infrastructure +- Extracted terminal stats, keyboard console, startup banner, and all `kb_*` + helpers to `server/console.rs` (~350 lines) + +**Phase 4 — CLI extraction** + +- Moved `Cli`, `Command`, `AdminAction` clap types and `run_admin()` to + `server/cli.rs` (~250 lines) + +**Phase 5 — Server extraction** + +- Moved `run_server()`, `build_router()`, all 7 background task spawns, + static asset handlers, HSTS middleware, request tracking, `ScopedDecrement`, + and global atomics to `server/server.rs` (~800 lines) +- `main.rs` is now ~50 lines: runtime construction, CLI parsing, dispatch + +**Phase 6 — Admin handler decomposition** + +- Converted `handlers/admin.rs` to a module folder (`handlers/admin/`) +- Extracted `backup.rs` — all backup and restore handlers (~2,500 lines) +- Extracted `auth.rs` — login, logout, session management +- Extracted `moderation.rs` — bans, reports, appeals, word filters, mod log +- Extracted `content.rs` — post/thread actions, board management +- Extracted `settings.rs` — site settings, VACUUM, admin panel +- `admin/mod.rs` now contains only shared session helpers and re-exports + +### By the numbers + +``` +File Before After +main.rs 1,757 lines ~50 lines +handlers/admin.rs 4,576 lines split across 6 files +server/ (new) — ~1,400 lines total +db/ unchanged + 2 functions from main.rs +workers/ unchanged + evict_thumb_cache +utils/crypto.rs unchanged + validate_password +``` + +### What was not changed + +`db/`, `templates/`, `utils/`, `media/`, `config.rs`, `error.rs`, `models.rs`, +`detect.rs`, `handlers/board.rs`, `handlers/thread.rs`, and `middleware/` are +all untouched. They were already well-structured. +``` + +## 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..6dee69e 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" @@ -433,12 +422,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" 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 +530,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 +692,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 +879,9 @@ dependencies = [ "moxcms", "num-traits", "png", - "zune-core", - "zune-jpeg", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", ] [[package]] @@ -1351,11 +1387,10 @@ dependencies = [ [[package]] name = "rustchan" -version = "1.0.13" +version = "1.1.0" dependencies = [ "anyhow", "argon2", - "async-trait", "axum", "axum-extra", "chrono", @@ -1384,6 +1419,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", "uuid", "zip", @@ -1663,6 +1699,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" @@ -1841,6 +1891,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -1873,6 +1935,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" @@ -1883,12 +1955,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -2388,17 +2463,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 9d74064..487703c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,20 @@ [package] name = "rustchan" -version = "1.0.13" +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,11 +31,10 @@ 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" -tower-http = { version = "0.6", features = ["fs", "set-header", "compression-full"] } +tower-http = { version = "0.6", features = ["fs", "set-header", "compression-full", "trace"] } tokio = { version = "1", features = ["full"] } tokio-util = "0.7" @@ -46,13 +53,12 @@ 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. 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 @@ -76,7 +82,8 @@ regex = "1" zip = { version = "8", default-features = false, features = ["deflate"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +tracing-appender = "0.2" thiserror = "2" tempfile = "3" 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/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 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/boards.rs b/src/db/boards.rs index 0957042..cb10e17 100644 --- a/src/db/boards.rs +++ b/src/db/boards.rs @@ -410,6 +410,31 @@ pub fn delete_board(conn: &rusqlite::Connection, id: i64) -> Result> Ok(safe) } +// ─── Per-board stats (terminal display) ────────────────────────────────────── + +/// Per-board thread and post counts for the terminal stats display. +pub fn get_per_board_stats(conn: &rusqlite::Connection) -> Vec<(String, i64, i64)> { + let Ok(mut stmt) = conn.prepare( + "SELECT b.short_name, \ + (SELECT COUNT(*) FROM threads WHERE board_id = b.id) AS tc, \ + (SELECT COUNT(*) FROM posts p \ + JOIN threads t ON p.thread_id = t.id \ + WHERE t.board_id = b.id) AS pc \ + FROM boards b ORDER BY b.short_name", + ) else { + return vec![]; + }; + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default() +} + // ─── Site statistics ────────────────────────────────────────────────────────── /// Gather aggregate site-wide statistics for the home page. diff --git a/src/db/mod.rs b/src/db/mod.rs index 84c6de5..6d8088f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -156,6 +156,39 @@ pub fn init_pool() -> Result { Ok(pool) } +// ─── First-run check ───────────────────────────────────────────────────────── + +/// Check whether this is a first run (no boards and no admins). +/// Prints a setup banner if so. Called once at server startup. +/// +/// # Errors +/// Returns an error if the database connection cannot be obtained. +pub fn first_run_check(pool: &DbPool) -> anyhow::Result<()> { + let conn = pool.get()?; + let board_count: i64 = conn + .query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0)) + .unwrap_or(0); + let admin_count: i64 = conn + .query_row("SELECT COUNT(*) FROM admin_users", [], |r| r.get(0)) + .unwrap_or(0); + + if board_count == 0 && admin_count == 0 { + println!(); + println!("╔══════════════════════════════════════════════════════╗"); + println!("║ FIRST RUN — SETUP REQUIRED ║"); + println!("╠══════════════════════════════════════════════════════╣"); + println!("║ No boards or admin accounts found. ║"); + println!("║ Create your first admin and boards: ║"); + println!("║ ║"); + println!("║ rustchan-cli admin create-admin admin mypassword ║"); + println!("║ rustchan-cli admin create-board b Random \"Anything\" ║"); + println!("║ rustchan-cli admin create-board tech Technology ║"); + println!("╚══════════════════════════════════════════════════════╝"); + println!(); + } + Ok(()) +} + // ─── Schema creation & migrations ──────────────────────────────────────────── #[allow(clippy::too_many_lines)] @@ -575,7 +608,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!( @@ -613,10 +646,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 aeef18f..5d33204 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); } @@ -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/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..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. @@ -113,6 +245,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 +472,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/error.rs b/src/error.rs index b424f08..7ba78c0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -58,6 +58,19 @@ pub enum AppError { /// 500 — internal error (database failure, IO error, etc.) #[error("Internal error: {0}")] Internal(#[from] anyhow::Error), + + /// Structured error for future API integration. + /// + /// Carries the HTTP status returned by the remote API, a human-readable + /// detail string, and an optional endpoint label so log lines are + /// self-describing without having to cross-reference request traces. + #[error("API error {status} at {endpoint:?}: {detail}")] + #[allow(dead_code)] + Api { + status: u16, + detail: String, + endpoint: Option, + }, } // Allow ? operator on rusqlite::Error — map SQLITE_BUSY to DbBusy (503) and @@ -109,6 +122,21 @@ impl IntoResponse for AppError { "An internal error occurred.".to_string(), ) } + Self::Api { + status, + detail, + endpoint, + } => { + error!( + status, + endpoint = endpoint.as_deref().unwrap_or("unknown"), + "API error: {detail}", + ); + ( + StatusCode::BAD_GATEWAY, + format!("API error {status}: {detail}"), + ) + } }; let html = crate::templates::error_page(status.as_u16(), &message); diff --git a/src/handlers/admin/auth.rs b/src/handlers/admin/auth.rs new file mode 100644 index 0000000..f714e4b --- /dev/null +++ b/src/handlers/admin/auth.rs @@ -0,0 +1,432 @@ +// handlers/admin/auth.rs +// +// Admin authentication: login, logout, session management. +// +// Authentication flow: +// 1. POST /admin/login → verify Argon2 password → create session in DB → set cookie +// 2. GET /admin → redirect to panel if already logged in, else show login form +// 3. POST /admin/logout → delete session from DB → clear cookie +// +// Brute-force protection (CRIT-6): +// After LOGIN_FAIL_LIMIT failed attempts within LOGIN_FAIL_WINDOW seconds, the IP is +// locked out for the remainder of that window. On success the counter is cleared. +// Keys are SHA-256(IP) to avoid retaining raw addresses in memory (CRIT-5). + +use crate::{ + config::CONFIG, + db, + error::{AppError, Result}, + handlers::board::ensure_csrf, + middleware::AppState, + templates, + utils::crypto::{new_session_id, verify_password}, +}; +use axum::{ + extract::{Form, State}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use chrono::Utc; +use dashmap::DashMap; +use serde::Deserialize; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::LazyLock; +use std::time::{SystemTime, UNIX_EPOCH}; +use time; +use tracing::{info, warn}; + +// ─── CRIT-6: Admin login brute-force lockout ────────────────────────────────── +// +// After LOGIN_FAIL_LIMIT failed attempts within LOGIN_FAIL_WINDOW seconds the +// IP is locked out for the remainder of that window. On success the counter +// is cleared immediately so a genuine admin is never self-locked. +// +// Keys are SHA-256(IP) to avoid retaining raw addresses in memory (CRIT-5). + +const LOGIN_FAIL_LIMIT: u32 = 5; +const LOGIN_FAIL_WINDOW: u64 = 900; // 15 minutes + +/// `ip_hash` → (`fail_count`, `window_start_secs`) +static ADMIN_LOGIN_FAILS: LazyLock> = LazyLock::new(DashMap::new); +static LOGIN_CLEANUP_SECS: AtomicU64 = AtomicU64::new(0); + +fn login_now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn login_ip_key(ip: &str) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(ip.as_bytes()); + hex::encode(h.finalize()) +} + +/// Returns true if this IP is currently locked out. +fn is_login_locked(ip_key: &str) -> bool { + let now = login_now_secs(); + if let Some(entry) = ADMIN_LOGIN_FAILS.get(ip_key) { + let (count, window_start) = *entry; + if now.saturating_sub(window_start) <= LOGIN_FAIL_WINDOW { + return count >= LOGIN_FAIL_LIMIT; + } + } + false +} + +/// Record a failed login attempt; returns the new failure count. +#[allow(clippy::arithmetic_side_effects)] +#[allow(clippy::significant_drop_tightening)] +fn record_login_fail(ip_key: &str) -> u32 { + let now = login_now_secs(); + let mut entry = ADMIN_LOGIN_FAILS + .entry(ip_key.to_string()) + .or_insert((0, now)); + let (count, window_start) = entry.value_mut(); + if now.saturating_sub(*window_start) > LOGIN_FAIL_WINDOW { + *count = 1; + *window_start = now; + } else { + *count = count.saturating_add(1); + } + *count +} + +fn clear_login_fails(ip_key: &str) { + ADMIN_LOGIN_FAILS.remove(ip_key); +} + +/// Remove login-fail entries whose window has expired. +/// Called periodically from the background task in `server/server.rs`. +pub fn prune_login_fails() { + let now = login_now_secs(); + // Throttle to at most once per LOGIN_FAIL_WINDOW seconds. + let last = LOGIN_CLEANUP_SECS.load(Ordering::Relaxed); + if now.saturating_sub(last) < LOGIN_FAIL_WINDOW { + return; + } + LOGIN_CLEANUP_SECS.store(now, Ordering::Relaxed); + ADMIN_LOGIN_FAILS + .retain(|_, (_, window_start)| now.saturating_sub(*window_start) <= LOGIN_FAIL_WINDOW); +} + +fn require_admin_sync(jar: &CookieJar, pool: &crate::db::DbPool) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()) + .ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; + + let conn = pool.get()?; + let session = db::get_session(&conn, &session_id)? + .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; + + Ok(session.admin_id) +} + +/// Public helper — returns true if the jar contains a valid admin session. +/// Used by other handlers to conditionally show admin controls. +/// FIX[HIGH-2]/[HIGH-3]: Callers must invoke this from inside `spawn_blocking`. +#[allow(dead_code)] +pub fn is_admin_session(jar: &CookieJar, pool: &crate::db::DbPool) -> bool { + require_admin_sync(jar, pool).is_ok() +} + +// ─── GET /admin ─────────────────────────────────────────────────────────────── + +pub async fn admin_index(State(state): State, jar: CookieJar) -> Result { + // FIX[HIGH-3]: Move DB I/O into spawn_blocking. + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + + let (is_logged_in, boards) = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<(bool, Vec)> { + let conn = pool.get()?; + let logged_in = session_id + .as_deref() + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); + let boards = db::get_all_boards(&conn)?; + Ok((logged_in, boards)) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + if is_logged_in { + return Ok(Redirect::to("/admin/panel").into_response()); + } + + let (jar, csrf) = ensure_csrf(jar); + Ok((jar, Html(templates::admin_login_page(None, &csrf, &boards))).into_response()) +} + +// ─── POST /admin/login ──────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct LoginForm { + username: String, + password: String, + #[serde(rename = "_csrf")] + csrf: Option, +} + +#[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] +pub async fn admin_login( + State(state): State, + jar: CookieJar, + crate::middleware::ClientIp(client_ip): crate::middleware::ClientIp, + Form(form): Form, +) -> Result { + // CRIT-6: Reject IPs that are currently locked out due to repeated failures. + let ip_key = login_ip_key(&client_ip); + if is_login_locked(&ip_key) { + warn!( + "Admin login blocked (brute-force lockout) for ip_prefix={}", + &ip_key[..8] + ); + return Err(AppError::RateLimited); + } + + // CSRF check + let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { + return Err(AppError::Forbidden("CSRF token mismatch.".into())); + } + + let username = form.username.trim().to_string(); + if username.is_empty() || username.len() > 64 { + let (jar, csrf) = ensure_csrf(jar); + let boards = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || { + let conn = pool.get()?; + db::get_all_boards(&conn) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + return Ok(( + jar, + Html(templates::admin_login_page( + Some("Invalid username."), + &csrf, + &boards, + )), + ) + .into_response()); + } + + let pool = state.db.clone(); + let password = form.password.clone(); + + // FIX[HIGH-3]: Argon2 verification is CPU-intensive; always use spawn_blocking. + let result = tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let user = db::get_admin_by_username(&conn, &username)?; + if let Some(u) = user { + if verify_password(&password, &u.password_hash)? { + return Ok(Some(u.id)); + } + } + Ok(None) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + match result { + None => { + warn!("Failed admin login attempt for '{}'", form.username.trim()); + let (jar, csrf) = ensure_csrf(jar); + let boards = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || { + let conn = pool.get()?; + db::get_all_boards(&conn) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + // CRIT-6: Record failed attempt and check if now locked. + let fails = record_login_fail(&ip_key); + warn!( + "Failed admin login for '{}' (attempt {}/{})", + form.username.trim(), + fails, + LOGIN_FAIL_LIMIT + ); + Ok(( + jar, + Html(templates::admin_login_page( + Some("Invalid username or password."), + &csrf, + &boards, + )), + ) + .into_response()) + } + Some(admin_id) => { + // CRIT-6: Successful login — reset any failure counter. + clear_login_fails(&ip_key); + // Create session (FIX[HIGH-3]: in spawn_blocking) + let session_id = new_session_id(); + let expires_at = Utc::now().timestamp() + CONFIG.session_duration; + let sid_clone = session_id.clone(); + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + db::create_session(&conn, &sid_clone, admin_id, expires_at)?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + let mut cookie = Cookie::new(super::SESSION_COOKIE, session_id); + cookie.set_http_only(true); + cookie.set_same_site(SameSite::Strict); + cookie.set_path("/"); + // FIX[MEDIUM-11]: Derive Secure flag from config; true when CHAN_HTTPS_COOKIES=true. + cookie.set_secure(CONFIG.https_cookies); + // FIX[HIGH-1]: Set Max-Age so browsers expire the cookie after the + // configured session lifetime instead of persisting it indefinitely. + cookie.set_max_age(time::Duration::seconds(CONFIG.session_duration)); + + info!("Admin {admin_id} logged in"); + Ok((jar.add(cookie), Redirect::to("/admin/panel")).into_response()) + } + } +} + +// ─── POST /admin/logout ─────────────────────────────────────────────────────── + +pub async fn admin_logout( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + // Verify CSRF to prevent forced-logout attacks + let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { + return Err(AppError::Forbidden("CSRF token mismatch.".into())); + } + + if let Some(session_cookie) = jar.get(super::SESSION_COOKIE) { + let session_id = session_cookie.value().to_string(); + // FIX[HIGH-3]: DB call in spawn_blocking + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + db::delete_session(&conn, &session_id)?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + } + let jar = jar.remove(Cookie::from(super::SESSION_COOKIE)); + // Redirect back to the page where logout was triggered, or fall back to login. + // FIX[HIGH-4]: Reject backslash (and its percent-encoded form %5C) in + // addition to the existing checks. On some browsers /\\evil.com and + // /%5Cevil.com are treated as protocol-relative redirects to evil.com. + let destination = form + .return_to + .as_deref() + .filter(|s| { + s.starts_with('/') + && !s.contains("//") + && !s.contains("..") + && !s.contains('\\') + && !s.to_ascii_lowercase().contains("%5c") + }) + .unwrap_or("/admin"); + Ok((jar, Redirect::to(destination)).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── login_ip_key ───────────────────────────────────────────────────────── + + #[test] + fn ip_key_is_hex_sha256() { + let key = login_ip_key("127.0.0.1"); + // SHA-256 produces 32 bytes = 64 hex chars + assert_eq!(key.len(), 64); + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn ip_key_same_ip_same_key() { + assert_eq!(login_ip_key("192.168.1.1"), login_ip_key("192.168.1.1")); + } + + #[test] + fn ip_key_different_ips_different_keys() { + assert_ne!(login_ip_key("192.168.1.1"), login_ip_key("192.168.1.2")); + } + + #[test] + fn ip_key_hides_raw_ip() { + // The raw IP should not appear anywhere in the hash output + let key = login_ip_key("10.0.0.1"); + assert!(!key.contains("10.0.0.1")); + } + + // ── is_login_locked ────────────────────────────────────────────────────── + + #[test] + fn fresh_ip_is_not_locked() { + let key = login_ip_key("test-fresh-ip-not-in-map"); + assert!(!is_login_locked(&key)); + } + + #[test] + fn locked_after_exceeding_fail_limit() { + // Use a unique key so parallel tests don't interfere + let key = login_ip_key("test-lock-unique-99887766"); + // Clean up any residue from a previous run + ADMIN_LOGIN_FAILS.remove(&key); + + let now = login_now_secs(); + // Insert exactly LOGIN_FAIL_LIMIT failures within the window + ADMIN_LOGIN_FAILS.insert(key.clone(), (LOGIN_FAIL_LIMIT, now)); + assert!(is_login_locked(&key)); + + // Cleanup + ADMIN_LOGIN_FAILS.remove(&key); + } + + #[test] + fn not_locked_below_fail_limit() { + let key = login_ip_key("test-below-limit-11223344"); + ADMIN_LOGIN_FAILS.remove(&key); + + let now = login_now_secs(); + ADMIN_LOGIN_FAILS.insert(key.clone(), (LOGIN_FAIL_LIMIT - 1, now)); + assert!(!is_login_locked(&key)); + + ADMIN_LOGIN_FAILS.remove(&key); + } + + #[test] + fn expired_window_is_not_locked() { + let key = login_ip_key("test-expired-window-55667788"); + ADMIN_LOGIN_FAILS.remove(&key); + + // window_start far in the past, beyond LOGIN_FAIL_WINDOW + let old_ts = login_now_secs().saturating_sub(LOGIN_FAIL_WINDOW + 60); + ADMIN_LOGIN_FAILS.insert(key.clone(), (LOGIN_FAIL_LIMIT + 10, old_ts)); + assert!(!is_login_locked(&key)); + + ADMIN_LOGIN_FAILS.remove(&key); + } +} diff --git a/src/handlers/admin.rs b/src/handlers/admin/backup.rs similarity index 65% rename from src/handlers/admin.rs rename to src/handlers/admin/backup.rs index f1b3068..07ba825 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin/backup.rs @@ -1,1362 +1,40 @@ -// handlers/admin.rs +// handlers/admin/backup.rs // -// Admin panel. All routes require a valid session cookie. -// -// Authentication flow: -// 1. POST /admin/login → verify Argon2 password → create session in DB → set cookie -// 2. All /admin/* routes → check session cookie → get session from DB → proceed -// 3. POST /admin/logout → delete session from DB → clear cookie -// -// Session cookie: HTTPOnly (not readable by JS), SameSite=Strict (prevents CSRF). -// Secure=true when CHAN_HTTPS_COOKIES=true (default: same as CHAN_BEHIND_PROXY). -// -// FIX[HIGH-3] + FIX[MEDIUM-12]: All admin handlers now wrap DB and file I/O in -// spawn_blocking to avoid blocking the Tokio event loop. Direct DB calls from -// async context were stalling worker threads under concurrent load. - -use crate::{ - config::CONFIG, - db::{self, DbPool}, - error::{AppError, Result}, - handlers::board::ensure_csrf, - middleware::AppState, - models::BackupInfo, - templates, - utils::crypto::{new_session_id, verify_password}, -}; -use axum::{ - extract::{Form, Multipart, Path, Query, State}, - http::header, - response::{Html, IntoResponse, Redirect, Response}, -}; -use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; -use chrono::Utc; -use dashmap::DashMap; -use rusqlite::backup::Backup; -use rusqlite::params; -use serde::Deserialize; -use std::io::{Seek, Write}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::LazyLock; -use std::time::{SystemTime, UNIX_EPOCH}; -use time; -use tokio::io::AsyncWriteExt as _; -use tokio_util::io::ReaderStream; -use tracing::{info, warn}; - -const SESSION_COOKIE: &str = "chan_admin_session"; - -// ─── CRIT-6: Admin login brute-force lockout ────────────────────────────────── -// -// After LOGIN_FAIL_LIMIT failed attempts within LOGIN_FAIL_WINDOW seconds the -// IP is locked out for the remainder of that window. On success the counter -// is cleared immediately so a genuine admin is never self-locked. -// -// Keys are SHA-256(IP) to avoid retaining raw addresses in memory (CRIT-5). - -const LOGIN_FAIL_LIMIT: u32 = 5; -const LOGIN_FAIL_WINDOW: u64 = 900; // 15 minutes - -/// `ip_hash` → (`fail_count`, `window_start_secs`) -static ADMIN_LOGIN_FAILS: LazyLock> = LazyLock::new(DashMap::new); -static LOGIN_CLEANUP_SECS: AtomicU64 = AtomicU64::new(0); - -fn login_now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -fn login_ip_key(ip: &str) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(ip.as_bytes()); - hex::encode(h.finalize()) -} - -/// Returns true if this IP is currently locked out. -fn is_login_locked(ip_key: &str) -> bool { - let now = login_now_secs(); - if let Some(entry) = ADMIN_LOGIN_FAILS.get(ip_key) { - let (count, window_start) = *entry; - if now.saturating_sub(window_start) <= LOGIN_FAIL_WINDOW { - return count >= LOGIN_FAIL_LIMIT; - } - } - false -} - -/// Record a failed login attempt; returns the new failure count. -fn record_login_fail(ip_key: &str) -> u32 { - let now = login_now_secs(); - let mut binding = ADMIN_LOGIN_FAILS - .entry(ip_key.to_string()) - .or_insert((0, now)); - let (count, window_start) = binding.value_mut(); - if now.saturating_sub(*window_start) > LOGIN_FAIL_WINDOW { - *count = 1; - *window_start = now; - } else { - *count += 1; - } - let result = *count; - drop(binding); - result -} - -/// Clear failure counter after a successful login. -fn clear_login_fails(ip_key: &str) { - ADMIN_LOGIN_FAILS.remove(ip_key); - // Opportunistically prune stale entries every ~15 min. - let now = login_now_secs(); - let last = LOGIN_CLEANUP_SECS.load(Ordering::Relaxed); - if now.saturating_sub(last) > 900 - && LOGIN_CLEANUP_SECS - .compare_exchange(last, now, Ordering::AcqRel, Ordering::Relaxed) - .is_ok() - { - ADMIN_LOGIN_FAILS.retain(|_, (_, ws)| now.saturating_sub(*ws) <= LOGIN_FAIL_WINDOW); - } -} - -/// Prune all expired entries from `ADMIN_LOGIN_FAILS`. -/// Called by a background task every 5 minutes so the map never grows -/// unbounded during sustained brute-force attacks with no successful logins. -pub fn prune_login_fails() { - let now = login_now_secs(); - ADMIN_LOGIN_FAILS.retain(|_, (_, ws)| now.saturating_sub(*ws) <= LOGIN_FAIL_WINDOW); -} - -/// Verify admin session. Returns the `admin_id` if valid. -/// NOTE: This function performs blocking DB I/O. Only call it from within a -/// `spawn_blocking` closure or synchronous (non-async) context. -#[allow(dead_code)] -fn require_admin_sync(jar: &CookieJar, pool: &DbPool) -> Result { - let session_id = jar - .get(SESSION_COOKIE) - .map(|c| c.value().to_string()) - .ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; - - let conn = pool.get()?; - let session = db::get_session(&conn, &session_id)? - .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; - - Ok(session.admin_id) -} - -/// Public helper — returns true if the jar contains a valid admin session. -/// Used by other handlers to conditionally show admin controls. -/// FIX[HIGH-2]/[HIGH-3]: Callers must invoke this from inside `spawn_blocking`. -#[allow(dead_code)] -pub fn is_admin_session(jar: &CookieJar, pool: &DbPool) -> bool { - require_admin_sync(jar, pool).is_ok() -} - -pub async fn admin_index(State(state): State, jar: CookieJar) -> Result { - // FIX[HIGH-3]: Move DB I/O into spawn_blocking. - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - - let (is_logged_in, boards) = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<(bool, Vec)> { - let conn = pool.get()?; - let logged_in = session_id - .as_deref() - .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); - let boards = db::get_all_boards(&conn)?; - Ok((logged_in, boards)) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - if is_logged_in { - return Ok(Redirect::to("/admin/panel").into_response()); - } - - let (jar, csrf) = ensure_csrf(jar); - Ok((jar, Html(templates::admin_login_page(None, &csrf, &boards))).into_response()) -} - -// ─── POST /admin/login ──────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct LoginForm { - username: String, - password: String, - #[serde(rename = "_csrf")] - csrf: Option, -} - -#[allow(clippy::too_many_lines)] -pub async fn admin_login( - State(state): State, - jar: CookieJar, - crate::middleware::ClientIp(client_ip): crate::middleware::ClientIp, - Form(form): Form, -) -> Result { - // CRIT-6: Reject IPs that are currently locked out due to repeated failures. - let ip_key = login_ip_key(&client_ip); - if is_login_locked(&ip_key) { - warn!( - "Admin login blocked (brute-force lockout) for ip_prefix={}", - &ip_key[..8] - ); - return Err(AppError::RateLimited); - } - - // CSRF check - let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) - { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); - } - - let username = form.username.trim().to_string(); - if username.is_empty() || username.len() > 64 { - let (jar, csrf) = ensure_csrf(jar); - let boards = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || { - let conn = pool.get()?; - db::get_all_boards(&conn) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - return Ok(( - jar, - Html(templates::admin_login_page( - Some("Invalid username."), - &csrf, - &boards, - )), - ) - .into_response()); - } - - let pool = state.db.clone(); - let password = form.password.clone(); - - // FIX[HIGH-3]: Argon2 verification is CPU-intensive; always use spawn_blocking. - let result = tokio::task::spawn_blocking(move || -> Result> { - let conn = pool.get()?; - let user = db::get_admin_by_username(&conn, &username)?; - if let Some(u) = user { - if verify_password(&password, &u.password_hash)? { - return Ok(Some(u.id)); - } - } - Ok(None) - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - match result { - None => { - warn!("Failed admin login attempt for '{}'", form.username.trim()); - let (jar, csrf) = ensure_csrf(jar); - let boards = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || { - let conn = pool.get()?; - db::get_all_boards(&conn) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - // CRIT-6: Record failed attempt and check if now locked. - let fails = record_login_fail(&ip_key); - warn!( - "Failed admin login for '{}' (attempt {}/{})", - form.username.trim(), - fails, - LOGIN_FAIL_LIMIT - ); - Ok(( - jar, - Html(templates::admin_login_page( - Some("Invalid username or password."), - &csrf, - &boards, - )), - ) - .into_response()) - } - Some(admin_id) => { - // CRIT-6: Successful login — reset any failure counter. - clear_login_fails(&ip_key); - // Create session (FIX[HIGH-3]: in spawn_blocking) - let session_id = new_session_id(); - let expires_at = Utc::now().timestamp() + CONFIG.session_duration; - let sid_clone = session_id.clone(); - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - db::create_session(&conn, &sid_clone, admin_id, expires_at)?; - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - let mut cookie = Cookie::new(SESSION_COOKIE, session_id); - cookie.set_http_only(true); - cookie.set_same_site(SameSite::Strict); - cookie.set_path("/"); - // FIX[MEDIUM-11]: Derive Secure flag from config; true when CHAN_HTTPS_COOKIES=true. - cookie.set_secure(CONFIG.https_cookies); - // FIX[HIGH-1]: Set Max-Age so browsers expire the cookie after the - // configured session lifetime instead of persisting it indefinitely. - cookie.set_max_age(time::Duration::seconds(CONFIG.session_duration)); - - info!("Admin {admin_id} logged in"); - Ok((jar.add(cookie), Redirect::to("/admin/panel")).into_response()) - } - } -} - -// ─── POST /admin/logout ─────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct CsrfOnly { - #[serde(rename = "_csrf")] - csrf: Option, - return_to: Option, -} - -pub async fn admin_logout( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - // Verify CSRF to prevent forced-logout attacks - let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) - { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); - } - - if let Some(session_cookie) = jar.get(SESSION_COOKIE) { - let session_id = session_cookie.value().to_string(); - // FIX[HIGH-3]: DB call in spawn_blocking - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - db::delete_session(&conn, &session_id)?; - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - } - let jar = jar.remove(Cookie::from(SESSION_COOKIE)); - // Redirect back to the page where logout was triggered, or fall back to login. - // FIX[HIGH-4]: Reject backslash (and its percent-encoded form %5C) in - // addition to the existing checks. On some browsers /\evil.com and - // /%5Cevil.com are treated as protocol-relative redirects to evil.com. - let destination = form - .return_to - .as_deref() - .filter(|s| { - s.starts_with('/') - && !s.contains("//") - && !s.contains("..") - && !s.contains('\\') - && !s.to_ascii_lowercase().contains("%5c") - }) - .unwrap_or("/admin"); - Ok((jar, Redirect::to(destination)).into_response()) -} - -// ─── GET /admin/panel ───────────────────────────────────────────────────────── - -/// Query params accepted by GET /admin/panel. -/// All fields are optional — missing = no flash message. -#[derive(Deserialize, Default)] -pub struct AdminPanelQuery { - /// Set by `board_restore` on success: the `short_name` of the restored board. - pub board_restored: Option, - /// Set by `board_restore` / `restore_saved_board_backup` on failure. - pub restore_error: Option, - /// Set by `update_site_settings` on success. - pub settings_saved: Option, -} - -pub async fn admin_panel( - State(state): State, - jar: CookieJar, - Query(params): Query, -) -> Result<(CookieJar, Html)> { - // FIX[HIGH-3]: Move auth check and all DB calls into spawn_blocking. - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let (jar, csrf) = ensure_csrf(jar); - let csrf_clone = csrf.clone(); - - // Build the flash message from query params before entering spawn_blocking. - let flash: Option<(bool, String)> = if let Some(err) = params.restore_error { - Some((true, format!("Restore failed: {err}"))) - } else if let Some(board) = params.board_restored { - Some((false, format!("Board /{board}/ restored successfully."))) - } else if params.settings_saved.is_some() { - Some((false, "Site settings saved.".to_string())) - } else { - None - }; - - let html = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result { - let conn = pool.get()?; - - // Auth check inside blocking task - let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; - db::get_session(&conn, &sid)? - .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; - - let boards = db::get_all_boards(&conn)?; - let bans = db::list_bans(&conn)?; - let filters = db::get_word_filters(&conn)?; - let collapse_greentext = db::get_collapse_greentext(&conn); - let reports = db::get_open_reports(&conn)?; - let appeals = db::get_open_ban_appeals(&conn)?; - let site_name = db::get_site_name(&conn); - let site_subtitle = db::get_site_subtitle(&conn); - let default_theme = db::get_default_user_theme(&conn); - - // Collect saved backup file lists (read from disk, not DB). - let full_backups = list_backup_files(&full_backup_dir()); - let board_backups_list = list_backup_files(&board_backup_dir()); - - let db_size_bytes = db::get_db_size_bytes(&conn).unwrap_or(0); - - // 1.8: Compute whether the DB file size exceeds the configured - // warning threshold. Uses the on-disk file size (via fs::metadata) - // rather than SQLite's PRAGMA page_count estimate for accuracy, - // falling back to the pragma value if the path is unavailable. - let db_size_warning = if CONFIG.db_warn_threshold_bytes > 0 { - let file_size = std::fs::metadata(&CONFIG.database_path) - .map_or_else(|_| db_size_bytes.cast_unsigned(), |m| m.len()); - file_size >= CONFIG.db_warn_threshold_bytes - } else { - false - }; - - // Read the tor onion address from the hostname file if tor is enabled. - let tor_address: Option = if CONFIG.enable_tor_support { - let data_dir = std::path::PathBuf::from(&CONFIG.database_path) - .parent() - .map_or_else( - || std::path::PathBuf::from("."), - std::path::Path::to_path_buf, - ); - let hostname_path = data_dir.join("tor_hidden_service").join("hostname"); - std::fs::read_to_string(&hostname_path) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } else { - None - }; - - let flash_ref = flash.as_ref().map(|(is_err, msg)| (*is_err, msg.as_str())); - - Ok(templates::admin_panel_page( - &boards, - &bans, - &filters, - collapse_greentext, - &csrf_clone, - &full_backups, - &board_backups_list, - db_size_bytes, - db_size_warning, - &reports, - &appeals, - &site_name, - &site_subtitle, - &default_theme, - tor_address.as_deref(), - flash_ref, - )) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok((jar, Html(html))) -} - -// ─── POST /admin/board/create ───────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct CreateBoardForm { - short_name: String, - name: String, - description: String, - nsfw: Option, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn create_board( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - // FIX[HIGH-3]: auth + DB write in spawn_blocking - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) - { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); - } - - let short = form - .short_name - .trim() - .to_lowercase() - .chars() - .filter(char::is_ascii_alphanumeric) - .take(8) - .collect::(); - - if short.is_empty() { - return Err(AppError::BadRequest("Invalid board name.".into())); - } - - let nsfw = form.nsfw.as_deref() == Some("1"); - let name = form.name.trim().chars().take(64).collect::(); - let description = form - .description - .trim() - .chars() - .take(256) - .collect::(); - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - db::create_board(&conn, &short, &name, &description, nsfw)?; - info!("Admin created board /{short}/"); - // Refresh live board list so the top bar on any subsequent error - // page includes the newly created board. - crate::templates::set_live_boards(db::get_all_boards(&conn)?); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} - -// ─── POST /admin/board/delete ───────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct BoardIdForm { - board_id: i64, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn delete_board( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - let upload_dir = CONFIG.upload_dir.clone(); - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - - // Fetch the board's short_name before deletion so we can remove - // its upload directory entirely after cleaning tracked files. - let short_name: Option = conn - .query_row( - "SELECT short_name FROM boards WHERE id = ?1", - rusqlite::params![form.board_id], - |r| r.get(0), - ) - .ok(); - - // delete_board returns all file paths for posts in this board. - let paths = db::delete_board(&conn, form.board_id)?; - - // Delete every tracked file and thumbnail from disk. - for p in &paths { - crate::utils::files::delete_file(&upload_dir, p); - } - - // Remove the entire board upload directory — handles the thumbs/ - // sub-directory and any orphaned/untracked files too. - if let Some(short) = short_name { - let board_dir = PathBuf::from(&upload_dir).join(&short); - if board_dir.exists() { - if let Err(e) = std::fs::remove_dir_all(&board_dir) { - warn!("Could not remove board dir {:?}: {}", board_dir, e); - } - } - } - - info!( - "Admin deleted board id={} ({} file(s) removed)", - form.board_id, - paths.len() - ); - // Refresh live board list so the top bar immediately stops showing - // the deleted board — important because error pages use this cache. - crate::templates::set_live_boards(db::get_all_boards(&conn)?); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} - -// ─── POST /admin/thread/action ──────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ThreadActionForm { - thread_id: i64, - board: String, - action: String, // "sticky", "unsticky", "lock", "unlock" - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn thread_action( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - // Validate action before spawning to give early error - match form.action.as_str() { - "sticky" | "unsticky" | "lock" | "unlock" | "archive" => {} - _ => return Err(AppError::BadRequest("Unknown action.".into())), - } - - let action = form.action.clone(); - let thread_id = form.thread_id; - let board_for_log = form.board.clone(); - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - match action.as_str() { - "sticky" => db::set_thread_sticky(&conn, thread_id, true)?, - "unsticky" => db::set_thread_sticky(&conn, thread_id, false)?, - "lock" => db::set_thread_locked(&conn, thread_id, true)?, - "unlock" => db::set_thread_locked(&conn, thread_id, false)?, - "archive" => db::set_thread_archived(&conn, thread_id, true)?, - _ => {} - } - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - &action, - "thread", - Some(thread_id), - &board_for_log, - "", - ); - info!("Admin {action} thread {thread_id}"); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - // FIX[MEDIUM-10]: Use the board name from the DB (via the thread's board_id), - // not the user-supplied form.board, to prevent path-confusion redirects. - let redirect_url = { - let pool = state.db.clone(); - let board_name = tokio::task::spawn_blocking(move || -> Result { - let conn = pool.get()?; - let thread = db::get_thread(&conn, thread_id)?; - if let Some(t) = thread { - let boards = db::get_all_boards(&conn)?; - if let Some(b) = boards.iter().find(|b| b.id == t.board_id) { - return Ok(b.short_name.clone()); - } - } - // Fallback: sanitize the user-supplied board name to prevent open-redirect. - // Only allow alphanumeric characters (matching the board short_name format). - let safe: String = form - .board - .chars() - .filter(char::is_ascii_alphanumeric) - .take(8) - .collect(); - Ok(safe) - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - // After archiving, send to the board archive; for all other actions - // stay on the thread. - if form.action == "archive" { - format!("/{board_name}/archive") - } else { - format!("/{board_name}/thread/{}", form.thread_id) - } - }; - - Ok(Redirect::to(&redirect_url).into_response()) -} - -// ─── POST /admin/post/delete ────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct AdminDeletePostForm { - post_id: i64, - board: String, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn admin_delete_post( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - let upload_dir = CONFIG.upload_dir.clone(); - let post_id = form.post_id; - - let redirect_board = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - - let post = db::get_post(&conn, post_id)? - .ok_or_else(|| AppError::NotFound("Post not found.".into()))?; - - // FIX[MEDIUM-10]: Resolve board name from DB, not user-supplied form field. - // Fallback sanitizes the user-supplied value to alphanumeric only. - let board_name = db::get_all_boards(&conn)? - .into_iter() - .find(|b| b.id == post.board_id) - .map_or_else( - || { - form.board - .chars() - .filter(char::is_ascii_alphanumeric) - .take(8) - .collect() - }, - |b| b.short_name, - ); - - let thread_id = post.thread_id; - let is_op = post.is_op; - - let paths = if post.is_op { - db::delete_thread(&conn, post.thread_id)? - } else { - db::delete_post(&conn, post_id)? - }; - - for p in paths { - crate::utils::files::delete_file(&upload_dir, &p); - } - - let action = if is_op { - "delete_thread" - } else { - "delete_post" - }; - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - action, - "post", - Some(post_id), - &board_name, - &post.body.chars().take(80).collect::(), - ); - info!("Admin deleted post {post_id}"); - // Return board_name + thread context so we can redirect back to the thread. - // If the post was an OP, redirect to the board index (thread is gone). - if is_op { - Ok(format!("/{board_name}")) - } else { - Ok(format!("/{board_name}/thread/{thread_id}")) - } - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to(&redirect_board).into_response()) -} - -// ─── POST /admin/thread/delete ──────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct AdminDeleteThreadForm { - thread_id: i64, - board: String, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn admin_delete_thread( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - let upload_dir = CONFIG.upload_dir.clone(); - let thread_id = form.thread_id; - - let redirect_board = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - - // FIX[MEDIUM-10]: Resolve board name from DB. - // Fallback sanitizes the user-supplied value to alphanumeric only. - let board_name = db::get_thread(&conn, thread_id)? - .and_then(|t| { - db::get_all_boards(&conn) - .ok()? - .into_iter() - .find(|b| b.id == t.board_id) - .map(|b| b.short_name) - }) - .unwrap_or_else(|| { - form.board - .chars() - .filter(char::is_ascii_alphanumeric) - .take(8) - .collect() - }); - - let paths = db::delete_thread(&conn, thread_id)?; - for p in paths { - crate::utils::files::delete_file(&upload_dir, &p); - } - - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "delete_thread", - "thread", - Some(thread_id), - &board_name, - "", - ); - info!("Admin deleted thread {thread_id}"); - Ok(board_name) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to(&format!("/{redirect_board}")).into_response()) -} - -// ─── POST /admin/ban/add ────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct AddBanForm { - ip_hash: String, - reason: String, - duration_hours: Option, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn add_ban( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - let expires_at = form - .duration_hours - .filter(|&h| h > 0) - // Cap at 87_600 hours (10 years) to prevent overflow in h * 3600. - // Permanent bans are represented by None (duration_hours absent or zero). - .map(|h| Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); - - let ip_hash_log = form.ip_hash.chars().take(8).collect::(); - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - db::add_ban(&conn, &form.ip_hash, &form.reason, expires_at)?; - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "ban", - "ban", - None, - "", - &format!("ip_hash={}… reason={}", &ip_hash_log, form.reason), - ); - info!("Admin added ban for ip_hash {ip_hash_log}…"); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} - -// ─── POST /admin/ban/remove ─────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct BanIdForm { - ban_id: i64, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn remove_ban( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - db::remove_ban(&conn, form.ban_id)?; - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} - -// ─── POST /admin/post/ban-delete ────────────────────────────────────────────── -// Inline ban + delete from the per-post admin toolbar. -// Bans the post author's IP hash, deletes the post, then redirects back to -// the thread (or the board index if the OP is deleted). - -#[derive(Deserialize)] -pub struct BanDeleteForm { - post_id: i64, - ip_hash: String, - board: String, - thread_id: i64, - is_op: Option, - reason: Option, - duration_hours: Option, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn admin_ban_and_delete( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - let reason = form - .reason - .as_deref() - .map(|r| r.trim().to_string()) - .filter(|r| !r.is_empty()) - .unwrap_or_else(|| "Rule violation".to_string()); - - let expires_at = form - .duration_hours - .filter(|&h| h > 0) - .map(|h| chrono::Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); - - let ip_hash_log = form.ip_hash.chars().take(8).collect::(); - let post_id = form.post_id; - let board_short = form.board.clone(); - let thread_id = form.thread_id; - let is_op = form.is_op.as_deref() == Some("1"); - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - - // Validate ip_hash: must be a well-formed SHA-256 hex string (64 hex - // chars). The value comes from a form field in the post toolbar; a - // confused or tampered submission should be rejected cleanly. - if form.ip_hash.len() != 64 || !form.ip_hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(AppError::BadRequest("Invalid IP hash format.".into())); - } - - // Ban first so the IP cannot re-post before the delete lands - db::add_ban(&conn, &form.ip_hash, &reason, expires_at)?; - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "ban", - "ban", - None, - &board_short, - &format!("inline ban — ip_hash={reason}… reason={}", &ip_hash_log), - ); - - // Delete post (or whole thread if OP) - if is_op { - let paths = db::delete_thread(&conn, thread_id)?; - for p in paths { - crate::utils::files::delete_file(&crate::config::CONFIG.upload_dir, &p); - } - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "delete_thread", - "thread", - Some(thread_id), - &board_short, - "", - ); - } else { - let paths = db::delete_post(&conn, post_id)?; - for p in paths { - crate::utils::files::delete_file(&crate::config::CONFIG.upload_dir, &p); - } - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "delete_post", - "post", - Some(post_id), - &board_short, - "", - ); - } - - info!("Admin ban+delete: post={post_id} ip_hash={ip_hash_log}… board={board_short}"); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - // FIX[A1]: form.board is user-supplied; sanitise to alphanumeric only before - // embedding in the redirect URL to prevent open-redirect via "//" prefixes. - let safe_board: String = form - .board - .chars() - .filter(char::is_ascii_alphanumeric) - .take(8) - .collect(); - // If OP was deleted, the thread is gone — send to board index - let redirect = if is_op { - format!("/{safe_board}") - } else { - format!("/{safe_board}/thread/{thread_id}#p{post_id}") - }; - Ok(Redirect::to(&redirect).into_response()) -} - -// ─── POST /admin/appeal/dismiss ─────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct AppealActionForm { - appeal_id: i64, - ip_hash: Option, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn dismiss_appeal( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - db::dismiss_ban_appeal(&conn, form.appeal_id)?; - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel#appeals").into_response()) -} - -// ─── POST /admin/appeal/accept ──────────────────────────────────────────────── - -pub async fn accept_appeal( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - let ip = form.ip_hash.as_deref().unwrap_or(""); - db::accept_ban_appeal(&conn, form.appeal_id, ip)?; - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "accept_appeal", - "ban", - None, - "", - &format!("appeal {} — ip unban", form.appeal_id), - ); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel#appeals").into_response()) -} - -#[derive(Deserialize)] -pub struct AddFilterForm { - pattern: String, - replacement: String, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn add_filter( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - if form.pattern.trim().is_empty() { - return Err(AppError::BadRequest("Pattern cannot be empty.".into())); - } - - let pattern = form.pattern.trim().chars().take(256).collect::(); - let replacement = form - .replacement - .trim() - .chars() - .take(256) - .collect::(); - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - db::add_word_filter(&conn, &pattern, &replacement)?; - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} - -// ─── POST /admin/filter/remove ──────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct FilterIdForm { - filter_id: i64, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn remove_filter( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - db::remove_word_filter(&conn, form.filter_id)?; - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} - -// ─── POST /admin/board/settings ────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct BoardSettingsForm { - board_id: i64, - name: String, - description: String, - bump_limit: Option, - max_threads: Option, - nsfw: Option, - allow_images: Option, - allow_video: Option, - allow_audio: Option, - allow_tripcodes: Option, - allow_editing: Option, - edit_window_secs: Option, - allow_archive: Option, - allow_video_embeds: Option, - allow_captcha: Option, - post_cooldown_secs: Option, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn update_board_settings( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - let bump_limit = form - .bump_limit - .as_deref() - .and_then(|v| v.parse::().ok()) - .unwrap_or(500) - .clamp(1, 10_000); - let max_threads = form - .max_threads - .as_deref() - .and_then(|v| v.parse::().ok()) - .unwrap_or(150) - .clamp(1, 1_000); - let edit_window_secs = form - .edit_window_secs - .as_deref() - .and_then(|v| v.parse::().ok()) - .unwrap_or(300) - .clamp(0, 86_400); // 0 = disabled, max 24 h - let post_cooldown_secs = form - .post_cooldown_secs - .as_deref() - .and_then(|v| v.parse::().ok()) - .unwrap_or(0) - .clamp(0, 3_600); // 0 = disabled, max 1 hour - - // Enforce server-side length limits on free-text fields - let name = form.name.trim().chars().take(64).collect::(); - let description = form - .description - .trim() - .chars() - .take(256) - .collect::(); - let board_id = form.board_id; - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - db::update_board_settings( - &conn, - board_id, - &name, - &description, - form.nsfw.as_deref() == Some("1"), - bump_limit, - max_threads, - form.allow_images.as_deref() == Some("1"), - form.allow_video.as_deref() == Some("1"), - form.allow_audio.as_deref() == Some("1"), - form.allow_tripcodes.as_deref() == Some("1"), - edit_window_secs, - form.allow_editing.as_deref() == Some("1"), - form.allow_archive.as_deref() == Some("1"), - form.allow_video_embeds.as_deref() == Some("1"), - form.allow_captcha.as_deref() == Some("1"), - post_cooldown_secs, - )?; - info!("Admin updated settings for board id={board_id}"); - crate::templates::set_live_boards(db::get_all_boards(&conn)?); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel").into_response()) -} +// Backup and restore subsystem for the admin panel. +// Covers full-site backups, board-level backups, streaming downloads, +// saved-backup restoration, and live board.json restore. -// ─── GET /admin/backup ──────────────────────────────────────────────────────── +use crate::{ + config::CONFIG, + db, + error::{AppError, Result}, + middleware::AppState, + models::BackupInfo, + utils::crypto::new_session_id, +}; +use axum::{ + extract::{Form, Multipart, State}, + http::header, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use chrono::Utc; +use rusqlite::{backup::Backup, params}; +use serde::Deserialize; +use serde_json; +use std::io::{Seek, Write}; +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use time; +use tokio::io::AsyncWriteExt as _; +use tokio_util::io::ReaderStream; +use tracing::{info, warn}; -/// Stream a full zip backup of the database + all uploaded files. -/// -/// 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::too_many_lines)] 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 session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); let progress = state.backup_progress.clone(); @@ -1364,7 +42,7 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu let pool = state.db.clone(); move || -> Result<(PathBuf, String, u64)> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); @@ -1387,7 +65,7 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu // +1 for chan.db progress .files_total - .store(file_count + 1, Ordering::Relaxed); + .store(file_count.saturating_add(1), Ordering::Relaxed); // MEM-FIX: write zip directly to a NamedTempFile instead of Vec. let zip_tmp = tempfile::NamedTempFile::new() @@ -1404,7 +82,7 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu progress.reset(crate::middleware::backup_phase::COMPRESS); progress .files_total - .store(file_count + 1, Ordering::Relaxed); + .store(file_count.saturating_add(1), Ordering::Relaxed); // ── Database snapshot (streamed, not read into RAM) ──────── zip.start_file("chan.db", opts) @@ -1484,6 +162,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,12 +260,15 @@ 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, mut multipart: Multipart, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); // FIX[A7]: Stream the uploaded zip to a NamedTempFile on disk instead of // buffering the entire upload into a Vec. Full-site backups can be @@ -1673,7 +355,7 @@ pub async fn admin_restore( let mut live_conn = pool.get()?; // Save admin_id now — we'll need it to create a new session // in the restored DB once the backup completes. - let admin_id = require_admin_session_sid(&live_conn, session_id.as_deref())?; + let admin_id = super::require_admin_session_sid(&live_conn, session_id.as_deref())?; // ── Open the on-disk zip (FIX[A7]) ─────────────────────────── // reopen() gives a fresh File descriptor seeked to position 0, @@ -1794,11 +476,11 @@ pub async fn admin_restore( // If we got a valid session ID back, replace the cookie and go to the // panel. If not (admin didn't exist in the backup), go to login instead. if fresh_sid.is_empty() { - let jar = jar.remove(Cookie::from(SESSION_COOKIE)); + let jar = jar.remove(Cookie::from(super::SESSION_COOKIE)); return Ok((jar, Redirect::to("/admin")).into_response()); } - let mut new_cookie = Cookie::new(SESSION_COOKIE, fresh_sid); + let mut new_cookie = Cookie::new(super::SESSION_COOKIE, fresh_sid); new_cookie.set_http_only(true); new_cookie.set_same_site(SameSite::Strict); new_cookie.set_path("/"); @@ -1855,6 +537,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 +620,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, @@ -1971,36 +655,6 @@ fn copy_limited( /// Check CSRF using the cookie jar. Returns error on mismatch. /// Verify admin session and also return the admin's username. /// For use inside `spawn_blocking` closures. -fn require_admin_session_with_name( - conn: &rusqlite::Connection, - session_id: Option<&str>, -) -> Result<(i64, String)> { - let admin_id = require_admin_session_sid(conn, session_id)?; - let name = db::get_admin_name_by_id(conn, admin_id)?.unwrap_or_else(|| "unknown".to_string()); - Ok((admin_id, name)) -} - -fn check_csrf_jar(jar: &CookieJar, form_token: Option<&str>) -> Result<()> { - let cookie_token = jar.get("csrf_token").map(|c| c.value().to_string()); - if crate::middleware::validate_csrf(cookie_token.as_deref(), form_token.unwrap_or("")) { - Ok(()) - } else { - Err(AppError::Forbidden("CSRF token mismatch.".into())) - } -} - -/// Verify admin session from a session ID string. -/// For use inside `spawn_blocking` closures where we have an open connection. -fn require_admin_session_sid(conn: &rusqlite::Connection, session_id: Option<&str>) -> Result { - let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; - let session = db::get_session(conn, sid)? - .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; - Ok(session.admin_id) -} - -// ─── Backup directory helpers ──────────────────────────────────────────────── - -/// Returns the directory that contains chan.db (i.e. rustchan-data/). fn db_dir() -> PathBuf { PathBuf::from(&CONFIG.database_path) .parent() @@ -2018,7 +672,7 @@ pub fn board_backup_dir() -> PathBuf { } /// List `.zip` files in `dir`, newest-filename-first. -fn list_backup_files(dir: &std::path::Path) -> Vec { +pub fn list_backup_files(dir: &std::path::Path) -> Vec { let mut files = Vec::new(); if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { @@ -2065,13 +719,16 @@ 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, - Form(form): Form, + Form(form): Form, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; let upload_dir = CONFIG.upload_dir.clone(); let progress = state.backup_progress.clone(); @@ -2080,7 +737,7 @@ pub async fn create_full_backup( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); @@ -2103,7 +760,7 @@ pub async fn create_full_backup( let file_count = count_files_in_dir(uploads_base); progress .files_total - .store(file_count + 1, Ordering::Relaxed); + .store(file_count.saturating_add(1), Ordering::Relaxed); // MEM-FIX: write zip directly to a .tmp file on disk, not a Vec. let backup_dir = full_backup_dir(); @@ -2126,7 +783,7 @@ pub async fn create_full_backup( progress.reset(crate::middleware::backup_phase::COMPRESS); progress .files_total - .store(file_count + 1, Ordering::Relaxed); + .store(file_count.saturating_add(1), Ordering::Relaxed); // ── Database snapshot (streamed, not read into RAM) ──────── zip.start_file("chan.db", opts) @@ -2192,8 +849,10 @@ pub async fn create_board_backup( jar: CookieJar, Form(form): Form, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; let board_short = form .board_short @@ -2214,7 +873,7 @@ pub async fn create_board_backup( use board_backup_types::{BoardRow, ThreadRow, PostRow, PollRow, PollOptionRow, PollVoteRow, FileHashRow, BoardBackupManifest}; let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); let board: BoardRow = conn .query_row( @@ -2442,7 +1101,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( @@ -2509,14 +1168,16 @@ pub async fn download_backup( jar: CookieJar, axum::extract::Path((kind, filename)): axum::extract::Path<(String, String)>, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); // Auth check. tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; Ok(()) } }) @@ -2587,12 +1248,14 @@ pub async fn backup_progress_json( State(state): State, jar: CookieJar, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; Ok(()) } }) @@ -2632,8 +1295,10 @@ pub async fn delete_backup( jar: CookieJar, Form(form): Form, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; // Validate filename. let safe_filename: String = form @@ -2663,7 +1328,7 @@ pub async fn delete_backup( let pool = state.db.clone(); move || -> Result<()> { let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; let path = backup_dir.join(&safe_filename); if path.exists() { @@ -2691,13 +1356,16 @@ 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, Form(form): Form, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; let safe_filename: String = form .filename @@ -2725,7 +1393,7 @@ pub async fn restore_saved_full_backup( move || -> Result { let mut live_conn = pool.get()?; // Auth check first — only read the (potentially huge) file if valid. - let admin_id = require_admin_session_sid(&live_conn, session_id.as_deref())?; + let admin_id = super::require_admin_session_sid(&live_conn, session_id.as_deref())?; // MEM-FIX: open the zip as a seekable BufReader instead of // reading the whole file into a Vec. The FIX[A3] comment above @@ -2827,11 +1495,11 @@ pub async fn restore_saved_full_backup( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; if fresh_sid.is_empty() { - let jar = jar.remove(Cookie::from(SESSION_COOKIE)); + let jar = jar.remove(Cookie::from(super::SESSION_COOKIE)); return Ok((jar, Redirect::to("/admin")).into_response()); } - let mut new_cookie = Cookie::new(SESSION_COOKIE, fresh_sid); + let mut new_cookie = Cookie::new(super::SESSION_COOKIE, fresh_sid); new_cookie.set_http_only(true); new_cookie.set_same_site(SameSite::Strict); new_cookie.set_path("/"); @@ -2845,12 +1513,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, @@ -2867,8 +1537,10 @@ pub async fn restore_saved_board_backup( }) .collect() } - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; let safe_filename: String = form .filename @@ -2897,7 +1569,7 @@ pub async fn restore_saved_board_backup( let conn = pool.get()?; // Auth check first — only read the file if the session is valid. - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; // MEM-FIX: open the zip as BufReader instead of loading the // entire file into a Vec. Board backups can be hundreds of MB. @@ -3321,12 +1993,15 @@ 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, axum::extract::Path(board_short): axum::extract::Path, ) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); let progress = state.backup_progress.clone(); @@ -3336,7 +2011,7 @@ pub async fn board_backup( use board_backup_types::{BoardRow, ThreadRow, PostRow, PollRow, PollOptionRow, PollVoteRow, FileHashRow, BoardBackupManifest}; let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); let board: BoardRow = conn.query_row( @@ -3485,7 +2160,7 @@ pub async fn board_backup( let board_upload_path = uploads_base.join(&board_short); let file_count = count_files_in_dir(&board_upload_path); progress.reset(crate::middleware::backup_phase::COMPRESS); - progress.files_total.store(file_count + 1, Ordering::Relaxed); + progress.files_total.store(file_count.saturating_add(1), Ordering::Relaxed); let zip_tmp = tempfile::NamedTempFile::new() .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {e}")))?; @@ -3569,11 +2244,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, @@ -3601,7 +2278,9 @@ pub async fn board_restore( // Run the whole operation as a fallible async block so any early return // with Err(...) is caught below and turned into a redirect. let result: Result = async { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); // MEM-FIX: stream the uploaded file to a NamedTempFile on disk instead @@ -3685,7 +2364,7 @@ pub async fn board_restore( use std::io::Read; let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; // Detect format from the first four bytes (ZIP magic or JSON '{'). let mut magic = [0u8; 4]; @@ -4129,432 +2808,3 @@ pub async fn board_restore( .into_response(), } } - -// ─── POST /admin/site/settings ──────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct SiteSettingsForm { - #[serde(rename = "_csrf")] - pub csrf: Option, - /// Checkbox: present = "1", absent = not submitted (treat as false) - pub collapse_greentext: Option, - /// Custom site name (replaces [ `RustChan` ] on home page and footer). - pub site_name: Option, - /// Custom home page subtitle line below the site name. - pub site_subtitle: Option, - /// Default theme served to first-time visitors. - /// Valid slugs: terminal, aero, dorfic, fluorogrid, neoncubicle, chanclassic - pub default_theme: Option, -} - -pub async fn update_site_settings( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) - { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); - } - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - const VALID_THEMES: &[&str] = &[ - "terminal", - "aero", - "dorfic", - "fluorogrid", - "neoncubicle", - "chanclassic", - ]; - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - let val = if form.collapse_greentext.as_deref() == Some("1") { - "1" - } else { - "0" - }; - db::set_site_setting(&conn, "collapse_greentext", val)?; - info!("Admin updated site setting: collapse_greentext={val}"); - - // Save the custom site name (trimmed, max 64 chars). - let new_name = form - .site_name - .as_deref() - .unwrap_or("") - .trim() - .chars() - .take(64) - .collect::(); - db::set_site_setting(&conn, "site_name", &new_name)?; - // Update the in-memory live name so all pages reflect it immediately. - crate::templates::set_live_site_name(&new_name); - info!("Admin updated site name to: {:?}", new_name); - - // Save the custom subtitle. - let new_subtitle = form - .site_subtitle - .as_deref() - .unwrap_or("") - .trim() - .chars() - .take(128) - .collect::(); - db::set_site_setting(&conn, "site_subtitle", &new_subtitle)?; - crate::templates::set_live_site_subtitle(&new_subtitle); - info!("Admin updated site subtitle to: {:?}", new_subtitle); - - // Persist both values back to settings.toml so they survive a - // server restart without requiring a manual file edit. - crate::config::update_settings_file_site_names(&new_name, &new_subtitle); - info!("settings.toml updated with new site_name and site_subtitle"); - - // Save the default theme slug (validated against allowed values). - let new_theme = form - .default_theme - .as_deref() - .unwrap_or("terminal") - .trim() - .to_string(); - let new_theme = if VALID_THEMES.contains(&new_theme.as_str()) { - new_theme - } else { - "terminal".to_string() - }; - db::set_site_setting(&conn, "default_theme", &new_theme)?; - crate::templates::set_live_default_theme(&new_theme); - info!("Admin updated default theme to: {:?}", new_theme); - - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel?settings_saved=1").into_response()) -} - -// ─── POST /admin/vacuum ─────────────────────────────────────────────────────── -// -// Runs SQLite VACUUM to reclaim space after bulk deletions. -// Returns an inline result page showing DB size before and after. - -#[derive(Deserialize)] -pub struct VacuumForm { - #[serde(rename = "_csrf")] - pub csrf: Option, -} - -pub async fn admin_vacuum( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) - { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); - } - - let (jar, csrf) = ensure_csrf(jar); - - let html = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - let csrf_clone = csrf.clone(); - move || -> Result { - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - - let size_before = db::get_db_size_bytes(&conn).unwrap_or(0); - - db::run_vacuum(&conn)?; - - let size_after = db::get_db_size_bytes(&conn).unwrap_or(0); - - let saved = size_before.saturating_sub(size_after); - - info!( - "Admin ran VACUUM: {} → {} bytes ({} reclaimed)", - size_before, size_after, saved - ); - - Ok(crate::templates::admin_vacuum_result_page( - size_before, - size_after, - &csrf_clone, - )) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok((jar, Html(html)).into_response()) -} - -// ─── GET /admin/ip/{ip_hash} ────────────────────────────────────────────────── -// -// Shows all posts made by a given IP hash across all boards, newest first, -// with pagination. Requires an active admin session. - -#[derive(Deserialize)] -pub struct IpHistoryQuery { - #[serde(default = "default_page")] - pub page: i64, -} - -const fn default_page() -> i64 { - 1 -} - -pub async fn admin_ip_history( - State(state): State, - Path(ip_hash): Path, - Query(params): Query, - jar: CookieJar, -) -> Result<(CookieJar, Html)> { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let (jar, csrf) = ensure_csrf(jar); - let csrf_clone = csrf.clone(); - - // Sanitise the IP hash: must be exactly a SHA-256 hex string (64 hex chars). - // The previous guard used `> 64` which accepted any string of 0–64 chars, - // including an empty string. Require exactly 64. - if ip_hash.len() != 64 || !ip_hash.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(AppError::BadRequest("Invalid IP hash.".into())); - } - - let page = params.page.max(1); - - let html = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result { - const PER_PAGE: i64 = 25; - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - - let total = db::count_posts_by_ip_hash(&conn, &ip_hash)?; - let pagination = crate::models::Pagination::new(page, PER_PAGE, total); - let posts_with_boards = - db::get_posts_by_ip_hash(&conn, &ip_hash, PER_PAGE, pagination.offset())?; - - let all_boards = db::get_all_boards(&conn)?; - - Ok(crate::templates::admin_ip_history_page( - &ip_hash, - &posts_with_boards, - &pagination, - &all_boards, - &csrf_clone, - )) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok((jar, Html(html))) -} - -// ─── POST /admin/report/resolve ─────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ResolveReportForm { - report_id: i64, - /// Optional: also ban the reported post's author - ban_ip_hash: Option, - ban_reason: Option, - #[serde(rename = "_csrf")] - csrf: Option, -} - -pub async fn resolve_report( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Result { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form.csrf.as_deref())?; - - tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result<()> { - let conn = pool.get()?; - let (admin_id, admin_name) = - require_admin_session_with_name(&conn, session_id.as_deref())?; - - db::resolve_report(&conn, form.report_id, admin_id)?; - - // Optionally ban the reporter's target while resolving. - if let Some(ref ip) = form.ban_ip_hash { - let ip = ip.trim(); - if !ip.is_empty() { - // Validate the ip_hash is a well-formed SHA-256 hex string - // (64 hex chars) before inserting — guards against form tampering. - if ip.len() != 64 || !ip.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(AppError::BadRequest("Invalid IP hash format.".into())); - } - let reason = form.ban_reason.as_deref().unwrap_or("Reported content"); - db::add_ban(&conn, ip, reason, None)?; // permanent ban - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "ban", - "ban", - None, - "", - &format!("via report {} — {reason}", form.report_id), - ); - } - } - - let _ = db::log_mod_action( - &conn, - admin_id, - &admin_name, - "resolve_report", - "report", - Some(form.report_id), - "", - "", - ); - info!("Admin resolved report {}", form.report_id); - Ok(()) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok(Redirect::to("/admin/panel#reports").into_response()) -} - -// ─── GET /admin/mod-log ─────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct ModLogQuery { - #[serde(default = "default_mod_log_page")] - page: i64, -} - -const fn default_mod_log_page() -> i64 { - 1 -} - -pub async fn mod_log_page( - State(state): State, - jar: CookieJar, - Query(params): Query, -) -> Result<(CookieJar, Html)> { - let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - let (jar, csrf) = ensure_csrf(jar); - let csrf_clone = csrf.clone(); - let page = params.page.max(1); - - let html = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - move || -> Result { - const PER_PAGE: i64 = 50; - let conn = pool.get()?; - require_admin_session_sid(&conn, session_id.as_deref())?; - - let total = db::count_mod_log(&conn)?; - let pagination = crate::models::Pagination::new(page, PER_PAGE, total); - let entries = db::get_mod_log(&conn, PER_PAGE, pagination.offset())?; - let boards = db::get_all_boards(&conn)?; - Ok(crate::templates::mod_log_page( - &entries, - &pagination, - &csrf_clone, - &boards, - )) - } - }) - .await - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - - Ok((jar, Html(html))) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── login_ip_key ───────────────────────────────────────────────────────── - - #[test] - fn ip_key_is_hex_sha256() { - let key = login_ip_key("127.0.0.1"); - // SHA-256 produces 32 bytes = 64 hex chars - assert_eq!(key.len(), 64); - assert!(key.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn ip_key_same_ip_same_key() { - assert_eq!(login_ip_key("192.168.1.1"), login_ip_key("192.168.1.1")); - } - - #[test] - fn ip_key_different_ips_different_keys() { - assert_ne!(login_ip_key("192.168.1.1"), login_ip_key("192.168.1.2")); - } - - #[test] - fn ip_key_hides_raw_ip() { - // The raw IP should not appear anywhere in the hash output - let key = login_ip_key("10.0.0.1"); - assert!(!key.contains("10.0.0.1")); - } - - // ── is_login_locked ────────────────────────────────────────────────────── - - #[test] - fn fresh_ip_is_not_locked() { - let key = login_ip_key("test-fresh-ip-not-in-map"); - assert!(!is_login_locked(&key)); - } - - #[test] - fn locked_after_exceeding_fail_limit() { - // Use a unique key so parallel tests don't interfere - let key = login_ip_key("test-lock-unique-99887766"); - // Clean up any residue from a previous run - ADMIN_LOGIN_FAILS.remove(&key); - - let now = login_now_secs(); - // Insert exactly LOGIN_FAIL_LIMIT failures within the window - ADMIN_LOGIN_FAILS.insert(key.clone(), (LOGIN_FAIL_LIMIT, now)); - assert!(is_login_locked(&key)); - - // Cleanup - ADMIN_LOGIN_FAILS.remove(&key); - } - - #[test] - fn not_locked_below_fail_limit() { - let key = login_ip_key("test-below-limit-11223344"); - ADMIN_LOGIN_FAILS.remove(&key); - - let now = login_now_secs(); - ADMIN_LOGIN_FAILS.insert(key.clone(), (LOGIN_FAIL_LIMIT - 1, now)); - assert!(!is_login_locked(&key)); - - ADMIN_LOGIN_FAILS.remove(&key); - } - - #[test] - fn expired_window_is_not_locked() { - let key = login_ip_key("test-expired-window-55667788"); - ADMIN_LOGIN_FAILS.remove(&key); - - // window_start far in the past, beyond LOGIN_FAIL_WINDOW - let old_ts = login_now_secs().saturating_sub(LOGIN_FAIL_WINDOW + 60); - ADMIN_LOGIN_FAILS.insert(key.clone(), (LOGIN_FAIL_LIMIT + 10, old_ts)); - assert!(!is_login_locked(&key)); - - ADMIN_LOGIN_FAILS.remove(&key); - } -} diff --git a/src/handlers/admin/content.rs b/src/handlers/admin/content.rs new file mode 100644 index 0000000..c25066c --- /dev/null +++ b/src/handlers/admin/content.rs @@ -0,0 +1,424 @@ +// handlers/admin/content.rs +// +// Board, thread, and post management handlers. +// All routes require a valid admin session cookie. + +use crate::{ + config::CONFIG, + db, + error::{AppError, Result}, + middleware::AppState, +}; +use axum::{ + extract::{Form, State}, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; +use std::path::PathBuf; +use tracing::info; + +// ─── POST /admin/board/create ───────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct CreateBoardForm { + short_name: String, + name: String, + description: String, + nsfw: Option, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn create_board( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + // FIX[HIGH-3]: auth + DB write in spawn_blocking + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { + return Err(AppError::Forbidden("CSRF token mismatch.".into())); + } + + let short = form + .short_name + .trim() + .to_lowercase() + .chars() + .filter(char::is_ascii_alphanumeric) + .take(8) + .collect::(); + + if short.is_empty() { + return Err(AppError::BadRequest("Invalid board name.".into())); + } + + let nsfw = form.nsfw.as_deref() == Some("1"); + let name = form.name.trim().chars().take(64).collect::(); + let description = form + .description + .trim() + .chars() + .take(256) + .collect::(); + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + db::create_board(&conn, &short, &name, &description, nsfw)?; + info!("Admin created board /{short}/"); + // Refresh live board list so the top bar on any subsequent error + // page includes the newly created board. + crate::templates::set_live_boards(db::get_all_boards(&conn)?); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── POST /admin/board/delete ───────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct BoardIdForm { + board_id: i64, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn delete_board( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + let upload_dir = CONFIG.upload_dir.clone(); + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + + // Fetch the board's short_name before deletion so we can remove + // its upload directory entirely after cleaning tracked files. + let short_name: Option = conn + .query_row( + "SELECT short_name FROM boards WHERE id = ?1", + rusqlite::params![form.board_id], + |r| r.get(0), + ) + .ok(); + + // delete_board returns all file paths for posts in this board. + let paths = db::delete_board(&conn, form.board_id)?; + + // Delete every tracked file and thumbnail from disk. + for p in &paths { + crate::utils::files::delete_file(&upload_dir, p); + } + + // Remove the entire board upload directory — handles the thumbs/ + // sub-directory and any orphaned/untracked files too. + if let Some(short) = short_name { + let board_dir = PathBuf::from(&upload_dir).join(&short); + if board_dir.exists() { + if let Err(e) = std::fs::remove_dir_all(&board_dir) { + tracing::warn!("Could not remove board dir {:?}: {}", board_dir, e); + } + } + } + + info!( + "Admin deleted board id={} ({} file(s) removed)", + form.board_id, + paths.len() + ); + // Refresh live board list so the top bar immediately stops showing + // the deleted board — important because error pages use this cache. + crate::templates::set_live_boards(db::get_all_boards(&conn)?); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── POST /admin/thread/action ──────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ThreadActionForm { + thread_id: i64, + board: String, + action: String, // "sticky", "unsticky", "lock", "unlock" + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn thread_action( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + // Validate action before spawning to give early error + match form.action.as_str() { + "sticky" | "unsticky" | "lock" | "unlock" | "archive" => {} + _ => return Err(AppError::BadRequest("Unknown action.".into())), + } + + let action = form.action.clone(); + let thread_id = form.thread_id; + let board_for_log = form.board.clone(); + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + match action.as_str() { + "sticky" => db::set_thread_sticky(&conn, thread_id, true)?, + "unsticky" => db::set_thread_sticky(&conn, thread_id, false)?, + "lock" => db::set_thread_locked(&conn, thread_id, true)?, + "unlock" => db::set_thread_locked(&conn, thread_id, false)?, + "archive" => db::set_thread_archived(&conn, thread_id, true)?, + _ => {} + } + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + &action, + "thread", + Some(thread_id), + &board_for_log, + "", + ); + info!("Admin {action} thread {thread_id}"); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + // FIX[MEDIUM-10]: Use the board name from the DB (via the thread's board_id), + // not the user-supplied form.board, to prevent path-confusion redirects. + let redirect_url = { + let pool = state.db.clone(); + let board_name = tokio::task::spawn_blocking(move || -> Result { + let conn = pool.get()?; + let thread = db::get_thread(&conn, thread_id)?; + if let Some(t) = thread { + let boards = db::get_all_boards(&conn)?; + if let Some(b) = boards.iter().find(|b| b.id == t.board_id) { + return Ok(b.short_name.clone()); + } + } + // Fallback: sanitize the user-supplied board name to prevent open-redirect. + // Only allow alphanumeric characters (matching the board short_name format). + let safe: String = form + .board + .chars() + .filter(char::is_ascii_alphanumeric) + .take(8) + .collect(); + Ok(safe) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + // After archiving, send to the board archive; for all other actions + // stay on the thread. + if form.action == "archive" { + format!("/{board_name}/archive") + } else { + format!("/{board_name}/thread/{}", form.thread_id) + } + }; + + Ok(Redirect::to(&redirect_url).into_response()) +} + +// ─── POST /admin/post/delete ────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct AdminDeletePostForm { + post_id: i64, + board: String, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn admin_delete_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + let upload_dir = CONFIG.upload_dir.clone(); + let post_id = form.post_id; + + let redirect_board = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + + let post = db::get_post(&conn, post_id)? + .ok_or_else(|| AppError::NotFound("Post not found.".into()))?; + + // FIX[MEDIUM-10]: Resolve board name from DB, not user-supplied form field. + // Fallback sanitizes the user-supplied value to alphanumeric only. + let board_name = db::get_all_boards(&conn)? + .into_iter() + .find(|b| b.id == post.board_id) + .map_or_else( + || { + form.board + .chars() + .filter(char::is_ascii_alphanumeric) + .take(8) + .collect() + }, + |b| b.short_name, + ); + + let thread_id = post.thread_id; + let is_op = post.is_op; + + let paths = if post.is_op { + db::delete_thread(&conn, post.thread_id)? + } else { + db::delete_post(&conn, post_id)? + }; + + for p in paths { + crate::utils::files::delete_file(&upload_dir, &p); + } + + let action = if is_op { + "delete_thread" + } else { + "delete_post" + }; + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + action, + "post", + Some(post_id), + &board_name, + &post.body.chars().take(80).collect::(), + ); + info!("Admin deleted post {post_id}"); + // Return board_name + thread context so we can redirect back to the thread. + // If the post was an OP, redirect to the board index (thread is gone). + if is_op { + Ok(format!("/{board_name}")) + } else { + Ok(format!("/{board_name}/thread/{thread_id}")) + } + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to(&redirect_board).into_response()) +} + +// ─── POST /admin/thread/delete ──────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct AdminDeleteThreadForm { + thread_id: i64, + board: String, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn admin_delete_thread( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + let upload_dir = CONFIG.upload_dir.clone(); + let thread_id = form.thread_id; + + let redirect_board = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + + // FIX[MEDIUM-10]: Resolve board name from DB. + // Fallback sanitizes the user-supplied value to alphanumeric only. + let board_name = db::get_thread(&conn, thread_id)? + .and_then(|t| { + db::get_all_boards(&conn) + .ok()? + .into_iter() + .find(|b| b.id == t.board_id) + .map(|b| b.short_name) + }) + .unwrap_or_else(|| { + form.board + .chars() + .filter(char::is_ascii_alphanumeric) + .take(8) + .collect() + }); + + let paths = db::delete_thread(&conn, thread_id)?; + for p in paths { + crate::utils::files::delete_file(&upload_dir, &p); + } + + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "delete_thread", + "thread", + Some(thread_id), + &board_name, + "", + ); + info!("Admin deleted thread {thread_id}"); + Ok(board_name) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to(&format!("/{redirect_board}")).into_response()) +} diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs new file mode 100644 index 0000000..72a0c12 --- /dev/null +++ b/src/handlers/admin/mod.rs @@ -0,0 +1,207 @@ +// handlers/admin/mod.rs +// +// Admin panel. All routes require a valid session cookie. +// +// Authentication flow: +// 1. POST /admin/login → verify Argon2 password → create session in DB → set cookie +// 2. All /admin/* routes → check session cookie → get session from DB → proceed +// 3. POST /admin/logout → delete session from DB → clear cookie +// +// Session cookie: HTTPOnly (not readable by JS), SameSite=Strict (prevents CSRF). +// Secure=true when CHAN_HTTPS_COOKIES=true (default: same as CHAN_BEHIND_PROXY). +// +// FIX[HIGH-3] + FIX[MEDIUM-12]: All admin handlers now wrap DB and file I/O in +// spawn_blocking to avoid blocking the Tokio event loop. Direct DB calls from +// async context were stalling worker threads under concurrent load. + +pub mod auth; +pub use auth::*; + +pub mod backup; +pub use backup::*; + +pub mod content; +pub use content::*; + +pub mod moderation; +pub use moderation::*; + +pub mod settings; +pub use settings::*; + +use crate::{ + config::CONFIG, + db, + error::{AppError, Result}, + handlers::board::ensure_csrf, + middleware::AppState, +}; +use axum::{ + extract::{Query, State}, + response::Html, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; + +// ─── Shared constant ────────────────────────────────────────────────────────── + +const SESSION_COOKIE: &str = "chan_admin_session"; + +// ─── Shared form type used by auth and backup ───────────────────────────────── + +#[derive(Deserialize)] +pub struct CsrfOnly { + #[serde(rename = "_csrf")] + pub csrf: Option, + pub return_to: Option, +} + +// ─── Shared session helpers (used by all sub-modules) ──────────────────────── + +/// Verify admin session and also return the admin's username. +/// For use inside `spawn_blocking` closures. +fn require_admin_session_with_name( + conn: &rusqlite::Connection, + session_id: Option<&str>, +) -> Result<(i64, String)> { + let admin_id = require_admin_session_sid(conn, session_id)?; + let name = db::get_admin_name_by_id(conn, admin_id)?.unwrap_or_else(|| "unknown".to_string()); + Ok((admin_id, name)) +} + +/// Check CSRF using the cookie jar. Returns error on mismatch. +fn check_csrf_jar(jar: &CookieJar, form_token: Option<&str>) -> Result<()> { + let cookie_token = jar.get("csrf_token").map(|c| c.value().to_string()); + if crate::middleware::validate_csrf(cookie_token.as_deref(), form_token.unwrap_or("")) { + Ok(()) + } else { + Err(AppError::Forbidden("CSRF token mismatch.".into())) + } +} + +/// Verify admin session from a session ID string. +/// For use inside `spawn_blocking` closures where we have an open connection. +fn require_admin_session_sid(conn: &rusqlite::Connection, session_id: Option<&str>) -> Result { + let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; + let session = db::get_session(conn, sid)? + .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; + Ok(session.admin_id) +} + +// ─── GET /admin/panel ───────────────────────────────────────────────────────── + +/// Query params accepted by GET /admin/panel. +/// All fields are optional — missing = no flash message. +#[derive(Deserialize, Default)] +pub struct AdminPanelQuery { + /// Set by `board_restore` on success: the `short_name` of the restored board. + pub board_restored: Option, + /// Set by `board_restore` / `restore_saved_board_backup` on failure. + pub restore_error: Option, + /// Set by `update_site_settings` on success. + pub settings_saved: Option, +} + +pub async fn admin_panel( + State(state): State, + jar: CookieJar, + Query(params): Query, +) -> Result<(CookieJar, Html)> { + // FIX[HIGH-3]: Move auth check and all DB calls into spawn_blocking. + let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); + let (jar, csrf) = ensure_csrf(jar); + let csrf_clone = csrf.clone(); + + // Build the flash message from query params before entering spawn_blocking. + let flash: Option<(bool, String)> = if let Some(err) = params.restore_error { + Some((true, format!("Restore failed: {err}"))) + } else if let Some(board) = params.board_restored { + Some((false, format!("Board /{board}/ restored successfully."))) + } else if params.settings_saved.is_some() { + Some((false, "Site settings saved.".to_string())) + } else { + None + }; + + let html = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + let conn = pool.get()?; + + // Auth check inside blocking task + let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; + db::get_session(&conn, &sid)? + .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; + + let boards = db::get_all_boards(&conn)?; + let bans = db::list_bans(&conn)?; + let filters = db::get_word_filters(&conn)?; + let collapse_greentext = db::get_collapse_greentext(&conn); + let reports = db::get_open_reports(&conn)?; + let appeals = db::get_open_ban_appeals(&conn)?; + let site_name = db::get_site_name(&conn); + let site_subtitle = db::get_site_subtitle(&conn); + let default_theme = db::get_default_user_theme(&conn); + + // Collect saved backup file lists (read from disk, not DB). + let full_backups = list_backup_files(&full_backup_dir()); + let board_backups_list = list_backup_files(&board_backup_dir()); + + let db_size_bytes = db::get_db_size_bytes(&conn).unwrap_or(0); + + // 1.8: Compute whether the DB file size exceeds the configured + // warning threshold. Uses the on-disk file size (via fs::metadata) + // rather than SQLite's PRAGMA page_count estimate for accuracy, + // falling back to the pragma value if the path is unavailable. + let db_size_warning = if CONFIG.db_warn_threshold_bytes > 0 { + let file_size = std::fs::metadata(&CONFIG.database_path) + .map_or_else(|_| db_size_bytes.cast_unsigned(), |m| m.len()); + file_size >= CONFIG.db_warn_threshold_bytes + } else { + false + }; + + // Read the tor onion address from the hostname file if tor is enabled. + let tor_address: Option = if CONFIG.enable_tor_support { + let data_dir = std::path::PathBuf::from(&CONFIG.database_path) + .parent() + .map_or_else( + || std::path::PathBuf::from("."), + std::path::Path::to_path_buf, + ); + let hostname_path = data_dir.join("tor_hidden_service").join("hostname"); + std::fs::read_to_string(&hostname_path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + }; + + let flash_ref = flash.as_ref().map(|(is_err, msg)| (*is_err, msg.as_str())); + + Ok(crate::templates::admin_panel_page( + &boards, + &bans, + &filters, + collapse_greentext, + &csrf_clone, + &full_backups, + &board_backups_list, + db_size_bytes, + db_size_warning, + &reports, + &appeals, + &site_name, + &site_subtitle, + &default_theme, + tor_address.as_deref(), + flash_ref, + )) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok((jar, Html(html))) +} diff --git a/src/handlers/admin/moderation.rs b/src/handlers/admin/moderation.rs new file mode 100644 index 0000000..4b37424 --- /dev/null +++ b/src/handlers/admin/moderation.rs @@ -0,0 +1,585 @@ +// handlers/admin/moderation.rs +// +// Moderation handlers: bans, ban appeals, word filters, reports, IP history, +// and the mod log. All routes require a valid admin session cookie. + +use crate::{ + db, + error::{AppError, Result}, + middleware::AppState, +}; +use axum::{ + extract::{Form, Path, Query, State}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::cookie::CookieJar; +use chrono::Utc; +use serde::Deserialize; +use tracing::info; + +// ─── POST /admin/ban/add ────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct AddBanForm { + ip_hash: String, + reason: String, + duration_hours: Option, + #[serde(rename = "_csrf")] + csrf: Option, +} + +#[allow(clippy::arithmetic_side_effects)] +pub async fn add_ban( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + let expires_at = form + .duration_hours + .filter(|&h| h > 0) + // Cap at 87_600 hours (10 years) to prevent overflow in h * 3600. + // Permanent bans are represented by None (duration_hours absent or zero). + .map(|h| Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); + + let ip_hash_log = form.ip_hash.chars().take(8).collect::(); + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + db::add_ban(&conn, &form.ip_hash, &form.reason, expires_at)?; + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "ban", + "ban", + None, + "", + &format!("ip_hash={}… reason={}", &ip_hash_log, form.reason), + ); + info!("Admin added ban for ip_hash {ip_hash_log}…"); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── POST /admin/ban/remove ─────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct BanIdForm { + ban_id: i64, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn remove_ban( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + db::remove_ban(&conn, form.ban_id)?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── POST /admin/post/ban-delete ────────────────────────────────────────────── +// Inline ban + delete from the per-post admin toolbar. +// Bans the post author's IP hash, deletes the post, then redirects back to +// the thread (or the board index if the OP is deleted). + +#[derive(Deserialize)] +pub struct BanDeleteForm { + post_id: i64, + ip_hash: String, + board: String, + thread_id: i64, + is_op: Option, + reason: Option, + duration_hours: Option, + #[serde(rename = "_csrf")] + csrf: Option, +} + +#[allow(clippy::arithmetic_side_effects)] +pub async fn admin_ban_and_delete( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + let reason = form + .reason + .as_deref() + .map(|r| r.trim().to_string()) + .filter(|r| !r.is_empty()) + .unwrap_or_else(|| "Rule violation".to_string()); + + let expires_at = form + .duration_hours + .filter(|&h| h > 0) + .map(|h| chrono::Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); + + let ip_hash_log = form.ip_hash.chars().take(8).collect::(); + let post_id = form.post_id; + let board_short = form.board.clone(); + let thread_id = form.thread_id; + let is_op = form.is_op.as_deref() == Some("1"); + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + + // Validate ip_hash: must be a well-formed SHA-256 hex string (64 hex + // chars). The value comes from a form field in the post toolbar; a + // confused or tampered submission should be rejected cleanly. + if form.ip_hash.len() != 64 || !form.ip_hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AppError::BadRequest("Invalid IP hash format.".into())); + } + + // Ban first so the IP cannot re-post before the delete lands + db::add_ban(&conn, &form.ip_hash, &reason, expires_at)?; + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "ban", + "ban", + None, + &board_short, + &format!("inline ban — ip_hash={reason}… reason={}", &ip_hash_log), + ); + + // Delete post (or whole thread if OP) + if is_op { + let paths = db::delete_thread(&conn, thread_id)?; + for p in paths { + crate::utils::files::delete_file(&crate::config::CONFIG.upload_dir, &p); + } + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "delete_thread", + "thread", + Some(thread_id), + &board_short, + "", + ); + } else { + let paths = db::delete_post(&conn, post_id)?; + for p in paths { + crate::utils::files::delete_file(&crate::config::CONFIG.upload_dir, &p); + } + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "delete_post", + "post", + Some(post_id), + &board_short, + "", + ); + } + + info!("Admin ban+delete: post={post_id} ip_hash={ip_hash_log}… board={board_short}"); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + // FIX[A1]: form.board is user-supplied; sanitise to alphanumeric only before + // embedding in the redirect URL to prevent open-redirect via "//" prefixes. + let safe_board: String = form + .board + .chars() + .filter(char::is_ascii_alphanumeric) + .take(8) + .collect(); + // If OP was deleted, the thread is gone — send to board index + let redirect = if is_op { + format!("/{safe_board}") + } else { + format!("/{safe_board}/thread/{thread_id}#p{post_id}") + }; + Ok(Redirect::to(&redirect).into_response()) +} + +// ─── POST /admin/appeal/dismiss ─────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct AppealActionForm { + appeal_id: i64, + ip_hash: Option, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn dismiss_appeal( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + db::dismiss_ban_appeal(&conn, form.appeal_id)?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel#appeals").into_response()) +} + +// ─── POST /admin/appeal/accept ──────────────────────────────────────────────── + +pub async fn accept_appeal( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + let ip = form.ip_hash.as_deref().unwrap_or(""); + db::accept_ban_appeal(&conn, form.appeal_id, ip)?; + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "accept_appeal", + "ban", + None, + "", + &format!("appeal {} — ip unban", form.appeal_id), + ); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel#appeals").into_response()) +} + +// ─── POST /admin/filter/add ─────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct AddFilterForm { + pattern: String, + replacement: String, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn add_filter( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + if form.pattern.trim().is_empty() { + return Err(AppError::BadRequest("Pattern cannot be empty.".into())); + } + + let pattern = form.pattern.trim().chars().take(256).collect::(); + let replacement = form + .replacement + .trim() + .chars() + .take(256) + .collect::(); + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + db::add_word_filter(&conn, &pattern, &replacement)?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── POST /admin/filter/remove ──────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct FilterIdForm { + filter_id: i64, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn remove_filter( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + db::remove_word_filter(&conn, form.filter_id)?; + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── GET /admin/ip/{ip_hash} ────────────────────────────────────────────────── +// +// Shows all posts made by a given IP hash across all boards, newest first, +// with pagination. Requires an active admin session. + +#[derive(Deserialize)] +pub struct IpHistoryQuery { + #[serde(default = "default_page")] + pub page: i64, +} + +const fn default_page() -> i64 { + 1 +} + +pub async fn admin_ip_history( + State(state): State, + Path(ip_hash): Path, + Query(params): Query, + jar: CookieJar, +) -> Result<(CookieJar, Html)> { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + let (jar, csrf) = crate::handlers::board::ensure_csrf(jar); + let csrf_clone = csrf.clone(); + + // Sanitise the IP hash: must be exactly a SHA-256 hex string (64 hex chars). + // The previous guard used `> 64` which accepted any string of 0–64 chars, + // including an empty string. Require exactly 64. + if ip_hash.len() != 64 || !ip_hash.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(AppError::BadRequest("Invalid IP hash.".into())); + } + + let page = params.page.max(1); + + let html = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + const PER_PAGE: i64 = 25; + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + + let total = db::count_posts_by_ip_hash(&conn, &ip_hash)?; + let pagination = crate::models::Pagination::new(page, PER_PAGE, total); + let posts_with_boards = + db::get_posts_by_ip_hash(&conn, &ip_hash, PER_PAGE, pagination.offset())?; + + let all_boards = db::get_all_boards(&conn)?; + + Ok(crate::templates::admin_ip_history_page( + &ip_hash, + &posts_with_boards, + &pagination, + &all_boards, + &csrf_clone, + )) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok((jar, Html(html))) +} + +// ─── POST /admin/report/resolve ─────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ResolveReportForm { + report_id: i64, + /// Optional: also ban the reported post's author + ban_ip_hash: Option, + ban_reason: Option, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn resolve_report( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + let (admin_id, admin_name) = + super::require_admin_session_with_name(&conn, session_id.as_deref())?; + + db::resolve_report(&conn, form.report_id, admin_id)?; + + // Optionally ban the reporter's target while resolving. + if let Some(ref ip) = form.ban_ip_hash { + let ip = ip.trim(); + if !ip.is_empty() { + // Validate the ip_hash is a well-formed SHA-256 hex string + // (64 hex chars) before inserting — guards against form tampering. + if ip.len() != 64 || !ip.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AppError::BadRequest("Invalid IP hash format.".into())); + } + let reason = form.ban_reason.as_deref().unwrap_or("Reported content"); + db::add_ban(&conn, ip, reason, None)?; // permanent ban + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "ban", + "ban", + None, + "", + &format!("via report {} — {reason}", form.report_id), + ); + } + } + + let _ = db::log_mod_action( + &conn, + admin_id, + &admin_name, + "resolve_report", + "report", + Some(form.report_id), + "", + "", + ); + info!("Admin resolved report {}", form.report_id); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel#reports").into_response()) +} + +// ─── GET /admin/mod-log ─────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ModLogQuery { + #[serde(default = "default_mod_log_page")] + page: i64, +} + +const fn default_mod_log_page() -> i64 { + 1 +} + +pub async fn mod_log_page( + State(state): State, + jar: CookieJar, + Query(params): Query, +) -> Result<(CookieJar, Html)> { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + let (jar, csrf) = crate::handlers::board::ensure_csrf(jar); + let csrf_clone = csrf.clone(); + let page = params.page.max(1); + + let html = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + const PER_PAGE: i64 = 50; + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + + let total = db::count_mod_log(&conn)?; + let pagination = crate::models::Pagination::new(page, PER_PAGE, total); + let entries = db::get_mod_log(&conn, PER_PAGE, pagination.offset())?; + let boards = db::get_all_boards(&conn)?; + Ok(crate::templates::mod_log_page( + &entries, + &pagination, + &csrf_clone, + &boards, + )) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok((jar, Html(html))) +} diff --git a/src/handlers/admin/settings.rs b/src/handlers/admin/settings.rs new file mode 100644 index 0000000..d36e36e --- /dev/null +++ b/src/handlers/admin/settings.rs @@ -0,0 +1,414 @@ +// handlers/admin/settings.rs +// +// Board settings, site settings, and maintenance (vacuum) handlers. +// All routes require a valid admin session cookie. + +use crate::{ + config::CONFIG, + db, + error::{AppError, Result}, + handlers::board::ensure_csrf, + middleware::AppState, +}; +use axum::{ + extract::{Form, Query, State}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; +use tracing::info; + +// ─── POST /admin/board/settings ────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct BoardSettingsForm { + board_id: i64, + name: String, + description: String, + bump_limit: Option, + max_threads: Option, + nsfw: Option, + allow_images: Option, + allow_video: Option, + allow_audio: Option, + allow_tripcodes: Option, + allow_editing: Option, + edit_window_secs: Option, + allow_archive: Option, + allow_video_embeds: Option, + allow_captcha: Option, + post_cooldown_secs: Option, + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn update_board_settings( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + super::check_csrf_jar(&jar, form.csrf.as_deref())?; + + let bump_limit = form + .bump_limit + .as_deref() + .and_then(|v| v.parse::().ok()) + .unwrap_or(500) + .clamp(1, 10_000); + let max_threads = form + .max_threads + .as_deref() + .and_then(|v| v.parse::().ok()) + .unwrap_or(150) + .clamp(1, 1_000); + let edit_window_secs = form + .edit_window_secs + .as_deref() + .and_then(|v| v.parse::().ok()) + .unwrap_or(300) + .clamp(0, 86_400); // 0 = disabled, max 24 h + let post_cooldown_secs = form + .post_cooldown_secs + .as_deref() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0) + .clamp(0, 3_600); // 0 = disabled, max 1 hour + + // Enforce server-side length limits on free-text fields + let name = form.name.trim().chars().take(64).collect::(); + let description = form + .description + .trim() + .chars() + .take(256) + .collect::(); + let board_id = form.board_id; + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + db::update_board_settings( + &conn, + board_id, + &name, + &description, + form.nsfw.as_deref() == Some("1"), + bump_limit, + max_threads, + form.allow_images.as_deref() == Some("1"), + form.allow_video.as_deref() == Some("1"), + form.allow_audio.as_deref() == Some("1"), + form.allow_tripcodes.as_deref() == Some("1"), + edit_window_secs, + form.allow_editing.as_deref() == Some("1"), + form.allow_archive.as_deref() == Some("1"), + form.allow_video_embeds.as_deref() == Some("1"), + form.allow_captcha.as_deref() == Some("1"), + post_cooldown_secs, + )?; + info!("Admin updated settings for board id={board_id}"); + crate::templates::set_live_boards(db::get_all_boards(&conn)?); + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel").into_response()) +} + +// ─── POST /admin/site/settings ──────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct SiteSettingsForm { + #[serde(rename = "_csrf")] + pub csrf: Option, + /// Checkbox: present = "1", absent = not submitted (treat as false) + pub collapse_greentext: Option, + /// Custom site name (replaces [ `RustChan` ] on home page and footer). + pub site_name: Option, + /// Custom home page subtitle line below the site name. + pub site_subtitle: Option, + /// Default theme served to first-time visitors. + /// Valid slugs: terminal, aero, dorfic, fluorogrid, neoncubicle, chanclassic + pub default_theme: Option, +} + +pub async fn update_site_settings( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { + return Err(AppError::Forbidden("CSRF token mismatch.".into())); + } + + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + const VALID_THEMES: &[&str] = &[ + "terminal", + "aero", + "dorfic", + "fluorogrid", + "neoncubicle", + "chanclassic", + ]; + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + let val = if form.collapse_greentext.as_deref() == Some("1") { + "1" + } else { + "0" + }; + db::set_site_setting(&conn, "collapse_greentext", val)?; + info!("Admin updated site setting: collapse_greentext={val}"); + + // Save the custom site name (trimmed, max 64 chars). + let new_name = form + .site_name + .as_deref() + .unwrap_or("") + .trim() + .chars() + .take(64) + .collect::(); + db::set_site_setting(&conn, "site_name", &new_name)?; + // Update the in-memory live name so all pages reflect it immediately. + crate::templates::set_live_site_name(&new_name); + info!("Admin updated site name to: {:?}", new_name); + + // Save the custom subtitle. + let new_subtitle = form + .site_subtitle + .as_deref() + .unwrap_or("") + .trim() + .chars() + .take(128) + .collect::(); + db::set_site_setting(&conn, "site_subtitle", &new_subtitle)?; + crate::templates::set_live_site_subtitle(&new_subtitle); + info!("Admin updated site subtitle to: {:?}", new_subtitle); + + // Persist both values back to settings.toml so they survive a + // server restart without requiring a manual file edit. + crate::config::update_settings_file_site_names(&new_name, &new_subtitle); + info!("settings.toml updated with new site_name and site_subtitle"); + + // Save the default theme slug (validated against allowed values). + let new_theme = form + .default_theme + .as_deref() + .unwrap_or("terminal") + .trim() + .to_string(); + let new_theme = if VALID_THEMES.contains(&new_theme.as_str()) { + new_theme + } else { + "terminal".to_string() + }; + db::set_site_setting(&conn, "default_theme", &new_theme)?; + crate::templates::set_live_default_theme(&new_theme); + info!("Admin updated default theme to: {:?}", new_theme); + + Ok(()) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok(Redirect::to("/admin/panel?settings_saved=1").into_response()) +} + +// ─── POST /admin/vacuum ─────────────────────────────────────────────────────── +// +// Runs SQLite VACUUM to reclaim space after bulk deletions. +// Returns an inline result page showing DB size before and after. + +#[derive(Deserialize)] +pub struct VacuumForm { + #[serde(rename = "_csrf")] + pub csrf: Option, +} + +pub async fn admin_vacuum( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { + return Err(AppError::Forbidden("CSRF token mismatch.".into())); + } + + let (jar, csrf) = ensure_csrf(jar); + + let html = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + let csrf_clone = csrf.clone(); + move || -> Result { + let conn = pool.get()?; + super::require_admin_session_sid(&conn, session_id.as_deref())?; + + let size_before = db::get_db_size_bytes(&conn).unwrap_or(0); + + db::run_vacuum(&conn)?; + + let size_after = db::get_db_size_bytes(&conn).unwrap_or(0); + + let saved = size_before.saturating_sub(size_after); + + info!( + "Admin ran VACUUM: {} → {} bytes ({} reclaimed)", + size_before, size_after, saved + ); + + Ok(crate::templates::admin_vacuum_result_page( + size_before, + size_after, + &csrf_clone, + )) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok((jar, Html(html)).into_response()) +} + +// ─── GET /admin/panel ───────────────────────────────────────────────────────── + +/// Query params accepted by GET /admin/panel. +/// All fields are optional — missing = no flash message. +#[allow(dead_code)] +#[derive(Deserialize, Default)] +pub struct AdminPanelQuery { + /// Set by `board_restore` on success: the `short_name` of the restored board. + pub board_restored: Option, + /// Set by `board_restore` / `restore_saved_board_backup` on failure. + pub restore_error: Option, + /// Set by `update_site_settings` on success. + pub settings_saved: Option, +} + +#[allow(dead_code)] +pub async fn admin_panel( + State(state): State, + jar: CookieJar, + Query(params): Query, +) -> Result<(CookieJar, Html)> { + // FIX[HIGH-3]: Move auth check and all DB calls into spawn_blocking. + let session_id = jar + .get(super::SESSION_COOKIE) + .map(|c| c.value().to_string()); + let (jar, csrf) = ensure_csrf(jar); + let csrf_clone = csrf.clone(); + + // Build the flash message from query params before entering spawn_blocking. + let flash: Option<(bool, String)> = if let Some(err) = params.restore_error { + Some((true, format!("Restore failed: {err}"))) + } else if let Some(board) = params.board_restored { + Some((false, format!("Board /{board}/ restored successfully."))) + } else if params.settings_saved.is_some() { + Some((false, "Site settings saved.".to_string())) + } else { + None + }; + + let html = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + let conn = pool.get()?; + + // Auth check inside blocking task + let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; + db::get_session(&conn, &sid)? + .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; + + let boards = db::get_all_boards(&conn)?; + let bans = db::list_bans(&conn)?; + let filters = db::get_word_filters(&conn)?; + let collapse_greentext = db::get_collapse_greentext(&conn); + let reports = db::get_open_reports(&conn)?; + let appeals = db::get_open_ban_appeals(&conn)?; + let site_name = db::get_site_name(&conn); + let site_subtitle = db::get_site_subtitle(&conn); + let default_theme = db::get_default_user_theme(&conn); + + // Collect saved backup file lists (read from disk, not DB). + let full_backups = super::list_backup_files(&super::full_backup_dir()); + let board_backups_list = super::list_backup_files(&super::board_backup_dir()); + + let db_size_bytes = db::get_db_size_bytes(&conn).unwrap_or(0); + + // 1.8: Compute whether the DB file size exceeds the configured + // warning threshold. Uses the on-disk file size (via fs::metadata) + // rather than SQLite's PRAGMA page_count estimate for accuracy, + // falling back to the pragma value if the path is unavailable. + let db_size_warning = if CONFIG.db_warn_threshold_bytes > 0 { + let file_size = std::fs::metadata(&CONFIG.database_path) + .map_or_else(|_| db_size_bytes.cast_unsigned(), |m| m.len()); + file_size >= CONFIG.db_warn_threshold_bytes + } else { + false + }; + + // Read the tor onion address from the hostname file if tor is enabled. + let tor_address: Option = if CONFIG.enable_tor_support { + let data_dir = std::path::PathBuf::from(&CONFIG.database_path) + .parent() + .map_or_else( + || std::path::PathBuf::from("."), + std::path::Path::to_path_buf, + ); + let hostname_path = data_dir.join("tor_hidden_service").join("hostname"); + std::fs::read_to_string(&hostname_path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + }; + + let flash_ref = flash.as_ref().map(|(is_err, msg)| (*is_err, msg.as_str())); + + Ok(crate::templates::admin_panel_page( + &boards, + &bans, + &filters, + collapse_greentext, + &csrf_clone, + &full_backups, + &board_backups_list, + db_size_bytes, + db_size_warning, + &reports, + &appeals, + &site_name, + &site_subtitle, + &default_theme, + tor_address.as_deref(), + flash_ref, + )) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + Ok((jar, Html(html))) +} diff --git a/src/handlers/board.rs b/src/handlers/board.rs index 499fe62..75f008f 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, @@ -125,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 { @@ -209,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; @@ -225,7 +231,11 @@ pub async fn create_thread( // Also extract csrf_token before spawn_blocking so the ban page appeal form works. let ban_csrf_token = csrf_cookie.clone().unwrap_or_default(); - let _board_short_err = board_short.clone(); + // Clones kept outside the closure so we can re-render the board page inline on error. + let admin_session_err = admin_session_id.clone(); + let csrf_for_error = csrf_cookie.clone().unwrap_or_default(); + + let board_short_err = board_short.clone(); let result = tokio::task::spawn_blocking({ let pool = state.db.clone(); let job_queue = state.job_queue.clone(); @@ -311,6 +321,7 @@ pub async fn create_thread( max_video_size, max_audio_size, ffmpeg_available, + ffmpeg_webp_available, )?; // ── Image+audio combo ───────────────────────────────────────────── @@ -372,7 +383,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)?; } @@ -403,12 +414,60 @@ pub async fn create_thread( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - // BadRequest → return a lightweight 422 page instead of re-querying the - // entire board index (which wastes significant DB and CPU under spam load). + // BadRequest → re-render the board page with an inline error banner so the + // user sees the message in context without being sent to a separate error page. let redirect_url = match result { Ok(url) => url, Err(AppError::BadRequest(msg)) => { - let mut resp = Html(templates::error_page(422, &msg)).into_response(); + let board_short_render = board_short_err.clone(); + let pool = state.db.clone(); + let html = tokio::task::spawn_blocking(move || -> Result { + let conn = pool.get()?; + let is_admin = admin_session_err + .as_deref() + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); + let board = + db::get_board_by_short(&conn, &board_short_render)?.ok_or_else(|| { + AppError::NotFound(format!("Board /{board_short_render}/ not found")) + })?; + let total = db::count_threads_for_board(&conn, board.id)?; + let pagination = crate::models::Pagination::new(1, THREADS_PER_PAGE, total); + let threads = db::get_threads_for_board( + &conn, + board.id, + THREADS_PER_PAGE, + pagination.offset(), + )?; + let mut summaries = Vec::with_capacity(threads.len()); + for thread in threads { + let total_replies = thread.reply_count; + let preview = db::get_preview_posts(&conn, thread.id, PREVIEW_REPLIES)?; + #[allow(clippy::arithmetic_side_effects)] + let omitted = + (total_replies - i64::try_from(preview.len()).unwrap_or(0)).max(0); + summaries.push(ThreadSummary { + thread, + preview_posts: preview, + omitted, + }); + } + let all_boards = db::get_all_boards(&conn)?; + let collapse_greentext = db::get_collapse_greentext(&conn); + Ok(templates::board_page( + &board, + &summaries, + &pagination, + &csrf_for_error, + &all_boards, + is_admin, + Some(&msg), + collapse_greentext, + )) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + let mut resp = Html(html).into_response(); *resp.status_mut() = axum::http::StatusCode::UNPROCESSABLE_ENTITY; return Ok(resp); } @@ -599,6 +658,7 @@ pub struct ReportForm { pub thread_id: i64, pub board: String, pub reason: Option, + #[serde(rename = "_csrf")] pub csrf: Option, } @@ -673,7 +733,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. @@ -710,14 +801,28 @@ 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() .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() @@ -853,6 +958,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/mod.rs b/src/handlers/mod.rs index 796d09c..97e447e 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, @@ -259,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); @@ -290,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( @@ -315,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 6f44dd8..eb5bec3 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; @@ -160,6 +161,12 @@ pub async fn post_reply( // Also extract csrf_token before spawn_blocking so the ban page appeal form works. let ban_csrf_token = csrf_cookie.clone().unwrap_or_default(); + // Clones kept outside the closure so we can re-render the thread page inline on error. + let board_short_err = board_short.clone(); + let admin_session_err = admin_session_id.clone(); + let client_ip_err = client_ip.clone(); + let csrf_for_error = csrf_cookie.clone().unwrap_or_default(); + let _board_short_err = board_short.clone(); let result = tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -258,6 +265,7 @@ pub async fn post_reply( max_video_size, max_audio_size, ffmpeg_available, + ffmpeg_webp_available, )?; // ── Image+audio combo ───────────────────────────────────────────── @@ -327,13 +335,44 @@ pub async fn post_reply( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - // BadRequest → return a lightweight 422 page instead of re-querying the - // entire thread (which wastes significant DB and CPU under spam load). + // BadRequest → re-render the thread page with an inline error banner so the + // user sees the message in context (e.g. "wait for captcha to solve") without + // being redirected to a separate error page and losing their scroll position. let redirect_url = match result { Ok(url) => url, Err(AppError::BadRequest(msg)) => { - let mut resp = - axum::response::Html(crate::templates::error_page(422, &msg)).into_response(); + let db_pool = state.db.clone(); + let html = tokio::task::spawn_blocking(move || -> Result { + let conn = db_pool.get()?; + let is_admin = admin_session_err + .as_deref() + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); + let board = db::get_board_by_short(&conn, &board_short_err)?.ok_or_else(|| { + AppError::NotFound(format!("Board /{board_short_err}/ not found")) + })?; + let thread = db::get_thread(&conn, thread_id)? + .ok_or_else(|| AppError::NotFound("Thread not found.".into()))?; + let posts = db::get_posts_for_thread(&conn, thread_id)?; + let all_boards = db::get_all_boards(&conn)?; + let ip_hash = hash_ip(&client_ip_err, &CONFIG.cookie_secret); + let poll = db::get_poll_for_thread(&conn, thread_id, &ip_hash)?; + let collapse_greentext = db::get_collapse_greentext(&conn); + Ok(crate::templates::thread_page( + &board, + &thread, + &posts, + &csrf_for_error, + &all_boards, + is_admin, + poll.as_ref(), + Some(&msg), + collapse_greentext, + )) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + let mut resp = axum::response::Html(html).into_response(); *resp.status_mut() = axum::http::StatusCode::UNPROCESSABLE_ENTITY; return Ok(resp); } @@ -350,6 +389,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)>, @@ -415,6 +455,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, @@ -428,6 +469,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)>, @@ -535,6 +577,7 @@ pub async fn edit_post_post( #[derive(Deserialize)] pub struct VoteForm { + #[serde(rename = "_csrf")] pub csrf: Option, pub option_id: i64, } 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/logging.rs b/src/logging.rs new file mode 100644 index 0000000..15e7542 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,47 @@ +// logging.rs — Structured logging initialisation. +// +// Sets up two output layers: +// 1. stderr — human-readable, `info` and above (terminal/systemd journal) +// 2. file — JSON formatted, `debug` and above, written to +// `rustchan.log` inside `log_dir` +// +// Respects `RUST_LOG` env var if set. + +use std::path::Path; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +/// Initialise the global tracing subscriber. +/// +/// `log_dir` is the directory where `rustchan.log` will be written. +/// Typically this is the directory that contains the binary itself +/// (`std::env::current_exe()?.parent()`), so log output stays alongside +/// the executable and is easy to find. +/// +/// The file appender is non-rotating — a single `rustchan.log` is used for +/// the lifetime of the process. Rotation can be added later via +/// `tracing_appender::rolling::daily` / `hourly` without changing callers. +pub fn init_logging(log_dir: &Path) { + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("rustchan=info,tower_http=warn")); + + // stderr layer — compact human-readable output for the terminal. + let stderr_layer = fmt::layer().with_target(false).compact(); + + // File layer — JSON, includes file/line for structured log analysis. + // Uses `tracing_appender::rolling::never` so the file is never rotated + // automatically; restart the process to start a fresh log. + let file_appender = tracing_appender::rolling::never(log_dir, "rustchan.log"); + let file_layer = fmt::layer() + .json() + .with_writer(file_appender) + .with_target(true) + .with_file(true) + .with_line_number(true) + .with_span_events(fmt::format::FmtSpan::CLOSE); + + tracing_subscriber::registry() + .with(env_filter) + .with(stderr_layer) + .with(file_layer) + .init(); +} diff --git a/src/main.rs b/src/main.rs index 0d446d5..f2609cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,177 +14,55 @@ // // Data lives in /rustchan-data/ (override with CHAN_DB / CHAN_UPLOADS) // Static CSS is compiled into the binary — no external files needed. +// +// All HTTP server logic lives in server/server.rs. +// CLI types and admin commands live in server/cli.rs. +// Terminal console and startup banner live in server/console.rs. -use axum::{ - extract::DefaultBodyLimit, - http::{header, StatusCode}, - middleware as axum_middleware, - response::IntoResponse, - routing::{get, post}, - Router, -}; -use clap::{Parser, Subcommand}; -use dashmap::DashMap; -use std::io::{BufRead, BufReader, Write}; -use std::net::SocketAddr; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::LazyLock; -use std::time::{Duration, Instant}; -use tracing::info; -use tracing::Instrument as _; -use tracing_subscriber::{filter::EnvFilter, fmt}; +use clap::Parser; mod config; mod db; mod detect; mod error; mod handlers; +mod logging; +mod media; mod middleware; mod models; +mod server; mod templates; mod utils; mod workers; -use config::{check_cookie_secret_rotation, generate_settings_file_if_missing, CONFIG}; -use middleware::AppState; - -// ─── Embedded static assets ─────────────────────────────────────────────────── -static STYLE_CSS: &str = include_str!("../static/style.css"); -static MAIN_JS: &str = include_str!("../static/main.js"); -static THEME_INIT_JS: &str = include_str!("../static/theme-init.js"); - -// ─── Global terminal state ───────────────────────────────────────────────────── -/// Total HTTP requests handled since startup. -pub static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0); -/// Requests currently being processed (in-flight). -/// -/// FIX[AUDIT-1]: Changed from `AtomicI64` to `AtomicU64`. In-flight request -/// counts are inherently non-negative; using a signed type required defensive -/// `.max(0)` casts at every read site and masked counter underflow bugs. -/// Decrements use `ScopedDecrement` RAII guards (see below) to prevent -/// counter leaks when async futures are cancelled mid-flight. -static IN_FLIGHT: AtomicU64 = AtomicU64::new(0); -/// Multipart file uploads currently in progress. -/// -/// FIX[AUDIT-1]: Same signed→unsigned change as `IN_FLIGHT`. -static ACTIVE_UPLOADS: AtomicU64 = AtomicU64::new(0); -/// Monotonic tick used to animate the upload spinner. -static SPINNER_TICK: AtomicU64 = AtomicU64::new(0); -/// Recently active client IPs (last ~5 min); maps SHA-256(IP) → last-seen Instant. -/// CRIT-5: Keys are hashed so raw IP addresses are never retained in process -/// memory (or coredumps). The count is used for the "users online" display. -static ACTIVE_IPS: LazyLock> = LazyLock::new(DashMap::new); - -// ─── RAII counter guard ─────────────────────────────────────────────────────── -// -// FIX[AUDIT-2]: `IN_FLIGHT` and `ACTIVE_UPLOADS` are decremented inside -// `track_requests` *after* `.await`. If the surrounding future is cancelled -// (e.g. client disconnect, timeout, or panic in a handler), the post-await -// code never runs and the counters permanently over-count. -// -// `ScopedDecrement` ties the decrement to the guard's lifetime so it fires -// unconditionally via `Drop`, even when the future is dropped mid-flight. -// The decrement is saturating to prevent underflow on `AtomicU64`. -struct ScopedDecrement<'a>(&'a AtomicU64); - -impl Drop for ScopedDecrement<'_> { - fn drop(&mut self) { - // Saturating decrement: fetch_update retries on spurious failure. - let _ = self - .0 - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| { - Some(v.saturating_sub(1)) - }); - } -} - -// ─── CLI definition ─────────────────────────────────────────────────────────── - -#[derive(Parser)] -#[command( - name = "rustchan-cli", - about = "Self-contained imageboard server", - long_about = "RustChan Imageboard — single binary, zero dependencies.\n\ - Data is stored in ./rustchan-data/ next to the binary.\n\ - Run without arguments to start the server." -)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Command { - Serve { - #[arg(long, short = 'p')] - port: Option, - }, - Admin { - #[command(subcommand)] - action: AdminAction, - }, -} - -#[derive(Subcommand)] -enum AdminAction { - CreateAdmin { - username: String, - password: String, - }, - ResetPassword { - username: String, - new_password: String, - }, - ListAdmins, - CreateBoard { - short: String, - name: String, - #[arg(default_value = "")] - description: String, - #[arg(long)] - nsfw: bool, - /// Disable image uploads on this board (default: images allowed) - #[arg(long = "no-images")] - no_images: bool, - /// Disable video uploads on this board (default: video allowed) - #[arg(long = "no-videos")] - no_videos: bool, - /// Disable audio uploads on this board (default: audio allowed) - #[arg(long = "no-audio")] - no_audio: bool, - }, - DeleteBoard { - short: String, - }, - ListBoards, - Ban { - ip_hash: String, - reason: String, - hours: Option, - }, - Unban { - ban_id: i64, - }, - ListBans, -} +use config::CONFIG; // ─── Entry point ───────────────────────────────────────────────────────────── // -// 3.5: `#[tokio::main]` does not expose `max_blocking_threads`, so we build -// the runtime manually. The blocking thread pool (used by every -// `spawn_blocking` call — page renders, DB queries, file I/O) defaults to -// logical CPUs × 4 but can be tuned via `blocking_threads` in settings.toml -// or the CHAN_BLOCKING_THREADS environment variable. - +// `#[tokio::main]` does not expose `max_blocking_threads`, so we build the +// runtime manually. The blocking thread pool (used by every `spawn_blocking` +// call — page renders, DB queries, file I/O) defaults to 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( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("rustchan=info,tower_http=warn")), - ) - .with_target(false) - .compact() - .init(); + // Resolve the binary directory so the log file lands alongside the + // executable (e.g. /opt/rustchan/rustchan.log). Falls back to "." if the + // path cannot be determined. + let binary_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(std::path::PathBuf::from)) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + logging::init_logging(&binary_dir); + + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + log_dir = %binary_dir.display(), + "rustchan starting", + ); // CONFIG must be initialised before building the runtime so that // blocking_threads is available. This is safe because CONFIG is a @@ -197,1544 +75,18 @@ fn main() -> anyhow::Result<()> { .build() .expect("Failed to build Tokio runtime"); - let cli = Cli::parse(); + let cli = server::cli::Cli::parse(); rt.block_on(async move { match cli.command { - None | Some(Command::Serve { port: None }) => run_server(None).await, - Some(Command::Serve { port }) => run_server(port).await, - Some(Command::Admin { action }) => { - run_admin(action)?; - Ok(()) - } - } - }) -} - -// ─── Server mode ───────────────────────────────────────────────────────────── - -#[allow(clippy::too_many_lines)] -async fn run_server(port_override: Option) -> anyhow::Result<()> { - let early_data_dir = { - let exe = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|p: &std::path::Path| p.to_path_buf())) - .unwrap_or_else(|| std::path::PathBuf::from(".")); - exe.join("rustchan-data") - }; - std::fs::create_dir_all(&early_data_dir)?; - - generate_settings_file_if_missing(); - - // Validate critical configuration values immediately — fail fast with a - // clear error rather than discovering misconfiguration at runtime (#8). - CONFIG.validate()?; - - // Fix #9: Path::parent() on a bare filename (e.g. "rustchan.db") returns - // Some("") rather than None, so the old `unwrap_or(".")` never fired and - // `create_dir_all("")` would fail with NotFound. Treat an empty-string - // parent the same as a missing one. - let data_dir: std::path::PathBuf = { - let p = std::path::Path::new(&CONFIG.database_path); - match p.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(), - _ => std::path::PathBuf::from("."), - } - }; - - std::fs::create_dir_all(&data_dir)?; - std::fs::create_dir_all(&CONFIG.upload_dir)?; - - print_banner(); - - let bind_addr: String = port_override.map_or_else( - || CONFIG.bind_addr.clone(), - |p| { - // rsplit_once splits at the LAST colon only, which correctly handles - // both IPv4 ("0.0.0.0:8080") and IPv6 ("[::1]:8080") bind addresses. - // rsplit(':').nth(1) was incorrect for IPv6 — it returned "1]" instead - // of "[::1]" because rsplit splits on every colon in the address. - let host = CONFIG - .bind_addr - .rsplit_once(':') - .map_or("0.0.0.0", |(h, _)| h); - format!("{host}:{p}") - }, - ); - - let pool = db::init_pool()?; - first_run_check(&pool)?; - - // Check whether cookie_secret has changed since the last run (#19). - // Must run after DB init so the site_settings table exists. - if let Ok(conn) = pool.get() { - check_cookie_secret_rotation(&conn); - } - - // Initialise the live site name and subtitle from DB so they're available before any request. - { - if let Ok(conn) = pool.get() { - // Site name: use DB value if an admin has set one, otherwise seed - // from CONFIG.forum_name (settings.toml). Using get_site_setting - // (not get_site_name) lets us distinguish "never set" from "set to - // the default", so that editing forum_name in settings.toml and - // restarting takes effect when no admin override is in the DB. - let name_in_db = db::get_site_setting(&conn, "site_name") - .ok() - .flatten() - .filter(|v| !v.trim().is_empty()); - let name = name_in_db.unwrap_or_else(|| { - // Seed DB from settings.toml so get_site_name is always consistent. - let _ = db::set_site_setting(&conn, "site_name", &CONFIG.forum_name); - CONFIG.forum_name.clone() - }); - templates::set_live_site_name(&name); - - // Seed subtitle from settings.toml if not yet configured in DB. - // BUG FIX: get_site_subtitle() always returns a non-empty fallback - // string, so we must query the DB key directly to detect "never set". - let subtitle_in_db = db::get_site_setting(&conn, "site_subtitle") - .ok() - .flatten() - .filter(|v| !v.trim().is_empty()); - let subtitle = subtitle_in_db.unwrap_or_else(|| { - // Nothing in DB — seed from CONFIG (settings.toml). - let seed = if CONFIG.initial_site_subtitle.is_empty() { - "select board to proceed".to_string() - } else { - CONFIG.initial_site_subtitle.clone() - }; - let _ = db::set_site_setting(&conn, "site_subtitle", &seed); - seed - }); - templates::set_live_site_subtitle(&subtitle); - - // Seed default_theme from settings.toml if not yet configured in DB. - let default_theme = db::get_default_user_theme(&conn); - let default_theme = if default_theme.is_empty() - && !CONFIG.initial_default_theme.is_empty() - && CONFIG.initial_default_theme != "terminal" - { - let _ = db::set_site_setting(&conn, "default_theme", &CONFIG.initial_default_theme); - CONFIG.initial_default_theme.clone() - } else { - default_theme - }; - templates::set_live_default_theme(&default_theme); - - // Seed the live board list used by error pages and ban pages. - if let Ok(boards) = db::get_all_boards(&conn) { - templates::set_live_boards(boards); - } - } - } - // ── External tool detection ──────────────────────────────────────────────── - // 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; - - // Tor: create hidden-service directory + torrc, launch tor as a background - // process, and poll for the hostname file (all non-blocking). - // Fix #1: derive bind_port from `bind_addr` (which already incorporates - // port_override) rather than CONFIG.bind_addr. Previously, starting with - // `--port 9000` would still pass 8080 to Tor's HiddenServicePort. - // rsplit_once(':') handles both IPv4 ("0.0.0.0:9000") and IPv6 ("[::1]:9000"). - let bind_port = bind_addr - .rsplit_once(':') - .and_then(|(_, p)| p.parse::().ok()) - .unwrap_or(8080); - detect::detect_tor(CONFIG.enable_tor_support, bind_port, &data_dir); - println!(); - - let state = AppState { - db: pool.clone(), - ffmpeg_available, - job_queue: { - let q = std::sync::Arc::new(workers::JobQueue::new(pool.clone())); - workers::start_worker_pool(&q, ffmpeg_available); - q - }, - backup_progress: std::sync::Arc::new(middleware::BackupProgress::new()), - }; - // Keep a reference to the job queue cancel token for graceful shutdown (#7). - let worker_cancel = state.job_queue.cancel.clone(); - let start_time = Instant::now(); - - // Background: purge expired sessions hourly - { - let bg = pool.clone(); - tokio::spawn(async move { - let mut iv = tokio::time::interval(Duration::from_secs(3600)); - loop { - iv.tick().await; - if let Ok(conn) = bg.get() { - match db::purge_expired_sessions(&conn) { - Ok(n) if n > 0 => info!("Purged {n} expired sessions"), - Err(e) => tracing::error!("Session purge error: {e}"), - Ok(_) => {} - } - } - } - }); - } - - // Background: WAL checkpoint — prevent WAL files growing unbounded. - // Runs PRAGMA wal_checkpoint(TRUNCATE) at the configured interval, plus - // PRAGMA optimize to keep query-planner statistics current (#18). - if CONFIG.wal_checkpoint_interval > 0 { - let bg = pool.clone(); - let interval_secs = CONFIG.wal_checkpoint_interval; - tokio::spawn(async move { - // Stagger the first run by half the interval so it doesn't fire - // immediately at startup alongside the session purge. - tokio::time::sleep(Duration::from_secs(interval_secs / 2 + 1)).await; - let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); - loop { - iv.tick().await; - if let Ok(conn) = bg.get() { - match db::run_wal_checkpoint(&conn) { - Ok((pages, moved, backfill)) => { - tracing::debug!("WAL checkpoint: {pages} pages total, {moved} moved, {backfill} backfilled"); - } - Err(e) => tracing::warn!("WAL checkpoint failed: {e}"), - } - // Fix #7: reuse `conn` instead of calling bg.get() again. - // A second acquire while the first is still alive deadlocks - // with a pool size of 1. - let _ = conn.execute_batch("PRAGMA optimize;"); - } - } - }); - } - - // Background: prune stale IPs from ACTIVE_IPS every 5 min - tokio::spawn(async move { - let mut iv = tokio::time::interval(Duration::from_secs(300)); - loop { - iv.tick().await; - let cutoff = Instant::now() - .checked_sub(Duration::from_secs(300)) - .unwrap_or_else(Instant::now); - ACTIVE_IPS.retain(|_, last_seen| *last_seen > cutoff); - } - }); - - // Background: prune expired entries from ADMIN_LOGIN_FAILS every 5 min. - // Prevents unbounded growth under a sustained brute-force attack that - // never produces a successful login (which would trigger the existing - // opportunistic prune path inside clear_login_fails). - tokio::spawn(async move { - let mut iv = tokio::time::interval(Duration::from_secs(300)); - loop { - iv.tick().await; - crate::handlers::admin::prune_login_fails(); - } - }); - - // 1.6: Scheduled database VACUUM — reclaim disk space from deleted posts - // and threads without requiring manual admin intervention. - if CONFIG.auto_vacuum_interval_hours > 0 { - let bg = pool.clone(); - let interval_secs = CONFIG.auto_vacuum_interval_hours * 3600; - tokio::spawn(async move { - // Stagger the first run by half the interval to avoid hammering the - // DB immediately at startup alongside WAL checkpoint and session purge. - tokio::time::sleep(Duration::from_secs(interval_secs / 2 + 7)).await; - let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); - loop { - iv.tick().await; - let bg2 = bg.clone(); - tokio::task::spawn_blocking(move || { - if let Ok(conn) = bg2.get() { - let before = db::get_db_size_bytes(&conn).unwrap_or(0); - match db::run_vacuum(&conn) { - Ok(()) => { - let after = db::get_db_size_bytes(&conn).unwrap_or(0); - let saved = before.saturating_sub(after); - info!( - "Scheduled VACUUM complete: {} → {} bytes ({} reclaimed)", - before, after, saved - ); - } - Err(e) => tracing::warn!("Scheduled VACUUM failed: {e}"), - } - } - }) - .await - .ok(); - } - }); - } - - // 1.7: Expired poll vote cleanup — purge per-IP vote rows for polls whose - // expiry is older than poll_cleanup_interval_hours, preventing the - // poll_votes table from growing indefinitely. - if CONFIG.poll_cleanup_interval_hours > 0 { - let bg = pool.clone(); - let interval_secs = CONFIG.poll_cleanup_interval_hours * 3600; - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(600)).await; // initial delay - let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); - loop { - iv.tick().await; - let bg2 = bg.clone(); - let retention_cutoff_secs = interval_secs.cast_signed(); - tokio::task::spawn_blocking(move || { - if let Ok(conn) = bg2.get() { - let cutoff = chrono::Utc::now().timestamp() - retention_cutoff_secs; - match db::cleanup_expired_poll_votes(&conn, cutoff) { - Ok(n) if n > 0 => { - info!("Poll vote cleanup: removed {} expired vote row(s)", n); - } - Ok(_) => {} - Err(e) => tracing::warn!("Poll vote cleanup failed: {e}"), - } - } - }) - .await - .ok(); - } - }); - } - - // 2.6: Waveform/thumbnail cache eviction — keep total size of all thumbs - // directories under CONFIG.waveform_cache_max_bytes by deleting the oldest - // files when the threshold is exceeded. Waveform PNGs can be regenerated - // by re-enqueueing the AudioWaveform job; image thumbnails can be - // regenerated from the originals. Uses 1-hour intervals. - if CONFIG.waveform_cache_max_bytes > 0 { - let max_bytes = CONFIG.waveform_cache_max_bytes; - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(1800)).await; // initial stagger - let mut iv = tokio::time::interval(Duration::from_secs(3600)); - loop { - iv.tick().await; - let upload_dir = CONFIG.upload_dir.clone(); - tokio::task::spawn_blocking(move || { - evict_thumb_cache(&upload_dir, max_bytes); - }) - .await - .ok(); - } - }); - } - - let app = build_router(state); - let listener = tokio::net::TcpListener::bind(&bind_addr).await?; - info!("Listening on http://{bind_addr}"); - info!("Admin panel http://{bind_addr}/admin"); - info!("Data dir {}", data_dir.display()); - println!(); - - spawn_keyboard_handler(pool, start_time); - - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await?; - - // Signal background workers to drain and exit (#7). - info!("Signalling background workers to shut down…"); - worker_cancel.cancel(); - // Give workers up to 10 seconds to finish in-flight jobs. - tokio::time::sleep(Duration::from_secs(10)).await; - - info!("Server shut down gracefully."); - Ok(()) -} - -#[allow(clippy::too_many_lines)] -fn build_router(state: AppState) -> Router { - Router::new() - .route("/static/style.css", get(serve_css)) - .route("/static/main.js", get(serve_main_js)) - .route("/static/theme-init.js", get(serve_theme_init_js)) - .route("/", get(handlers::board::index)) - .route("/{board}", get(handlers::board::board_index)) - .route( - "/{board}", - post(handlers::board::create_thread).layer(DefaultBodyLimit::max( - CONFIG.max_video_size.max(CONFIG.max_audio_size), - )), - ) - .route("/{board}/catalog", get(handlers::board::catalog)) - .route("/{board}/archive", get(handlers::board::board_archive)) - .route("/{board}/search", get(handlers::board::search)) - .route("/{board}/thread/{id}", get(handlers::thread::view_thread)) - .route( - "/{board}/thread/{id}", - post(handlers::thread::post_reply).layer(DefaultBodyLimit::max( - CONFIG.max_video_size.max(CONFIG.max_audio_size), - )), - ) - .route( - "/{board}/post/{id}/edit", - get(handlers::thread::edit_post_get), - ) - .route( - "/{board}/post/{id}/edit", - post(handlers::thread::edit_post_post), - ) - .route( - "/report", - post(handlers::board::file_report).layer(DefaultBodyLimit::max(65_536)), - ) - .route( - "/appeal", - post(handlers::board::submit_appeal).layer(DefaultBodyLimit::max(65_536)), - ) - .route( - "/vote", - post(handlers::thread::vote_handler).layer(DefaultBodyLimit::max(65_536)), - ) - .route( - "/api/post/{board}/{post_id}", - get(handlers::board::api_post_preview), - ) - .route( - "/{board}/post/{post_id}", - get(handlers::board::redirect_to_post), - ) - .route( - "/admin/post/ban-delete", - post(handlers::admin::admin_ban_and_delete), - ) - .route( - "/admin/appeal/dismiss", - post(handlers::admin::dismiss_appeal), - ) - .route("/admin/appeal/accept", post(handlers::admin::accept_appeal)) - .route( - "/{board}/thread/{id}/updates", - get(handlers::thread::thread_updates), - ) - // Wildcard board media route: handles all /boards/** requests. - // For .mp4 files that have been transcoded away to .webm, issues a - // permanent redirect. All other paths are served directly from disk - // via tower-http ServeFile (Range, ETag, Content-Type handled correctly). - .route( - "/boards/{*media_path}", - get(handlers::board::serve_board_media), - ) - .route("/admin", get(handlers::admin::admin_index)) - .route( - "/admin/login", - post(handlers::admin::admin_login).layer(DefaultBodyLimit::max(65_536)), - ) - .route("/admin/logout", post(handlers::admin::admin_logout)) - .route("/admin/panel", get(handlers::admin::admin_panel)) - .route("/admin/board/create", post(handlers::admin::create_board)) - .route("/admin/board/delete", post(handlers::admin::delete_board)) - .route( - "/admin/board/settings", - post(handlers::admin::update_board_settings), - ) - .route("/admin/thread/action", post(handlers::admin::thread_action)) - .route( - "/admin/thread/delete", - post(handlers::admin::admin_delete_thread), - ) - .route( - "/admin/post/delete", - post(handlers::admin::admin_delete_post), - ) - .route("/admin/ban/add", post(handlers::admin::add_ban)) - .route("/admin/ban/remove", post(handlers::admin::remove_ban)) - .route( - "/admin/report/resolve", - post(handlers::admin::resolve_report), - ) - .route("/admin/mod-log", get(handlers::admin::mod_log_page)) - .route("/admin/filter/add", post(handlers::admin::add_filter)) - .route("/admin/filter/remove", post(handlers::admin::remove_filter)) - .route( - "/admin/site/settings", - post(handlers::admin::update_site_settings), - ) - .route("/admin/vacuum", post(handlers::admin::admin_vacuum)) - .route( - "/admin/ip/{ip_hash}", - get(handlers::admin::admin_ip_history), - ) - .route("/admin/backup", get(handlers::admin::admin_backup)) - // Admin restore routes have no body-size cap — backups can be multi-GB - // and these endpoints require a valid admin session, so there is no - // anonymous upload risk. - .route( - "/admin/restore", - post(handlers::admin::admin_restore).layer(DefaultBodyLimit::disable()), - ) - .route( - "/admin/board/backup/{board}", - get(handlers::admin::board_backup), - ) - .route( - "/admin/board/restore", - post(handlers::admin::board_restore).layer(DefaultBodyLimit::disable()), - ) - // ── Disk-based backup management routes ────────────────────────────── - .route( - "/admin/backup/create", - post(handlers::admin::create_full_backup), - ) - .route( - "/admin/board/backup/create", - post(handlers::admin::create_board_backup), - ) - .route( - "/admin/backup/download/{kind}/{filename}", - get(handlers::admin::download_backup), - ) - .route( - "/admin/backup/progress", - get(handlers::admin::backup_progress_json), - ) - .route("/admin/backup/delete", post(handlers::admin::delete_backup)) - .route( - "/admin/backup/restore-saved", - post(handlers::admin::restore_saved_full_backup), - ) - .route( - "/admin/board/backup/restore-saved", - post(handlers::admin::restore_saved_board_backup), - ) - .layer(axum_middleware::from_fn(middleware::rate_limit_middleware)) - .layer(DefaultBodyLimit::max(CONFIG.max_video_size)) - .layer(axum_middleware::from_fn(track_requests)) - // 3.3: Gzip/Brotli/Zstd response compression. HTML pages compress 5–10× - // with gzip and even better with Brotli. tower-http respects the client's - // Accept-Encoding header and negotiates the best supported algorithm. - // Applied before the trailing-slash normaliser so compressed responses - // are served correctly for all paths including redirects. - .layer(tower_http::compression::CompressionLayer::new()) - // Normalize trailing slashes before routing: redirect /path/ → /path (301). - // Applied last (outermost) so it fires before any other middleware sees the URI. - .layer(axum_middleware::from_fn( - middleware::normalize_trailing_slash, - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::HeaderName::from_static("x-content-type-options"), - header::HeaderValue::from_static("nosniff"), - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::HeaderName::from_static("x-frame-options"), - header::HeaderValue::from_static("SAMEORIGIN"), - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::HeaderName::from_static("referrer-policy"), - header::HeaderValue::from_static("same-origin"), - )) - // FIX[NEW-H1]: 'unsafe-inline' removed from script-src. All JavaScript - // has been moved to /static/main.js (loaded with 'self') and - // /static/theme-init.js. Inline event handlers (onclick= etc.) have - // been replaced with data-* attributes handled by main.js event - // delegation, so no inline script execution is required. - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::HeaderName::from_static("content-security-policy"), - header::HeaderValue::from_static( - "default-src 'self'; \ - script-src 'self'; \ - style-src 'self' 'unsafe-inline'; \ - img-src 'self' data: blob: https://img.youtube.com; \ - media-src 'self' blob:; \ - font-src 'self'; \ - connect-src 'self'; \ - frame-src https://www.youtube-nocookie.com https://streamable.com; \ - frame-ancestors 'none'; \ - object-src 'none'; \ - base-uri 'self'", - ), - )) - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - header::HeaderName::from_static("permissions-policy"), - header::HeaderValue::from_static( - "geolocation=(), camera=(), microphone=(), payment=()", - ), - )) - // Fix #8: HSTS (RFC 6797 §7.2) MUST only be sent over HTTPS. - // Sending it over plain HTTP (localhost dev, Tor .onion) is incorrect - // and can cause Tor-aware clients to misbehave. The middleware below - // checks both the request scheme and the X-Forwarded-Proto header - // (set by TLS-terminating proxies) before adding the header. - .layer(axum_middleware::from_fn(hsts_middleware)) - .with_state(state) -} - -/// Middleware that adds `Strict-Transport-Security` only when the connection -/// is confirmed to be HTTPS (RFC 6797 §7.2). Checks both the URI scheme -/// (set by some reverse proxies) and the `X-Forwarded-Proto` header. -async fn hsts_middleware( - req: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let is_https = req.uri().scheme_str() == Some("https") - || req - .headers() - .get("x-forwarded-proto") - .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v.eq_ignore_ascii_case("https")); - - let mut resp = next.run(req).await; - if is_https { - resp.headers_mut().insert( - header::HeaderName::from_static("strict-transport-security"), - header::HeaderValue::from_static("max-age=31536000; includeSubDomains"), - ); - } - resp -} - -async fn serve_css() -> impl IntoResponse { - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "text/css; charset=utf-8"), - (header::CACHE_CONTROL, "public, max-age=86400"), - ], - STYLE_CSS, - ) -} - -async fn serve_main_js() -> impl IntoResponse { - ( - StatusCode::OK, - [ - ( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ), - (header::CACHE_CONTROL, "public, max-age=86400"), - ], - MAIN_JS, - ) -} - -async fn serve_theme_init_js() -> impl IntoResponse { - ( - StatusCode::OK, - [ - ( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ), - (header::CACHE_CONTROL, "public, max-age=86400"), - ], - THEME_INIT_JS, - ) -} - -// ─── Request tracking middleware ────────────────────────────────────────────── - -async fn track_requests( - req: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - REQUEST_COUNT.fetch_add(1, Ordering::Relaxed); - IN_FLIGHT.fetch_add(1, Ordering::Relaxed); - - // FIX[AUDIT-2]: Bind the in-flight decrement to a RAII guard so it fires - // even if this future is cancelled (e.g. client disconnect, handler panic). - let _in_flight_guard = ScopedDecrement(&IN_FLIGHT); - - // Attach a per-request UUID to every tracing span so correlated log lines - // can be grouped by request even under concurrent load (#12). - let req_id = uuid::Uuid::new_v4(); - let method = req.method().clone(); - let path = req.uri().path().to_owned(); - let span = tracing::info_span!( - "request", - req_id = %req_id, - method = %method, - path = %path, - ); - - // Record the client IP for the "users online" display. - // CRIT-5: Store a SHA-256 hash of the IP (not the raw address) to avoid - // retaining PII in process memory and coredumps. - // CRIT-2: Use extract_ip() so proxy-forwarded real IPs are used instead - // of the raw socket address (which would always be the proxy's IP). - // Cap at 10,000 entries to prevent unbounded memory growth under a - // Sybil/bot attack rotating IPs (#11). The count is cosmetic so - // dropping inserts beyond the cap has no functional impact. - { - use sha2::{Digest, Sha256}; - let real_ip = middleware::extract_ip(&req); - let mut h = Sha256::new(); - h.update(real_ip.as_bytes()); - let ip_hash = hex::encode(h.finalize()); - if ACTIVE_IPS.len() < 10_000 { - ACTIVE_IPS.insert(ip_hash, Instant::now()); - } - } - - // Detect file uploads by Content-Type - let is_upload = req - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .is_some_and(|ct| ct.contains("multipart/form-data")); - - // FIX[AUDIT-2]: Bind upload decrement to a RAII guard for the same reason. - // Option is None when is_upload is false — zero-cost branch. - let _upload_guard = is_upload.then(|| { - ACTIVE_UPLOADS.fetch_add(1, Ordering::Relaxed); - ScopedDecrement(&ACTIVE_UPLOADS) - }); - - next.run(req).instrument(span).await -} - -// ─── First-run check ───────────────────────────────────────────────────────── - -fn first_run_check(pool: &db::DbPool) -> anyhow::Result<()> { - let conn = pool.get()?; - let board_count: i64 = conn - .query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0)) - .unwrap_or(0); - let admin_count: i64 = conn - .query_row("SELECT COUNT(*) FROM admin_users", [], |r| r.get(0)) - .unwrap_or(0); - - if board_count == 0 && admin_count == 0 { - println!(); - println!("╔══════════════════════════════════════════════════════╗"); - println!("║ FIRST RUN — SETUP REQUIRED ║"); - println!("╠══════════════════════════════════════════════════════╣"); - println!("║ No boards or admin accounts found. ║"); - // Fix #2: original line was only 40 display-columns wide (missing 16 spaces), - // breaking the box alignment. Padded to the correct inner width of 54. - println!("║ Create your first admin and boards: ║"); - println!("║ ║"); - println!("║ rustchan-cli admin create-admin admin mypassword ║"); - println!("║ rustchan-cli admin create-board b Random \"Anything\" ║"); - println!("║ rustchan-cli admin create-board tech Technology ║"); - println!("╚══════════════════════════════════════════════════════╝"); - println!(); - } - Ok(()) -} - -// ─── Terminal stats ─────────────────────────────────────────────────────────── - -struct TermStats { - prev_req_count: u64, - prev_post_count: i64, - prev_thread_count: i64, - last_tick: Instant, -} - -#[allow(clippy::too_many_lines)] -fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { - // Uptime - let uptime = start.elapsed(); - let h = uptime.as_secs() / 3600; - let m = (uptime.as_secs() % 3600) / 60; - - // req/s — delta since last tick - let now = Instant::now(); - let elapsed_secs = now.duration_since(ts.last_tick).as_secs_f64().max(0.001); - ts.last_tick = now; - let curr_reqs = REQUEST_COUNT.load(Ordering::Relaxed); - let req_delta = curr_reqs.saturating_sub(ts.prev_req_count); - #[allow(clippy::cast_precision_loss)] - let rps = req_delta as f64 / elapsed_secs; - ts.prev_req_count = curr_reqs; - - // DB query - let (boards, threads, posts, db_kb, board_stats) = pool.get().map_or_else( - |_| (0i64, 0i64, 0i64, 0i64, vec![]), - |conn| { - let b: i64 = conn - .query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0)) - .unwrap_or(0); - let th: i64 = conn - .query_row("SELECT COUNT(*) FROM threads", [], |r| r.get(0)) - .unwrap_or(0); - let p: i64 = conn - .query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0)) - .unwrap_or(0); - let kb: i64 = { - let pc: i64 = conn - .query_row("PRAGMA page_count", [], |r| r.get(0)) - .unwrap_or(0); - let ps: i64 = conn - .query_row("PRAGMA page_size", [], |r| r.get(0)) - .unwrap_or(4096); - pc * ps / 1024 - }; - let bs = get_per_board_stats(&conn); - (b, th, p, kb, bs) - }, - ); - - let upload_mb = dir_size_mb(&CONFIG.upload_dir); - - // New-event flash: bold+yellow when counts increased since last tick - let new_threads = (threads - ts.prev_thread_count).max(0); - let new_posts = (posts - ts.prev_post_count).max(0); - let thread_str = if new_threads > 0 { - format!("\x1b[1;33mthreads {threads} (+{new_threads})\x1b[0m") - } else { - format!("threads {threads}") - }; - let post_str = if new_posts > 0 { - format!("\x1b[1;33mposts {posts} (+{new_posts})\x1b[0m") - } else { - format!("posts {posts}") - }; - ts.prev_thread_count = threads; - ts.prev_post_count = posts; - - // Active connections / users online - // FIX[AUDIT-1]: IN_FLIGHT is now AtomicU64 — load directly, no .max(0) cast. - let in_flight = IN_FLIGHT.load(Ordering::Relaxed); - let online_count = ACTIVE_IPS.len(); - - // CRIT-5: Keys are SHA-256 hashes — show 8-char prefixes for diagnostics. - // FIX[AUDIT-3]: Use .get(..8) instead of direct [..8] byte-index so this - // stays safe if a key is ever shorter than 8 chars (defensive programming). - let ip_list: String = { - let mut hashes: Vec = ACTIVE_IPS - .iter() - .map(|e| { - let key = e.key(); - key.get(..8).unwrap_or(key.as_str()).to_string() - }) - .collect(); - hashes.sort_unstable(); - hashes.truncate(5); - if hashes.is_empty() { - "none".into() - } else { - hashes.join(", ") - } - }; - - // Upload progress bar — shown only while uploads are active - // FIX[AUDIT-1]: ACTIVE_UPLOADS is now AtomicU64 — load directly. - let active_uploads = ACTIVE_UPLOADS.load(Ordering::Relaxed); - if active_uploads > 0 { - // Fix #5: SPINNER_TICK was read but never written anywhere, so the - // spinner was permanently frozen on frame 0 ("⠋"). Increment it here, - // inside the only branch that actually displays the spinner. - let tick = SPINNER_TICK.fetch_add(1, Ordering::Relaxed); - let spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let spin = spinners - .get((usize::try_from(tick).unwrap_or(0)) % spinners.len()) - .copied() - .unwrap_or("⠋"); - let fill = ((tick % 20) as usize).min(10); - let bar = format!("{}{}", "█".repeat(fill), "░".repeat(10 - fill)); - println!(" \x1b[36m{spin} UPLOAD [{bar}] {active_uploads} file(s) uploading\x1b[0m"); - } - - // Main stats line - #[allow(clippy::cast_precision_loss)] - let stats_line = format!( - "── STATS uptime {h}h{m:02}m │ requests {curr_reqs} │ \x1b[32m{rps:.1} req/s\x1b[0m │ in-flight {in_flight} │ boards {boards} {thread_str} {post_str} │ db {db_kb} KiB uploads {upload_mb:.1} MiB ──" - ); - println!("{stats_line}"); - - // Users online line - let mem_rss = process_rss_kb(); - println!(" users online: {online_count} │ IPs: {ip_list} │ mem: {mem_rss} KiB RSS"); - - // Per-board breakdown - if !board_stats.is_empty() { - let segments: Vec = board_stats - .iter() - .map(|(short, t, p)| format!("/{short}/ threads:{t} posts:{p}")) - .collect(); - let mut line = String::from(" "); - let mut line_len = 0usize; - for seg in &segments { - if line_len > 0 && line_len + seg.len() + 5 > 110 { - println!("{line}"); - line = String::from(" "); - line_len = 0; + None | Some(server::cli::Command::Serve { port: None }) => { + server::run_server(None).await } - if line_len > 0 { - line.push_str(" │ "); - line_len += 5; - } - line.push_str(seg); - line_len += seg.len(); - } - if line_len > 0 { - println!("{line}"); - } - } -} - -/// Read the process RSS (resident set size) in KiB. -/// -/// * Linux — parsed from `/proc/self/status` (`VmRSS` field, already in KiB). -/// * macOS — Fix #11: spawns `ps -o rss= -p ` (output is KiB on macOS). -/// Previously this returned 0 on macOS, showing a misleading -/// `mem: 0 KiB RSS` in the terminal stats display. -/// * Other — returns 0 rather than showing a misleading value. -fn process_rss_kb() -> u64 { - #[cfg(target_os = "linux")] - { - if let Ok(s) = std::fs::read_to_string("/proc/self/status") { - for line in s.lines() { - if let Some(val) = line.strip_prefix("VmRSS:") { - return val - .split_whitespace() - .next() - .and_then(|n| n.parse().ok()) - .unwrap_or(0); - } - } - } - } - #[cfg(target_os = "macos")] - { - // `ps -o rss=` outputs the RSS in KiB on macOS (no header when '=' suffix used). - let pid = std::process::id().to_string(); - if let Ok(out) = std::process::Command::new("ps") - .args(["-o", "rss=", "-p", &pid]) - .output() - { - let s = String::from_utf8_lossy(&out.stdout); - if let Ok(kb) = s.trim().parse::() { - return kb; + Some(server::cli::Command::Serve { port }) => server::run_server(port).await, + Some(server::cli::Command::Admin { action }) => { + server::cli::run_admin(action)?; + Ok(()) } } - } - 0 -} - -fn get_per_board_stats(conn: &rusqlite::Connection) -> Vec<(String, i64, i64)> { - let Ok(mut stmt) = conn.prepare( - "SELECT b.short_name, \ - (SELECT COUNT(*) FROM threads WHERE board_id = b.id) AS tc, \ - (SELECT COUNT(*) FROM posts p \ - JOIN threads t ON p.thread_id = t.id \ - WHERE t.board_id = b.id) AS pc \ - FROM boards b ORDER BY b.short_name", - ) else { - return vec![]; - }; - stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - )) }) - .map(|rows| rows.flatten().collect()) - .unwrap_or_default() -} - -fn dir_size_mb(path: &str) -> f64 { - #[allow(clippy::cast_precision_loss)] - let mb = walkdir_size(std::path::Path::new(path)) as f64 / (1024.0 * 1024.0); - mb -} - -fn walkdir_size(path: &std::path::Path) -> u64 { - let Ok(entries) = std::fs::read_dir(path) else { - return 0; - }; - entries - .flatten() - .map(|e| { - // Fix #10: use file_type() from the DirEntry (does NOT follow - // symlinks) instead of Path::is_dir() (which does). A symlink - // loop via is_dir() causes unbounded recursion and a stack overflow. - let is_real_dir = e.file_type().is_ok_and(|ft| ft.is_dir()); - if is_real_dir { - walkdir_size(&e.path()) - } else { - e.metadata().map(|m| m.len()).unwrap_or(0) - } - }) - .sum() -} - -// ─── Startup banner ────────────────────────────────────────────────────────── - -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 - // │ character is always aligned regardless of the actual value length. - const INNER: usize = 53; - - // FIX[AUDIT-4]: The original closure collected chars into a `Vec` and - // had a dead `unwrap_or_else(|| s.clone())` branch — `get(..width)` on a - // slice of length >= width never returns None. The rewrite uses - // `chars().count()` (no heap allocation) and `chars().take(width).collect()` - // (single pass), eliminating both the intermediate Vec and the dead code. - let cell = |s: String, width: usize| -> String { - let char_count = s.chars().count(); - if char_count >= width { - s.chars().take(width).collect() - } else { - format!("{s}{}", " ".repeat(width - char_count)) - } - }; - - let title = cell( - format!("{} v{}", env!("CARGO_PKG_VERSION"), CONFIG.forum_name), - INNER - 2, // 2 leading spaces in "│ │" - ); - let bind = cell(CONFIG.bind_addr.clone(), INNER - 10); // "│ Bind <val>│" - let db = cell(CONFIG.database_path.clone(), INNER - 10); // "│ DB <val>│" - let upl = cell(CONFIG.upload_dir.clone(), INNER - 10); // "│ Uploads <val>│" - let img_mib = CONFIG.max_image_size / 1024 / 1024; - let vid_mib = CONFIG.max_video_size / 1024 / 1024; - let limits = cell( - format!("Images {img_mib} MiB max │ Videos {vid_mib} MiB max"), - INNER - 4, // "│ <val> │" - ); - - println!("┌─────────────────────────────────────────────────────┐"); - println!("│ {title}│"); - println!("├─────────────────────────────────────────────────────┤"); - println!("│ Bind {bind}│"); - println!("│ DB {db}│"); - println!("│ Uploads {upl}│"); - println!("│ {limits} │"); - println!("└─────────────────────────────────────────────────────┘"); -} - -// ─── Keyboard-driven admin console ─────────────────────────────────────────── - -// `reader` (BufReader<StdinLock>) must persist for the entire loop because it -// is passed by &mut into sub-commands; the lint's inline suggestion is invalid. -#[allow(clippy::significant_drop_tightening)] -fn spawn_keyboard_handler(pool: db::DbPool, start_time: Instant) { - std::thread::spawn(move || { - // Small delay so startup messages settle first - std::thread::sleep(Duration::from_millis(600)); - print_keyboard_help(); - - let stdin = std::io::stdin(); - - // Fix #4: TermStats must persist across keypresses so that - // prev_req_count/prev_post_count/prev_thread_count reflect the values - // at the *previous* 's' press, not zero. Initializing inside the - // match arm made every post/thread appear as "+N new" and reported the - // lifetime-average req/s instead of the current rate. - let mut persistent_stats = TermStats { - prev_req_count: REQUEST_COUNT.load(Ordering::Relaxed), - prev_post_count: 0, - prev_thread_count: 0, - last_tick: Instant::now(), - }; - - // Acquire stdin lock only after all pre-loop setup is complete so the - // StdinLock (significant Drop) is held for the shortest possible scope. - // `reader` is passed into kb_create_board / kb_delete_thread inside the - // loop, so it must live for the full loop scope and cannot be inlined. - let mut reader = BufReader::new(stdin.lock()); - - loop { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) | Err(_) => break, // EOF — stdin closed (daemon mode) - Ok(_) => {} - } - let cmd = line.trim().to_lowercase(); - match cmd.as_str() { - "s" => { - print_stats(&pool, start_time, &mut persistent_stats); - } - "l" => kb_list_boards(&pool), - "c" => kb_create_board(&pool, &mut reader), - "d" => kb_delete_thread(&pool, &mut reader), - "h" => print_keyboard_help(), - "q" => println!(" \x1b[33m[!]\x1b[0m Use Ctrl+C or SIGTERM to stop the server."), - "" => {} - other => println!(" Unknown command '{other}'. Press [h] for help."), - } - let _ = std::io::stdout().flush(); - } - }); -} - -fn print_keyboard_help() { - println!(); - println!(" \x1b[36m╔══ Admin Console ════════════════════════════════╗\x1b[0m"); - println!(" \x1b[36m║\x1b[0m [s] show stats now [l] list boards \x1b[36m║\x1b[0m"); - println!(" \x1b[36m║\x1b[0m [c] create board [d] delete thread \x1b[36m║\x1b[0m"); - println!(" \x1b[36m║\x1b[0m [h] help [q] quit hint \x1b[36m║\x1b[0m"); - println!(" \x1b[36m╚═════════════════════════════════════════════════╝\x1b[0m"); - println!(); -} - -fn kb_list_boards(pool: &db::DbPool) { - let Ok(conn) = pool.get() else { - println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); - return; - }; - let boards = match db::get_all_boards(&conn) { - Ok(b) => b, - Err(e) => { - println!(" \x1b[31m[err]\x1b[0m {e}"); - return; - } - }; - if boards.is_empty() { - println!(" No boards found."); - return; - } - println!(" {:<5} {:<12} {:<24} NSFW", "ID", "Short", "Name"); - println!(" {}", "─".repeat(48)); - for b in &boards { - println!( - " {:<5} /{:<11} {:<24} {}", - b.id, - format!("{}/", b.short_name), - b.name, - if b.nsfw { "yes" } else { "no" } - ); - } - println!(); -} - -fn kb_create_board(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { - let mut prompt = |msg: &str| -> String { - print!(" \x1b[36m{msg}\x1b[0m "); - let _ = std::io::stdout().flush(); - let mut s = String::new(); - let _ = reader.read_line(&mut s); - s.trim().to_string() - }; - - let short = prompt("Short name (e.g. 'tech'):"); - if short.is_empty() { - println!(" Aborted."); - return; - } - - // FIX[AUDIT-5]: Validate short name immediately after reading it, before - // prompting for the remaining fields. Previously validation happened at - // the bottom of the function, so the user would fill in all prompts before - // learning the short name was invalid. - let short_lc = short.to_lowercase(); - if short_lc.is_empty() - || short_lc.len() > 8 - || !short_lc.chars().all(|c| c.is_ascii_alphanumeric()) - { - println!(" \x1b[31m[err]\x1b[0m Short name must be 1-8 alphanumeric characters."); - return; - } - - let name = prompt("Display name:"); - if name.is_empty() { - println!(" Aborted."); - return; - } - let desc = prompt("Description (blank = none):"); - let nsfw_raw = prompt("NSFW board? [y/N]:"); - let nsfw = matches!(nsfw_raw.to_lowercase().as_str(), "y" | "yes"); - - // Fix #6: prompt for media flags and call create_board_with_media_flags so - // boards created from the console have the same capabilities as those - // created via `rustchan-cli admin create-board`. - let no_images_raw = prompt("Disable images? [y/N]:"); - let no_videos_raw = prompt("Disable video? [y/N]:"); - let no_audio_raw = prompt("Disable audio? [y/N]:"); - let allow_images = !matches!(no_images_raw.to_lowercase().as_str(), "y" | "yes"); - let allow_video = !matches!(no_videos_raw.to_lowercase().as_str(), "y" | "yes"); - let allow_audio = !matches!(no_audio_raw.to_lowercase().as_str(), "y" | "yes"); - - let Ok(conn) = pool.get() else { - println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); - return; - }; - match db::create_board_with_media_flags( - &conn, - &short_lc, - &name, - &desc, - nsfw, - allow_images, - allow_video, - allow_audio, - ) { - Ok(id) => println!( - " \x1b[32m✓\x1b[0m Board /{}/ — {}{} created (id={}). images:{} video:{} audio:{}", - short_lc, - name, - if nsfw { " [NSFW]" } else { "" }, - id, - if allow_images { "yes" } else { "no" }, - if allow_video { "yes" } else { "no" }, - if allow_audio { "yes" } else { "no" }, - ), - Err(e) => println!(" \x1b[31m[err]\x1b[0m {e}"), - } - println!(); -} - -fn kb_delete_thread(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { - print!(" \x1b[36mThread ID to delete:\x1b[0m "); - let _ = std::io::stdout().flush(); - let mut s = String::new(); - let _ = reader.read_line(&mut s); - let thread_id: i64 = if let Ok(n) = s.trim().parse() { - n - } else { - println!( - " \x1b[31m[err]\x1b[0m '{}' is not a valid thread ID.", - s.trim() - ); - return; - }; - - let Ok(conn) = pool.get() else { - println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); - return; - }; - let exists: i64 = conn - .query_row( - "SELECT COUNT(*) FROM threads WHERE id = ?1", - [thread_id], - |r| r.get(0), - ) - .unwrap_or(0); - if exists == 0 { - println!(" \x1b[31m[err]\x1b[0m Thread {thread_id} not found."); - return; - } - - print!(" \x1b[33mDelete thread {thread_id} and all its posts? [y/N]:\x1b[0m "); - let _ = std::io::stdout().flush(); - let mut confirm = String::new(); - let _ = reader.read_line(&mut confirm); - if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") { - println!(" Aborted."); - return; - } - - match db::delete_thread(&conn, thread_id) { - Ok(paths) => { - for p in &paths { - crate::utils::files::delete_file(&CONFIG.upload_dir, p); - } - println!( - " \x1b[32m✓\x1b[0m Thread {} deleted ({} file(s) removed).", - thread_id, - paths.len() - ); - } - Err(e) => println!(" \x1b[31m[err]\x1b[0m {e}"), - } - println!(); -} - -// ─── Graceful shutdown ──────────────────────────────────────────────────────── - -async fn shutdown_signal() { - use tokio::signal; - let ctrl_c = async { - if let Err(e) = signal::ctrl_c().await { - tracing::error!("Failed to listen for Ctrl+C: {e}"); - } - }; - #[cfg(unix)] - let terminate = async { - match signal::unix::signal(signal::unix::SignalKind::terminate()) { - Ok(mut sig) => { - sig.recv().await; - } - Err(e) => { - tracing::error!("Failed to register SIGTERM handler: {e}"); - std::future::pending::<()>().await; - } - } - }; - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - tokio::select! { - () = ctrl_c => info!("Received Ctrl+C"), - () = terminate => info!("Received SIGTERM"), - } -} - -// ─── Admin CLI mode ─────────────────────────────────────────────────────────── - -#[allow(clippy::too_many_lines)] -fn run_admin(action: AdminAction) -> anyhow::Result<()> { - use crate::{db, utils::crypto}; - use chrono::TimeZone; - use std::io::Write; - - let db_path = std::path::Path::new(&CONFIG.database_path); - - // FIX[AUDIT-6]: Apply the same Fix #9 empty-parent guard used in - // `run_server`. The original code used a plain `if let Some(parent)` - // check, which does NOT handle the case where `Path::parent()` returns - // `Some("")` for a bare filename (e.g. "rustchan.db"). - // `create_dir_all("")` fails with `NotFound`, so we normalise an empty - // parent to `"."` just as `run_server` does. - let db_parent: std::path::PathBuf = match db_path.parent() { - Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), - _ => std::path::PathBuf::from("."), - }; - std::fs::create_dir_all(&db_parent)?; - - let pool = db::init_pool()?; - let conn = pool.get()?; - - match action { - AdminAction::CreateAdmin { username, password } => { - validate_password(&password)?; - let hash = crypto::hash_password(&password)?; - let id = db::create_admin(&conn, &username, &hash)?; - println!("✓ Admin '{username}' created (id={id})."); - } - AdminAction::ResetPassword { - username, - new_password, - } => { - validate_password(&new_password)?; - db::get_admin_by_username(&conn, &username)? - .ok_or_else(|| anyhow::anyhow!("Admin '{username}' not found."))?; - let hash = crypto::hash_password(&new_password)?; - db::update_admin_password(&conn, &username, &hash)?; - println!("✓ Password updated for '{username}'."); - } - AdminAction::ListAdmins => { - let rows = db::list_admins(&conn)?; - if rows.is_empty() { - println!("No admins. Run: rustchan-cli admin create-admin <user> <pass>"); - } else { - println!("{:<6} {:<24} Created", "ID", "Username"); - println!("{}", "-".repeat(45)); - for (id, user, ts) in &rows { - let date = chrono::Utc - .timestamp_opt(*ts, 0) - .single() - .map_or_else(|| "?".to_string(), |d| d.format("%Y-%m-%d").to_string()); - println!("{id:<6} {user:<24} {date}"); - } - } - } - AdminAction::CreateBoard { - short, - name, - description, - nsfw, - no_images, - no_videos, - no_audio, - } => { - let short = short.to_lowercase(); - if short.is_empty() - || short.len() > 8 - || !short.chars().all(|c| c.is_ascii_alphanumeric()) - { - anyhow::bail!("Short name must be 1-8 alphanumeric chars (e.g. 'tech', 'b')."); - } - let allow_images = !no_images; - let allow_video = !no_videos; - let allow_audio = !no_audio; - let id = db::create_board_with_media_flags( - &conn, - &short, - &name, - &description, - nsfw, - allow_images, - allow_video, - allow_audio, - )?; - let nsfw_str = if nsfw { " [NSFW]" } else { "" }; - let media_info = format!( - " images:{} video:{} audio:{}", - if allow_images { "yes" } else { "no" }, - if allow_video { "yes" } else { "no" }, - if allow_audio { "yes" } else { "no" }, - ); - println!("✓ Board /{short}/ — {name}{nsfw_str} created (id={id}).{media_info}"); - } - AdminAction::DeleteBoard { short } => { - let board = db::get_board_by_short(&conn, &short)? - .ok_or_else(|| anyhow::anyhow!("Board /{short}/ not found."))?; - print!("Delete /{short}/ and ALL its content? Type 'yes' to confirm: "); - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if input.trim() != "yes" { - println!("Aborted."); - return Ok(()); - } - db::delete_board(&conn, board.id)?; - println!("✓ Board /{short}/ deleted."); - } - AdminAction::ListBoards => { - let boards = db::get_all_boards(&conn)?; - if boards.is_empty() { - println!("No boards. Run: rustchan-cli admin create-board <short> <n>"); - } else { - println!("{:<5} {:<12} {:<22} NSFW", "ID", "Short", "Name"); - println!("{}", "-".repeat(50)); - for b in &boards { - println!( - "{:<5} /{:<11} {:<22} {}", - b.id, - format!("{}/", b.short_name), - b.name, - if b.nsfw { "yes" } else { "no" } - ); - } - } - } - AdminAction::Ban { - ip_hash, - reason, - hours, - } => { - let expires = hours - .filter(|&h| h > 0) - .map(|h| chrono::Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); - let id = db::add_ban(&conn, &ip_hash, &reason, expires)?; - let exp_str = expires - .and_then(|ts| chrono::Utc.timestamp_opt(ts, 0).single()) - .map_or_else( - || "permanent".to_string(), - |d| d.format("%Y-%m-%d %H:%M UTC").to_string(), - ); - println!("✓ Ban #{id} added (expires: {exp_str})."); - } - AdminAction::Unban { ban_id } => { - db::remove_ban(&conn, ban_id)?; - println!("✓ Ban #{ban_id} lifted."); - } - AdminAction::ListBans => { - let bans = db::list_bans(&conn)?; - if bans.is_empty() { - println!("No active bans."); - } else { - println!( - "{:<5} {:<18} {:<28} Expires", - "ID", "IP Hash (partial)", "Reason" - ); - println!("{}", "-".repeat(75)); - for b in &bans { - // FIX[AUDIT-3]: Use .get(..16) for the same defensive - // safety as the ip_list slice above. - let partial = b.ip_hash.get(..16).unwrap_or(b.ip_hash.as_str()); - let expires = b - .expires_at - .and_then(|ts| chrono::Utc.timestamp_opt(ts, 0).single()) - .map_or_else( - || "Permanent".to_string(), - |d| d.format("%Y-%m-%d %H:%M").to_string(), - ); - let ban_id = b.id; - let reason = b.reason.as_deref().unwrap_or(""); - println!("{ban_id:<5} {partial:<18} {reason:<28} {expires}"); - } - } - } - } - Ok(()) -} - -// ─── 2.6 Waveform/thumbnail cache eviction ──────────────────────────────────── - -/// Walk every board's `thumbs/` subdirectory, collect all files with their -/// modification times, and delete the oldest ones until the total size of -/// the remaining set is under `max_bytes`. -/// -/// 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. -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(); - let Ok(boards_iter) = std::fs::read_dir(upload_dir) else { - return; - }; - for board_entry in boards_iter.flatten() { - let thumbs_dir = board_entry.path().join("thumbs"); - if !thumbs_dir.is_dir() { - continue; - } - let Ok(thumbs_iter) = std::fs::read_dir(&thumbs_dir) else { - continue; - }; - for entry in thumbs_iter.flatten() { - let path = entry.path(); - if let Ok(meta) = entry.metadata() { - if meta.is_file() { - let mtime = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map_or(0, |d| d.as_secs()); - files.push((mtime, path, meta.len())); - } - } - } - } - - // FIX[AUDIT-7]: Dereference `sz` explicitly and annotate the sum type for - // clarity. `Iterator<Item = &u64>` implements `Sum<&u64>` in std so this - // compiled before, but the explicit form is more readable and avoids the - // implicit coercion. - let total: u64 = files.iter().map(|(_, _, sz)| *sz).sum::<u64>(); - if total <= max_bytes { - return; // already within budget - } - - // Sort oldest-first so we delete the least-recently-used files first. - files.sort_unstable_by_key(|(mtime, _, _)| *mtime); - - let mut remaining = total; - let mut deleted = 0u64; - let mut deleted_bytes = 0u64; - for (_, path, size) in &files { - if remaining <= max_bytes { - break; - } - match std::fs::remove_file(path) { - Ok(()) => { - remaining = remaining.saturating_sub(*size); - deleted += 1; - // FIX[AUDIT-7]: Dereference `size` for clarity. - deleted_bytes += *size; - } - Err(e) => { - tracing::warn!("evict_thumb_cache: failed to delete {:?}: {}", path, e); - } - } - } - if deleted > 0 { - info!( - "evict_thumb_cache: removed {} file(s) ({} KiB), cache now {} KiB / {} KiB limit", - deleted, - deleted_bytes / 1024, - remaining / 1024, - max_bytes / 1024, - ); - } -} - -// ─── Password validation ────────────────────────────────────────────────────── - -/// Validate an admin password. -/// -/// FIX[AUDIT-8]: The original check only enforced a length of 8, which is too -/// weak for an admin credential on a security-critical service. Requirements -/// are now: -/// • Minimum 12 characters (NIST SP 800-63B §5.1.1 guidance) -/// • At least one uppercase ASCII letter -/// • At least one lowercase ASCII letter -/// • At least one ASCII digit -/// -/// These checks are intentionally simple (no special-char requirement) to -/// avoid overly prescriptive rules that lead to weaker real-world passwords. -fn validate_password(p: &str) -> anyhow::Result<()> { - if p.len() < 8 { - anyhow::bail!("Password must be at least 8 characters."); - } - Ok(()) } diff --git a/src/media/convert.rs b/src/media/convert.rs new file mode 100644 index 0000000..1a185a0 --- /dev/null +++ b/src/media/convert.rs @@ -0,0 +1,361 @@ +// media/convert.rs +// +// Per-format conversion logic. +// +// Conversion rules (from project spec): +// jpg / jpeg → WebP (quality 85, metadata stripped) +// 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 +// 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, GIF, BMP, TIFF). + ToWebp, + /// 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 { + // GIF → animated WebP: keeps the media type as Image so it renders in + // an <img> tag rather than a <video> player. The -loop 0 flag in + // ffmpeg_image_to_webp preserves animation for multi-frame GIFs. + // Falls back to storing the original GIF if libwebp is unavailable. + "image/jpeg" | "image/bmp" | "image/tiff" | "image/gif" => ConversionAction::ToWebp, + "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. +/// If `ffmpeg_webp_available` is `false`, WebP conversion is skipped even +/// when ffmpeg is otherwise available (e.g. stock build without libwebp). +/// +/// # 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. +/// * `ffmpeg_webp_available`— Whether ffmpeg has the libwebp encoder compiled in. +/// +/// # 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, + ffmpeg_webp_available: bool, +) -> Result<ConversionResult> { + let action = if ffmpeg_available { + let base = conversion_action(mime); + // Downgrade webp conversion actions if libwebp encoder is absent. + match base { + ConversionAction::ToWebp | ConversionAction::ToWebpIfSmaller + if !ffmpeg_webp_available => + { + ConversionAction::KeepAsIs + } + other => other, + } + } else { + ConversionAction::KeepAsIs + }; + + match action { + ConversionAction::ToWebp => convert_to_webp(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<ConversionResult> { + 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)?; + tracing::info!( + "image→webp: converted {} → {} ({final_size} bytes)", + input.display(), + output.display() + ); + 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)) + } + } +} + +/// 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<ConversionResult> { + 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<ConversionResult> { + 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<ConversionResult> { + 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. +/// +/// The temp file is given the same extension as `target` so that ffmpeg can +/// determine the output format from the filename. Without an extension, +/// ffmpeg cannot select the right muxer and fails immediately. +fn temp_sibling(target: &Path) -> PathBuf { + let ext = target.extension().and_then(|e| e.to_str()).unwrap_or(""); + let tmp_name = if ext.is_empty() { + format!(".tmp_{}", Uuid::new_v4().simple()) + } else { + format!(".tmp_{}.{ext}", 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<u64> { + 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_webp() { + assert_eq!(conversion_action("image/gif"), ConversionAction::ToWebp); + } + + #[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..332b1fc --- /dev/null +++ b/src/media/ffmpeg.rs @@ -0,0 +1,358 @@ +// 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, GIF, …). +/// +/// The `-loop 0` flag causes multi-frame inputs (GIF) to produce an animated +/// WebP that loops forever, matching the default GIF behaviour. For +/// single-frame inputs ffmpeg silently ignores the flag. +/// +/// # 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, + "-c:v", + "libwebp", + "-quality", + "85", + "-loop", + "0", + "-map_metadata", + "-1", + "-y", + out_str, + ]) + .with_context(|| format!("image→webp conversion failed for {in_str}")) +} + +/// Convert a GIF animation to `WebM` (VP9 codec) using ffmpeg. +/// +/// Retained for potential future use but no longer called by the conversion +/// pipeline — GIFs are now converted to animated WebP via +/// [`ffmpeg_image_to_webp`] so they remain in the Image media category and +/// render as `<img>` tags rather than `<video>` tags. +/// +/// # Errors +/// Returns an error if ffmpeg exits non-zero or cannot be spawned. +#[allow(dead_code)] +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, + "-c:v", + "libwebp", + "-quality", + "80", + "-y", + out_str, + ]) + .with_context(|| format!("thumbnail generation failed for {in_str}")) +} + +/// Probe the primary video codec of a media file using `ffprobe`. +/// +/// Returns the lowercase codec name (e.g. `"vp9"`, `"av1"`, `"h264"`) on +/// success. `ffprobe` must be on the same PATH as `ffmpeg`. +/// +/// # Errors +/// Returns an error if `ffprobe` cannot be spawned, exits non-zero, or its +/// output contains no recognisable codec name. +pub fn probe_video_codec(path: &str) -> Result<String> { + let output = std::process::Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=noprint_wrappers=1:nokey=1", + path, + ]) + .output() + .context("failed to spawn ffprobe — is it installed and on PATH?")?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "ffprobe exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let codec = String::from_utf8_lossy(&output.stdout) + .trim() + .to_ascii_lowercase(); + + if codec.is_empty() { + return Err(anyhow::anyhow!( + "ffprobe returned no codec name for: {path}" + )); + } + + Ok(codec) +} + +/// Transcode a video file to `WebM` (VP9 video + Opus audio) using ffmpeg. +/// +/// Uses constant-quality VP9 encoding (`-crf 30 -b:v 0`) and Opus audio at +/// 128 kbps. All metadata is stripped. Accepts MP4 or AV1 `WebM` inputs. +/// +/// # Errors +/// Returns an error if ffmpeg exits non-zero or cannot be spawned. +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", + "30", + "-b:v", + "0", + "-c:a", + "libopus", + "-b:a", + "128k", + "-map_metadata", + "-1", + "-y", + out_str, + ]) + .with_context(|| format!("video→webm transcode failed for {in_str}")) +} + +/// Generate a waveform PNG image for an audio file using ffmpeg's +/// `showwavespic` filter. +/// +/// The output image will be `width × height` pixels. The waveform is +/// rendered in a neutral grey (`0x888888`) suitable for both light and dark +/// page themes. +/// +/// # Errors +/// Returns an error if ffmpeg exits non-zero or cannot be spawned. +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 filter = format!("showwavespic=s={width}x{height}:colors=0x888888"); + + run_ffmpeg(&[ + "-loglevel", + "error", + "-i", + in_str, + "-filter_complex", + &filter, + "-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())) +} + +/// Probe whether the current ffmpeg binary has the `libwebp` encoder compiled in. +/// +/// Runs `ffmpeg -encoders` and scans stdout for a line containing `libwebp`. +/// Returns `true` only when ffmpeg is present AND the encoder is available. +/// +/// This is a synchronous, blocking call intended for use at server startup +/// inside a `spawn_blocking` task, or called directly from the startup path +/// where blocking is acceptable. +#[must_use] +pub fn check_webp_encoder() -> bool { + let output = Command::new("ffmpeg") + .args(["-encoders"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + stdout.lines().any(|line| line.contains("libwebp")) + } + Err(_) => false, + } +} + +/// Probe whether the current ffmpeg binary has the `libvpx-vp9` encoder compiled in. +/// +/// Required for MP4→WebM and WebM/AV1→WebM/VP9 transcoding. Runs +/// `ffmpeg -encoders` and scans stdout for a line containing `libvpx-vp9`. +/// Returns `true` only when ffmpeg is present AND the encoder is available. +/// +/// This is a synchronous, blocking call intended for use at server startup. +#[must_use] +pub fn check_vp9_encoder() -> bool { + let output = Command::new("ffmpeg") + .args(["-encoders"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + stdout.lines().any(|line| line.contains("libvpx-vp9")) + } + Err(_) => false, + } +} + +/// Probe whether the current ffmpeg binary has the `libopus` encoder compiled in. +/// +/// Required for audio encoding during MP4→WebM and WebM/AV1→WebM/VP9 transcoding. +/// Runs `ffmpeg -encoders` and scans stdout for a line containing `libopus`. +/// Returns `true` only when ffmpeg is present AND the encoder is available. +/// +/// This is a synchronous, blocking call intended for use at server startup. +#[must_use] +pub fn check_opus_encoder() -> bool { + let output = Command::new("ffmpeg") + .args(["-encoders"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + stdout.lines().any(|line| line.contains("libopus")) + } + Err(_) => false, + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..25b1709 --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,291 @@ +// 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, + /// Whether the libwebp encoder is compiled into the detected ffmpeg build. + /// Controls image→WebP conversion independently of video/audio capabilities. + pub ffmpeg_webp_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." + ); + } + let webp_available = available && ffmpeg::check_webp_encoder(); + Self { + ffmpeg_available: available, + ffmpeg_webp_available: webp_available, + } + } + + /// Create a `MediaProcessor` with pre-detected capability flags. + /// + /// Use this in request handlers to avoid re-detecting ffmpeg on every upload. + /// Both flags should come from `AppState` which is populated once at startup. + #[must_use] + pub const fn new_with_ffmpeg_caps(ffmpeg_available: bool, ffmpeg_webp_available: bool) -> Self { + Self { + ffmpeg_available, + ffmpeg_webp_available, + } + } + + /// Convenience constructor when only the base ffmpeg flag is known. + /// `ffmpeg_webp_available` defaults to the same value as `ffmpeg_available`. + /// Prefer [`new_with_ffmpeg_caps`](Self::new_with_ffmpeg_caps) in handlers. + #[must_use] + #[allow(dead_code)] + pub const fn new_with_ffmpeg(ffmpeg_available: bool) -> Self { + Self { + ffmpeg_available, + ffmpeg_webp_available: 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<ProcessedMedia> { + 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, + self.ffmpeg_webp_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, + self.ffmpeg_webp_available, + ); + + // generate_thumbnail returns the actual path written, which may differ + // from thumb_path when a video thumbnail falls back to an SVG placeholder + // (the pre-selected .webp extension would mismatch the SVG content). + let actual_thumb_path = match thumbnail::generate_thumbnail( + &conv.final_path, + conv.final_mime, + &thumb_path, + thumb_max, + self.ffmpeg_available, + self.ffmpeg_webp_available, + ) { + Ok(p) => p, + Err(e) => { + // Thumbnail failure must never abort an upload. Log and fall + // back to the pre-computed path (the thumbnail will be missing, + // but the upload still succeeds). + tracing::warn!("thumbnail generation failed: {e}"); + thumb_path + } + }; + + Ok(ProcessedMedia { + file_path: conv.final_path, + thumbnail_path: actual_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<PathBuf> { + let thumb_path = thumbnail::thumbnail_output_path( + thumb_dir, + file_stem, + mime, + self.ffmpeg_available, + self.ffmpeg_webp_available, + ); + + // Forward the actual path returned by generate_thumbnail (may differ from + // thumb_path when a video placeholder falls back to .svg extension). + thumbnail::generate_thumbnail( + input_path, + mime, + &thumb_path, + thumb_max, + self.ffmpeg_available, + self.ffmpeg_webp_available, + ) + } +} + +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..76a1b02 --- /dev/null +++ b/src/media/thumbnail.rs @@ -0,0 +1,337 @@ +// 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##"<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250" viewBox="0 0 250 250"> + <rect width="250" height="250" fill="#0a0f0a"/> + <circle cx="125" cy="125" r="60" fill="#0d120d" stroke="#00c840" stroke-width="2"/> + <polygon points="108,95 108,155 165,125" fill="#00c840"/> + <text x="125" y="215" text-anchor="middle" fill="#3a4a3a" font-family="monospace" font-size="12">VIDEO</text> +</svg>"##; + +const AUDIO_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250" viewBox="0 0 250 250"> + <rect width="250" height="250" fill="#0a0f0a"/> + <circle cx="125" cy="125" r="60" fill="#0d120d" stroke="#00c840" stroke-width="2"/> + <text x="125" y="140" text-anchor="middle" fill="#00c840" font-family="monospace" font-size="48">♫</text> + <text x="125" y="215" text-anchor="middle" fill="#3a4a3a" font-family="monospace" font-size="12">AUDIO</text> +</svg>"##; + +// ─── 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 (and its libwebp encoder) is available: +/// +/// | Source MIME | ffmpeg | libwebp | Action | +/// |-------------------|--------|---------|---------------------------------| +/// | `image/*` | yes | — | ffmpeg first-frame + WebP | +/// | `image/*` | no | — | `image` crate → resize → WebP | +/// | `video/webm` | yes | yes | ffmpeg first-frame + WebP | +/// | `video/webm` | yes | no | static SVG placeholder | +/// | `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. +/// * `ffmpeg_webp_available` — Whether ffmpeg has the libwebp encoder compiled in. +/// +/// # 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. +/// Generate a thumbnail and return the **actual path written**. +/// +/// The returned path may differ from `output_path` when a fallback SVG +/// placeholder is written for a video whose thumbnail extraction failed. +/// `thumbnail_output_path` selects `.webp` for video when ffmpeg+libwebp are +/// both present (because it cannot know ahead of time whether ffmpeg will +/// succeed). If extraction fails, writing SVG bytes into a `.webp` file +/// produces a file whose content and extension disagree — browsers reject it +/// and show a broken thumbnail. To avoid this, the video fallback writes the +/// placeholder to a `.svg` sibling path instead and returns that path, so the +/// caller can store the correct path in the database. +pub fn generate_thumbnail( + input_path: &Path, + mime: &str, + output_path: &Path, + max_dim: u32, + ffmpeg_available: bool, + ffmpeg_webp_available: bool, +) -> Result<PathBuf> { + match mime { + // ── SVG and audio: always use static placeholder ────────────────── + "image/svg+xml" => write_placeholder(output_path, PlaceholderKind::Video) + .map(|()| output_path.to_path_buf()), + m if m.starts_with("audio/") => write_placeholder(output_path, PlaceholderKind::Audio) + .map(|()| output_path.to_path_buf()), + + // ── Video (WebM, MP4, and any other video/*): requires ffmpeg AND libwebp ───────────────────── + // `thumbnail_output_path` pre-selects `.webp` when both are present. + // If ffmpeg_thumbnail then fails, write the SVG placeholder to the + // `.svg`-extension sibling so the file content and extension match. + // The `else` branch (ffmpeg absent / libwebp absent) already has the + // `.svg` extension pre-selected by `thumbnail_output_path`, so no + // rename is needed there. + m if m.starts_with("video/") => { + if ffmpeg_available && ffmpeg_webp_available { + match ffmpeg::ffmpeg_thumbnail(input_path, output_path, max_dim) { + Ok(()) => Ok(output_path.to_path_buf()), + Err(e) => { + tracing::warn!("ffmpeg video thumbnail failed ({}); using placeholder", e); + // Write the SVG placeholder with a .svg extension so its + // content and file extension agree. Browsers that receive + // SVG bytes served as image/webp silently show nothing. + let svg_path = output_path.with_extension("svg"); + write_placeholder(&svg_path, PlaceholderKind::Video).map(|()| svg_path) + } + } + } else { + // output_path already has .svg extension in this branch. + write_placeholder(output_path, PlaceholderKind::Video) + .map(|()| output_path.to_path_buf()) + } + } + + // ── WebP: skip ffmpeg entirely — use image crate directly ───────── + // ffmpeg fails on animated WebP (VP8L) and emits a spurious warning + // even though the image crate handles all WebP variants correctly. + "image/webp" => image_crate_thumbnail(input_path, mime, output_path, max_dim) + .map(|()| output_path.to_path_buf()), + + // ── Other 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(output_path.to_path_buf()), + Err(e) => { + tracing::warn!( + "ffmpeg image thumbnail failed ({}); falling back to image crate", + e + ); + image_crate_thumbnail(input_path, mime, output_path, max_dim) + .map(|()| output_path.to_path_buf()) + } + } + } else { + image_crate_thumbnail(input_path, mime, output_path, max_dim) + .map(|()| output_path.to_path_buf()) + } + } + + // ── Unknown MIME: placeholder ───────────────────────────────────── + _ => write_placeholder(output_path, PlaceholderKind::Video) + .map(|()| output_path.to_path_buf()), + } +} + +/// 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 or libwebp, 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. +/// * `ffmpeg_webp_available` — Whether ffmpeg has the libwebp encoder. +#[must_use] +pub fn thumbnail_output_path( + thumb_dir: &Path, + file_stem: &str, + mime: &str, + ffmpeg_available: bool, + ffmpeg_webp_available: bool, +) -> PathBuf { + let ext = thumbnail_extension(mime, ffmpeg_available, ffmpeg_webp_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<ImageFormat> { + 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). +/// +/// For `video/webm`, a WebP thumbnail can only be produced when ffmpeg is +/// available AND the `libwebp` encoder is compiled in. When either is +/// absent, `ffmpeg_thumbnail` will fail and `write_placeholder` will be +/// called — we must pre-select `.svg` so the placeholder is written to a +/// path whose extension matches its actual SVG content. Mismatching the +/// extension (SVG bytes in a `.webp` file) causes browsers to reject the +/// file and display nothing. +fn thumbnail_extension( + mime: &str, + ffmpeg_available: bool, + ffmpeg_webp_available: bool, +) -> &'static str { + match mime { + "image/svg+xml" => "svg", + m if m.starts_with("audio/") => "svg", + // Video thumbnails need both ffmpeg (to demux the stream) AND libwebp + // (to encode the extracted frame as WebP). If either is missing the + // fallback is an SVG placeholder. This applies to all video/* types, + // not just WebM — MP4 and any other video format go through ffmpeg the + // same way and need the same extension pre-selection logic. + m if m.starts_with("video/") && (!ffmpeg_available || !ffmpeg_webp_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, false), "webp"); + assert_eq!(thumbnail_extension("image/png", false, false), "webp"); + assert_eq!(thumbnail_extension("image/webp", false, false), "webp"); + } + + #[test] + fn thumbnail_ext_is_svg_for_video_without_ffmpeg() { + assert_eq!(thumbnail_extension("video/webm", false, false), "svg"); + assert_eq!(thumbnail_extension("video/mp4", false, false), "svg"); + } + + #[test] + fn thumbnail_ext_is_svg_for_video_with_ffmpeg_but_no_webp() { + // ffmpeg available but libwebp missing — placeholder path must be .svg + assert_eq!(thumbnail_extension("video/webm", true, false), "svg"); + assert_eq!(thumbnail_extension("video/mp4", true, false), "svg"); + } + + #[test] + fn thumbnail_ext_is_webp_for_video_with_ffmpeg_and_webp() { + assert_eq!(thumbnail_extension("video/webm", true, true), "webp"); + assert_eq!(thumbnail_extension("video/mp4", true, true), "webp"); + } + + #[test] + fn thumbnail_ext_is_svg_for_audio() { + assert_eq!(thumbnail_extension("audio/mpeg", true, true), "svg"); + assert_eq!(thumbnail_extension("audio/mpeg", false, false), "svg"); + } + + #[test] + fn thumbnail_ext_is_svg_for_svg_source() { + assert_eq!(thumbnail_extension("image/svg+xml", true, true), "svg"); + assert_eq!(thumbnail_extension("image/svg+xml", false, false), "svg"); + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index b87d064..a1f3cce 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -122,6 +122,9 @@ pub struct AppState { /// True when ffmpeg was detected at startup (set by `detect::detect_ffmpeg`). /// Passed to file handling to enable/disable video thumbnail generation. pub ffmpeg_available: bool, + /// True when ffmpeg was detected AND the libwebp encoder is compiled in. + /// Controls image→WebP conversion; independent of video/audio capabilities. + pub ffmpeg_webp_available: bool, /// Background job queue — enqueue CPU-heavy work here instead of blocking /// the HTTP request path. pub job_queue: std::sync::Arc<crate::workers::JobQueue>, @@ -159,6 +162,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..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<Self> { 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, @@ -322,17 +324,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 +443,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/server/cli.rs b/src/server/cli.rs new file mode 100644 index 0000000..db91222 --- /dev/null +++ b/src/server/cli.rs @@ -0,0 +1,259 @@ +// server/cli.rs — Command-line interface types and admin CLI handler. +// +// Defines the clap-based CLI structure (Cli, Command, AdminAction) and the +// synchronous `run_admin` function that executes admin subcommands against +// the database directly — no HTTP server is started. + +use clap::{Parser, Subcommand}; + +// ─── CLI definition ─────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command( + name = "rustchan-cli", + about = "Self-contained imageboard server", + long_about = "RustChan Imageboard — single binary, zero dependencies.\n\ + Data is stored in ./rustchan-data/ next to the binary.\n\ + Run without arguments to start the server." +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option<Command>, +} + +#[derive(Subcommand)] +pub enum Command { + Serve { + #[arg(long, short = 'p')] + port: Option<u16>, + }, + Admin { + #[command(subcommand)] + action: AdminAction, + }, +} + +#[derive(Subcommand)] +pub enum AdminAction { + CreateAdmin { + username: String, + password: String, + }, + ResetPassword { + username: String, + new_password: String, + }, + ListAdmins, + CreateBoard { + short: String, + name: String, + #[arg(default_value = "")] + description: String, + #[arg(long)] + nsfw: bool, + /// Disable image uploads on this board (default: images allowed) + #[arg(long = "no-images")] + no_images: bool, + /// Disable video uploads on this board (default: video allowed) + #[arg(long = "no-videos")] + no_videos: bool, + /// Disable audio uploads on this board (default: audio allowed) + #[arg(long = "no-audio")] + no_audio: bool, + }, + DeleteBoard { + short: String, + }, + ListBoards, + Ban { + ip_hash: String, + reason: String, + hours: Option<i64>, + }, + Unban { + ban_id: i64, + }, + ListBans, +} + +// ─── Admin CLI mode ─────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] +pub fn run_admin(action: AdminAction) -> anyhow::Result<()> { + use crate::{db, utils::crypto}; + use chrono::TimeZone; + use std::io::Write; + + let db_path = std::path::Path::new(&crate::config::CONFIG.database_path); + + // FIX[AUDIT-6]: Apply the same Fix #9 empty-parent guard used in + // `run_server`. The original code used a plain `if let Some(parent)` + // check, which does NOT handle the case where `Path::parent()` returns + // `Some("")` for a bare filename (e.g. "rustchan.db"). + // `create_dir_all("")` fails with `NotFound`, so we normalise an empty + // parent to `"."` just as `run_server` does. + let db_parent: std::path::PathBuf = match db_path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => std::path::PathBuf::from("."), + }; + std::fs::create_dir_all(&db_parent)?; + + let pool = db::init_pool()?; + let conn = pool.get()?; + + match action { + AdminAction::CreateAdmin { username, password } => { + crypto::validate_password(&password)?; + let hash = crypto::hash_password(&password)?; + let id = db::create_admin(&conn, &username, &hash)?; + println!("✓ Admin '{username}' created (id={id})."); + } + AdminAction::ResetPassword { + username, + new_password, + } => { + crypto::validate_password(&new_password)?; + db::get_admin_by_username(&conn, &username)? + .ok_or_else(|| anyhow::anyhow!("Admin '{username}' not found."))?; + let hash = crypto::hash_password(&new_password)?; + db::update_admin_password(&conn, &username, &hash)?; + println!("✓ Password updated for '{username}'."); + } + AdminAction::ListAdmins => { + let rows = db::list_admins(&conn)?; + if rows.is_empty() { + println!("No admins. Run: rustchan-cli admin create-admin <user> <pass>"); + } else { + println!("{:<6} {:<24} Created", "ID", "Username"); + println!("{}", "-".repeat(45)); + for (id, user, ts) in &rows { + let date = chrono::Utc + .timestamp_opt(*ts, 0) + .single() + .map_or_else(|| "?".to_string(), |d| d.format("%Y-%m-%d").to_string()); + println!("{id:<6} {user:<24} {date}"); + } + } + } + AdminAction::CreateBoard { + short, + name, + description, + nsfw, + no_images, + no_videos, + no_audio, + } => { + let short = short.to_lowercase(); + if short.is_empty() + || short.len() > 8 + || !short.chars().all(|c| c.is_ascii_alphanumeric()) + { + anyhow::bail!("Short name must be 1-8 alphanumeric chars (e.g. 'tech', 'b')."); + } + let allow_images = !no_images; + let allow_video = !no_videos; + let allow_audio = !no_audio; + let id = db::create_board_with_media_flags( + &conn, + &short, + &name, + &description, + nsfw, + allow_images, + allow_video, + allow_audio, + )?; + let nsfw_str = if nsfw { " [NSFW]" } else { "" }; + let media_info = format!( + " images:{} video:{} audio:{}", + if allow_images { "yes" } else { "no" }, + if allow_video { "yes" } else { "no" }, + if allow_audio { "yes" } else { "no" }, + ); + println!("✓ Board /{short}/ — {name}{nsfw_str} created (id={id}).{media_info}"); + } + AdminAction::DeleteBoard { short } => { + let board = db::get_board_by_short(&conn, &short)? + .ok_or_else(|| anyhow::anyhow!("Board /{short}/ not found."))?; + print!("Delete /{short}/ and ALL its content? Type 'yes' to confirm: "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if input.trim() != "yes" { + println!("Aborted."); + return Ok(()); + } + db::delete_board(&conn, board.id)?; + println!("✓ Board /{short}/ deleted."); + } + AdminAction::ListBoards => { + let boards = db::get_all_boards(&conn)?; + if boards.is_empty() { + println!("No boards. Run: rustchan-cli admin create-board <short> <n>"); + } else { + println!("{:<5} {:<12} {:<22} NSFW", "ID", "Short", "Name"); + println!("{}", "-".repeat(50)); + for b in &boards { + println!( + "{:<5} /{:<11} {:<22} {}", + b.id, + format!("{}/", b.short_name), + b.name, + if b.nsfw { "yes" } else { "no" } + ); + } + } + } + AdminAction::Ban { + ip_hash, + reason, + hours, + } => { + let expires = hours + .filter(|&h| h > 0) + .map(|h| chrono::Utc::now().timestamp() + h.min(87_600).saturating_mul(3600)); + let id = db::add_ban(&conn, &ip_hash, &reason, expires)?; + let exp_str = expires + .and_then(|ts| chrono::Utc.timestamp_opt(ts, 0).single()) + .map_or_else( + || "permanent".to_string(), + |d| d.format("%Y-%m-%d %H:%M UTC").to_string(), + ); + println!("✓ Ban #{id} added (expires: {exp_str})."); + } + AdminAction::Unban { ban_id } => { + db::remove_ban(&conn, ban_id)?; + println!("✓ Ban #{ban_id} lifted."); + } + AdminAction::ListBans => { + let bans = db::list_bans(&conn)?; + if bans.is_empty() { + println!("No active bans."); + } else { + println!( + "{:<5} {:<18} {:<28} Expires", + "ID", "IP Hash (partial)", "Reason" + ); + println!("{}", "-".repeat(75)); + for b in &bans { + // FIX[AUDIT-3]: Use .get(..16) for the same defensive + // safety as the ip_list slice above. + let partial = b.ip_hash.get(..16).unwrap_or(b.ip_hash.as_str()); + let expires = b + .expires_at + .and_then(|ts| chrono::Utc.timestamp_opt(ts, 0).single()) + .map_or_else( + || "Permanent".to_string(), + |d| d.format("%Y-%m-%d %H:%M").to_string(), + ); + let ban_id = b.id; + let reason = b.reason.as_deref().unwrap_or(""); + println!("{ban_id:<5} {partial:<18} {reason:<28} {expires}"); + } + } + } + } + Ok(()) +} diff --git a/src/server/console.rs b/src/server/console.rs new file mode 100644 index 0000000..85e5eb8 --- /dev/null +++ b/src/server/console.rs @@ -0,0 +1,507 @@ +// server/console.rs — Terminal stats display and interactive keyboard console. +// +// Everything in this file is pure terminal I/O — it has no knowledge of HTTP +// routing, middleware, or request handling. +// +// Exported entry points called from server/server.rs: +// print_banner() — startup box printed before bind +// spawn_keyboard_handler(pool, start_time) — spawns the stdin-reading thread + +use crate::config::CONFIG; +use crate::db::DbPool; +use crate::server::{ACTIVE_IPS, ACTIVE_UPLOADS, IN_FLIGHT, REQUEST_COUNT, SPINNER_TICK}; +use std::io::{BufRead, BufReader, Write}; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +// ─── Terminal stats ─────────────────────────────────────────────────────────── + +pub struct TermStats { + pub prev_req_count: u64, + pub prev_post_count: i64, + pub prev_thread_count: i64, + pub last_tick: Instant, +} + +#[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] +pub fn print_stats(pool: &DbPool, start: Instant, ts: &mut TermStats) { + // Uptime + let uptime = start.elapsed(); + let h = uptime.as_secs() / 3600; + let m = (uptime.as_secs() % 3600) / 60; + + // req/s — delta since last tick + let now = Instant::now(); + let elapsed_secs = now.duration_since(ts.last_tick).as_secs_f64().max(0.001); + ts.last_tick = now; + let curr_reqs = REQUEST_COUNT.load(Ordering::Relaxed); + let req_delta = curr_reqs.saturating_sub(ts.prev_req_count); + #[allow(clippy::cast_precision_loss)] + let rps = req_delta as f64 / elapsed_secs; + ts.prev_req_count = curr_reqs; + + // DB query + let (boards, threads, posts, db_kb, board_stats) = pool.get().map_or_else( + |_| (0i64, 0i64, 0i64, 0i64, vec![]), + |conn| { + let b: i64 = conn + .query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0)) + .unwrap_or(0); + let th: i64 = conn + .query_row("SELECT COUNT(*) FROM threads", [], |r| r.get(0)) + .unwrap_or(0); + let p: i64 = conn + .query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0)) + .unwrap_or(0); + let kb: i64 = { + let pc: i64 = conn + .query_row("PRAGMA page_count", [], |r| r.get(0)) + .unwrap_or(0); + let ps: i64 = conn + .query_row("PRAGMA page_size", [], |r| r.get(0)) + .unwrap_or(4096); + pc * ps / 1024 + }; + let bs = crate::db::get_per_board_stats(&conn); + (b, th, p, kb, bs) + }, + ); + + let upload_mb = dir_size_mb(&CONFIG.upload_dir); + + // New-event flash: bold+yellow when counts increased since last tick + let new_threads = (threads - ts.prev_thread_count).max(0); + let new_posts = (posts - ts.prev_post_count).max(0); + let thread_str = if new_threads > 0 { + format!("\x1b[1;33mthreads {threads} (+{new_threads})\x1b[0m") + } else { + format!("threads {threads}") + }; + let post_str = if new_posts > 0 { + format!("\x1b[1;33mposts {posts} (+{new_posts})\x1b[0m") + } else { + format!("posts {posts}") + }; + ts.prev_thread_count = threads; + ts.prev_post_count = posts; + + // Active connections / users online + // FIX[AUDIT-1]: IN_FLIGHT is now AtomicU64 — load directly, no .max(0) cast. + let in_flight = IN_FLIGHT.load(Ordering::Relaxed); + let online_count = ACTIVE_IPS.len(); + + // CRIT-5: Keys are SHA-256 hashes — show 8-char prefixes for diagnostics. + // FIX[AUDIT-3]: Use .get(..8) instead of direct [..8] byte-index so this + // stays safe if a key is ever shorter than 8 chars (defensive programming). + let ip_list: String = { + let mut hashes: Vec<String> = ACTIVE_IPS + .iter() + .map(|e| { + let key = e.key(); + key.get(..8).unwrap_or(key.as_str()).to_string() + }) + .collect(); + hashes.sort_unstable(); + hashes.truncate(5); + if hashes.is_empty() { + "none".into() + } else { + hashes.join(", ") + } + }; + + // Upload progress bar — shown only while uploads are active + // FIX[AUDIT-1]: ACTIVE_UPLOADS is now AtomicU64 — load directly. + let active_uploads = ACTIVE_UPLOADS.load(Ordering::Relaxed); + if active_uploads > 0 { + // Fix #5: SPINNER_TICK was read but never written anywhere, so the + // spinner was permanently frozen on frame 0 ("⠋"). Increment it here, + // inside the only branch that actually displays the spinner. + let tick = SPINNER_TICK.fetch_add(1, Ordering::Relaxed); + let spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let spin = spinners + .get((usize::try_from(tick).unwrap_or(0)) % spinners.len()) + .copied() + .unwrap_or("⠋"); + let fill = ((tick % 20) as usize).min(10); + let bar = format!("{}{}", "█".repeat(fill), "░".repeat(10 - fill)); + println!(" \x1b[36m{spin} UPLOAD [{bar}] {active_uploads} file(s) uploading\x1b[0m"); + } + + // Main stats line + #[allow(clippy::cast_precision_loss)] + let stats_line = format!( + "── STATS uptime {h}h{m:02}m │ requests {curr_reqs} │ \x1b[32m{rps:.1} req/s\x1b[0m │ in-flight {in_flight} │ boards {boards} {thread_str} {post_str} │ db {db_kb} KiB uploads {upload_mb:.1} MiB ──" + ); + println!("{stats_line}"); + + // Users online line + let mem_rss = process_rss_kb(); + println!(" users online: {online_count} │ IPs: {ip_list} │ mem: {mem_rss} KiB RSS"); + + // Per-board breakdown + if !board_stats.is_empty() { + let segments: Vec<String> = board_stats + .iter() + .map(|(short, t, p)| format!("/{short}/ threads:{t} posts:{p}")) + .collect(); + let mut line = String::from(" "); + let mut line_len = 0usize; + for seg in &segments { + if line_len > 0 && line_len + seg.len() + 5 > 110 { + println!("{line}"); + line = String::from(" "); + line_len = 0; + } + if line_len > 0 { + line.push_str(" │ "); + line_len += 5; + } + line.push_str(seg); + line_len += seg.len(); + } + if line_len > 0 { + println!("{line}"); + } + } +} + +/// Read the process RSS (resident set size) in KiB. +/// +/// * Linux — parsed from `/proc/self/status` (`VmRSS` field, already in KiB). +/// * macOS — Fix #11: spawns `ps -o rss= -p <pid>` (output is KiB on macOS). +/// Previously this returned 0 on macOS, showing a misleading +/// `mem: 0 KiB RSS` in the terminal stats display. +/// * Other — returns 0 rather than showing a misleading value. +fn process_rss_kb() -> u64 { + #[cfg(target_os = "linux")] + { + if let Ok(s) = std::fs::read_to_string("/proc/self/status") { + for line in s.lines() { + if let Some(val) = line.strip_prefix("VmRSS:") { + return val + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } + } + } + } + #[cfg(target_os = "macos")] + { + // `ps -o rss=` outputs the RSS in KiB on macOS (no header when '=' suffix used). + let pid = std::process::id().to_string(); + if let Ok(out) = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &pid]) + .output() + { + let s = String::from_utf8_lossy(&out.stdout); + if let Ok(kb) = s.trim().parse::<u64>() { + return kb; + } + } + } + 0 +} + +fn dir_size_mb(path: &str) -> f64 { + #[allow(clippy::cast_precision_loss)] + let mb = walkdir_size(std::path::Path::new(path)) as f64 / (1024.0 * 1024.0); + mb +} + +fn walkdir_size(path: &std::path::Path) -> u64 { + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + entries + .flatten() + .map(|e| { + // Fix #10: use file_type() from the DirEntry (does NOT follow + // symlinks) instead of Path::is_dir() (which does). A symlink + // loop via is_dir() causes unbounded recursion and a stack overflow. + let is_real_dir = e.file_type().is_ok_and(|ft| ft.is_dir()); + if is_real_dir { + walkdir_size(&e.path()) + } else { + e.metadata().map(|m| m.len()).unwrap_or(0) + } + }) + .sum() +} + +// ─── Startup banner ────────────────────────────────────────────────────────── + +#[allow(clippy::arithmetic_side_effects)] +pub 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 + // │ character is always aligned regardless of the actual value length. + const INNER: usize = 53; + + // FIX[AUDIT-4]: The original closure collected chars into a `Vec<char>` and + // had a dead `unwrap_or_else(|| s.clone())` branch — `get(..width)` on a + // slice of length >= width never returns None. The rewrite uses + // `chars().count()` (no heap allocation) and `chars().take(width).collect()` + // (single pass), eliminating both the intermediate Vec and the dead code. + let cell = |s: String, width: usize| -> String { + let char_count = s.chars().count(); + if char_count >= width { + s.chars().take(width).collect() + } else { + format!("{s}{}", " ".repeat(width - char_count)) + } + }; + + let title = cell( + format!("{} v{}", env!("CARGO_PKG_VERSION"), CONFIG.forum_name), + INNER - 2, // 2 leading spaces in "│ <title>│" + ); + let bind = cell(CONFIG.bind_addr.clone(), INNER - 10); // "│ Bind <val>│" + let db = cell(CONFIG.database_path.clone(), INNER - 10); // "│ DB <val>│" + let upl = cell(CONFIG.upload_dir.clone(), INNER - 10); // "│ Uploads <val>│" + let img_mib = CONFIG.max_image_size / 1024 / 1024; + let vid_mib = CONFIG.max_video_size / 1024 / 1024; + let limits = cell( + format!("Images {img_mib} MiB max │ Videos {vid_mib} MiB max"), + INNER - 4, // "│ <val> │" + ); + + println!("┌─────────────────────────────────────────────────────┐"); + println!("│ {title}│"); + println!("├─────────────────────────────────────────────────────┤"); + println!("│ Bind {bind}│"); + println!("│ DB {db}│"); + println!("│ Uploads {upl}│"); + println!("│ {limits} │"); + println!("└─────────────────────────────────────────────────────┘"); +} + +// ─── Keyboard-driven admin console ─────────────────────────────────────────── + +// `reader` (BufReader<StdinLock>) must persist for the entire loop because it +// is passed by &mut into sub-commands; the lint's inline suggestion is invalid. +#[allow(clippy::significant_drop_tightening)] +pub fn spawn_keyboard_handler(pool: DbPool, start_time: Instant) { + std::thread::spawn(move || { + // Small delay so startup messages settle first + std::thread::sleep(Duration::from_millis(600)); + print_keyboard_help(); + + let stdin = std::io::stdin(); + + // Fix #4: TermStats must persist across keypresses so that + // prev_req_count/prev_post_count/prev_thread_count reflect the values + // at the *previous* 's' press, not zero. Initializing inside the + // match arm made every post/thread appear as "+N new" and reported the + // lifetime-average req/s instead of the current rate. + let mut persistent_stats = TermStats { + prev_req_count: REQUEST_COUNT.load(Ordering::Relaxed), + prev_post_count: 0, + prev_thread_count: 0, + last_tick: Instant::now(), + }; + + // Acquire stdin lock only after all pre-loop setup is complete so the + // StdinLock (significant Drop) is held for the shortest possible scope. + // `reader` is passed into kb_create_board / kb_delete_thread inside the + // loop, so it must live for the full loop scope and cannot be inlined. + let mut reader = BufReader::new(stdin.lock()); + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, // EOF — stdin closed (daemon mode) + Ok(_) => {} + } + let cmd = line.trim().to_lowercase(); + match cmd.as_str() { + "s" => { + print_stats(&pool, start_time, &mut persistent_stats); + } + "l" => kb_list_boards(&pool), + "c" => kb_create_board(&pool, &mut reader), + "d" => kb_delete_thread(&pool, &mut reader), + "h" => print_keyboard_help(), + "q" => println!(" \x1b[33m[!]\x1b[0m Use Ctrl+C or SIGTERM to stop the server."), + "" => {} + other => println!(" Unknown command '{other}'. Press [h] for help."), + } + let _ = std::io::stdout().flush(); + } + }); +} + +fn print_keyboard_help() { + println!(); + println!(" \x1b[36m╔══ Admin Console ════════════════════════════════╗\x1b[0m"); + println!(" \x1b[36m║\x1b[0m [s] show stats now [l] list boards \x1b[36m║\x1b[0m"); + println!(" \x1b[36m║\x1b[0m [c] create board [d] delete thread \x1b[36m║\x1b[0m"); + println!(" \x1b[36m║\x1b[0m [h] help [q] quit hint \x1b[36m║\x1b[0m"); + println!(" \x1b[36m╚═════════════════════════════════════════════════╝\x1b[0m"); + println!(); +} + +fn kb_list_boards(pool: &DbPool) { + let Ok(conn) = pool.get() else { + println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); + return; + }; + let boards = match crate::db::get_all_boards(&conn) { + Ok(b) => b, + Err(e) => { + println!(" \x1b[31m[err]\x1b[0m {e}"); + return; + } + }; + if boards.is_empty() { + println!(" No boards found."); + return; + } + println!(" {:<5} {:<12} {:<24} NSFW", "ID", "Short", "Name"); + println!(" {}", "─".repeat(48)); + for b in &boards { + println!( + " {:<5} /{:<11} {:<24} {}", + b.id, + format!("{}/", b.short_name), + b.name, + if b.nsfw { "yes" } else { "no" } + ); + } + println!(); +} + +fn kb_create_board(pool: &DbPool, reader: &mut dyn std::io::BufRead) { + let mut prompt = |msg: &str| -> String { + print!(" \x1b[36m{msg}\x1b[0m "); + let _ = std::io::stdout().flush(); + let mut s = String::new(); + let _ = reader.read_line(&mut s); + s.trim().to_string() + }; + + let short = prompt("Short name (e.g. 'tech'):"); + if short.is_empty() { + println!(" Aborted."); + return; + } + + // FIX[AUDIT-5]: Validate short name immediately after reading it, before + // prompting for the remaining fields. Previously validation happened at + // the bottom of the function, so the user would fill in all prompts before + // learning the short name was invalid. + let short_lc = short.to_lowercase(); + if short_lc.is_empty() + || short_lc.len() > 8 + || !short_lc.chars().all(|c| c.is_ascii_alphanumeric()) + { + println!(" \x1b[31m[err]\x1b[0m Short name must be 1-8 alphanumeric characters."); + return; + } + + let name = prompt("Display name:"); + if name.is_empty() { + println!(" Aborted."); + return; + } + let desc = prompt("Description (blank = none):"); + let nsfw_raw = prompt("NSFW board? [y/N]:"); + let nsfw = matches!(nsfw_raw.to_lowercase().as_str(), "y" | "yes"); + + // Fix #6: prompt for media flags and call create_board_with_media_flags so + // boards created from the console have the same capabilities as those + // created via `rustchan-cli admin create-board`. + let no_images_raw = prompt("Disable images? [y/N]:"); + let no_videos_raw = prompt("Disable video? [y/N]:"); + let no_audio_raw = prompt("Disable audio? [y/N]:"); + let allow_images = !matches!(no_images_raw.to_lowercase().as_str(), "y" | "yes"); + let allow_video = !matches!(no_videos_raw.to_lowercase().as_str(), "y" | "yes"); + let allow_audio = !matches!(no_audio_raw.to_lowercase().as_str(), "y" | "yes"); + + let Ok(conn) = pool.get() else { + println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); + return; + }; + match crate::db::create_board_with_media_flags( + &conn, + &short_lc, + &name, + &desc, + nsfw, + allow_images, + allow_video, + allow_audio, + ) { + Ok(id) => println!( + " \x1b[32m✓\x1b[0m Board /{}/ — {}{} created (id={}). images:{} video:{} audio:{}", + short_lc, + name, + if nsfw { " [NSFW]" } else { "" }, + id, + if allow_images { "yes" } else { "no" }, + if allow_video { "yes" } else { "no" }, + if allow_audio { "yes" } else { "no" }, + ), + Err(e) => println!(" \x1b[31m[err]\x1b[0m {e}"), + } + println!(); +} + +fn kb_delete_thread(pool: &DbPool, reader: &mut dyn std::io::BufRead) { + print!(" \x1b[36mThread ID to delete:\x1b[0m "); + let _ = std::io::stdout().flush(); + let mut s = String::new(); + let _ = reader.read_line(&mut s); + let thread_id: i64 = if let Ok(n) = s.trim().parse() { + n + } else { + println!( + " \x1b[31m[err]\x1b[0m '{}' is not a valid thread ID.", + s.trim() + ); + return; + }; + + let Ok(conn) = pool.get() else { + println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); + return; + }; + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM threads WHERE id = ?1", + [thread_id], + |r| r.get(0), + ) + .unwrap_or(0); + if exists == 0 { + println!(" \x1b[31m[err]\x1b[0m Thread {thread_id} not found."); + return; + } + + print!(" \x1b[33mDelete thread {thread_id} and all its posts? [y/N]:\x1b[0m "); + let _ = std::io::stdout().flush(); + let mut confirm = String::new(); + let _ = reader.read_line(&mut confirm); + if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") { + println!(" Aborted."); + return; + } + + match crate::db::delete_thread(&conn, thread_id) { + Ok(paths) => { + for p in &paths { + crate::utils::files::delete_file(&CONFIG.upload_dir, p); + } + println!( + " \x1b[32m✓\x1b[0m Thread {} deleted ({} file(s) removed).", + thread_id, + paths.len() + ); + } + Err(e) => println!(" \x1b[31m[err]\x1b[0m {e}"), + } + println!(); +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..f881cdd --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,21 @@ +// server/mod.rs — Server subsystem module. +// +// Sub-modules: +// cli.rs — Cli, Command, AdminAction clap types + run_admin +// console.rs — TermStats, print_stats, spawn_keyboard_handler, print_banner, +// kb_* interactive console functions +// server.rs — run_server, build_router, background tasks, +// static asset handlers, hsts_middleware, track_requests, +// ScopedDecrement, global request-counter atomics + +pub mod cli; +pub mod console; +#[allow(clippy::module_inception)] +pub mod server; + +pub use server::run_server; + +// Re-export the global atomics so console.rs (and any future module) can +// reference them as `crate::server::REQUEST_COUNT` etc. rather than the +// longer `crate::server::server::REQUEST_COUNT`. +pub use server::{ACTIVE_IPS, ACTIVE_UPLOADS, IN_FLIGHT, REQUEST_COUNT, SPINNER_TICK}; diff --git a/src/server/server.rs b/src/server/server.rs new file mode 100644 index 0000000..585572c --- /dev/null +++ b/src/server/server.rs @@ -0,0 +1,868 @@ +// server/server.rs — HTTP server runtime. +// +// Contains: +// • Global request-counter atomics (REQUEST_COUNT, IN_FLIGHT, etc.) +// • ScopedDecrement RAII guard +// • run_server() — full server startup sequence +// • build_router() — Axum router wiring +// • spawn background tasks — session purge, WAL checkpoint, IP prune, +// login-fail prune, VACUUM, poll cleanup, +// thumb-cache eviction +// • Static asset handlers — serve_css, serve_main_js, serve_theme_init_js +// • track_requests — per-request counter middleware +// • hsts_middleware — HSTS header (HTTPS-only) +// • shutdown_signal() — Ctrl-C / SIGTERM waiter + +use axum::{ + extract::DefaultBodyLimit, + http::{header, StatusCode}, + middleware as axum_middleware, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use dashmap::DashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use tracing::info; +use tracing::Instrument as _; + +use crate::config::{check_cookie_secret_rotation, generate_settings_file_if_missing, CONFIG}; +use crate::middleware::AppState; + +// ─── Embedded static assets ─────────────────────────────────────────────────── +static STYLE_CSS: &str = include_str!("../../static/style.css"); +static MAIN_JS: &str = include_str!("../../static/main.js"); +static THEME_INIT_JS: &str = include_str!("../../static/theme-init.js"); + +// ─── Global terminal state ───────────────────────────────────────────────────── +/// Total HTTP requests handled since startup. +pub static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0); +/// Requests currently being processed (in-flight). +/// +/// FIX[AUDIT-1]: Changed from `AtomicI64` to `AtomicU64`. In-flight request +/// counts are inherently non-negative; using a signed type required defensive +/// `.max(0)` casts at every read site and masked counter underflow bugs. +/// Decrements use `ScopedDecrement` RAII guards (see below) to prevent +/// counter leaks when async futures are cancelled mid-flight. +pub static IN_FLIGHT: AtomicU64 = AtomicU64::new(0); +/// Multipart file uploads currently in progress. +/// +/// FIX[AUDIT-1]: Same signed→unsigned change as `IN_FLIGHT`. +pub static ACTIVE_UPLOADS: AtomicU64 = AtomicU64::new(0); +/// Monotonic tick used to animate the upload spinner. +pub static SPINNER_TICK: AtomicU64 = AtomicU64::new(0); +/// Recently active client IPs (last ~5 min); maps SHA-256(IP) → last-seen Instant. +/// CRIT-5: Keys are hashed so raw IP addresses are never retained in process +/// memory (or coredumps). The count is used for the "users online" display. +pub static ACTIVE_IPS: LazyLock<DashMap<String, Instant>> = LazyLock::new(DashMap::new); + +// ─── RAII counter guard ─────────────────────────────────────────────────────── +// +// FIX[AUDIT-2]: `IN_FLIGHT` and `ACTIVE_UPLOADS` are decremented inside +// `track_requests` *after* `.await`. If the surrounding future is cancelled +// (e.g. client disconnect, timeout, or panic in a handler), the post-await +// code never runs and the counters permanently over-count. +// +// `ScopedDecrement` ties the decrement to the guard's lifetime so it fires +// unconditionally via `Drop`, even when the future is dropped mid-flight. +// The decrement is saturating to prevent underflow on `AtomicU64`. +struct ScopedDecrement<'a>(&'a AtomicU64); + +impl Drop for ScopedDecrement<'_> { + fn drop(&mut self) { + // Saturating decrement: fetch_update retries on spurious failure. + let _ = self + .0 + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| { + Some(v.saturating_sub(1)) + }); + } +} + +// ─── Server mode ───────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_lines)] +#[allow(clippy::arithmetic_side_effects)] +pub async fn run_server(port_override: Option<u16>) -> anyhow::Result<()> { + let early_data_dir = { + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p: &std::path::Path| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + exe.join("rustchan-data") + }; + std::fs::create_dir_all(&early_data_dir)?; + + generate_settings_file_if_missing(); + + // Validate critical configuration values immediately — fail fast with a + // clear error rather than discovering misconfiguration at runtime (#8). + CONFIG.validate()?; + + // Fix #9: Path::parent() on a bare filename (e.g. "rustchan.db") returns + // Some("") rather than None, so the old `unwrap_or(".")` never fired and + // `create_dir_all("")` would fail with NotFound. Treat an empty-string + // parent the same as a missing one. + let data_dir: std::path::PathBuf = { + let p = std::path::Path::new(&CONFIG.database_path); + match p.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(), + _ => std::path::PathBuf::from("."), + } + }; + + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&CONFIG.upload_dir)?; + + super::console::print_banner(); + + let bind_addr: String = port_override.map_or_else( + || CONFIG.bind_addr.clone(), + |p| { + // rsplit_once splits at the LAST colon only, which correctly handles + // both IPv4 ("0.0.0.0:8080") and IPv6 ("[::1]:8080") bind addresses. + // rsplit(':').nth(1) was incorrect for IPv6 — it returned "1]" instead + // of "[::1]" because rsplit splits on every colon in the address. + let host = CONFIG + .bind_addr + .rsplit_once(':') + .map_or("0.0.0.0", |(h, _)| h); + format!("{host}:{p}") + }, + ); + + let pool = crate::db::init_pool()?; + crate::db::first_run_check(&pool)?; + + // Check whether cookie_secret has changed since the last run (#19). + // Must run after DB init so the site_settings table exists. + if let Ok(conn) = pool.get() { + check_cookie_secret_rotation(&conn); + } + + // Initialise the live site name and subtitle from DB so they're available before any request. + { + if let Ok(conn) = pool.get() { + // Site name: use DB value if an admin has set one, otherwise seed + // from CONFIG.forum_name (settings.toml). Using get_site_setting + // (not get_site_name) lets us distinguish "never set" from "set to + // the default", so that editing forum_name in settings.toml and + // restarting takes effect when no admin override is in the DB. + let name_in_db = crate::db::get_site_setting(&conn, "site_name") + .ok() + .flatten() + .filter(|v| !v.trim().is_empty()); + let name = name_in_db.unwrap_or_else(|| { + // Seed DB from settings.toml so get_site_name is always consistent. + let _ = crate::db::set_site_setting(&conn, "site_name", &CONFIG.forum_name); + CONFIG.forum_name.clone() + }); + crate::templates::set_live_site_name(&name); + + // Seed subtitle from settings.toml if not yet configured in DB. + // BUG FIX: get_site_subtitle() always returns a non-empty fallback + // string, so we must query the DB key directly to detect "never set". + let subtitle_in_db = crate::db::get_site_setting(&conn, "site_subtitle") + .ok() + .flatten() + .filter(|v| !v.trim().is_empty()); + let subtitle = subtitle_in_db.unwrap_or_else(|| { + // Nothing in DB — seed from CONFIG (settings.toml). + let seed = if CONFIG.initial_site_subtitle.is_empty() { + "select board to proceed".to_string() + } else { + CONFIG.initial_site_subtitle.clone() + }; + let _ = crate::db::set_site_setting(&conn, "site_subtitle", &seed); + seed + }); + crate::templates::set_live_site_subtitle(&subtitle); + + // Seed default_theme from settings.toml if not yet configured in DB. + let default_theme = crate::db::get_default_user_theme(&conn); + let default_theme = if default_theme.is_empty() + && !CONFIG.initial_default_theme.is_empty() + && CONFIG.initial_default_theme != "terminal" + { + let _ = crate::db::set_site_setting( + &conn, + "default_theme", + &CONFIG.initial_default_theme, + ); + CONFIG.initial_default_theme.clone() + } else { + default_theme + }; + crate::templates::set_live_default_theme(&default_theme); + + // Seed the live board list used by error pages and ban pages. + if let Ok(boards) = crate::db::get_all_boards(&conn) { + crate::templates::set_live_boards(boards); + } + } + } + // ── External tool detection ──────────────────────────────────────────────── + // ffmpeg: required for video thumbnails (optional — graceful degradation). + let ffmpeg_status = crate::detect::detect_ffmpeg(CONFIG.require_ffmpeg); + let ffmpeg_available = ffmpeg_status == crate::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 = crate::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 = crate::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). + // Fix #1: derive bind_port from `bind_addr` (which already incorporates + // port_override) rather than CONFIG.bind_addr. Previously, starting with + // `--port 9000` would still pass 8080 to Tor's HiddenServicePort. + // rsplit_once(':') handles both IPv4 ("0.0.0.0:9000") and IPv6 ("[::1]:9000"). + let bind_port = bind_addr + .rsplit_once(':') + .and_then(|(_, p)| p.parse::<u16>().ok()) + .unwrap_or(8080); + crate::detect::detect_tor(CONFIG.enable_tor_support, bind_port, &data_dir); + println!(); + + let state = AppState { + db: pool.clone(), + ffmpeg_available, + ffmpeg_webp_available, + job_queue: { + let q = std::sync::Arc::new(crate::workers::JobQueue::new(pool.clone())); + crate::workers::start_worker_pool(&q, ffmpeg_available, ffmpeg_vp9_available); + q + }, + backup_progress: std::sync::Arc::new(crate::middleware::BackupProgress::new()), + }; + // Keep a reference to the job queue cancel token for graceful shutdown (#7). + let worker_cancel = state.job_queue.cancel.clone(); + let start_time = Instant::now(); + + // Background: purge expired sessions hourly + { + let bg = pool.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(Duration::from_secs(3600)); + loop { + iv.tick().await; + if let Ok(conn) = bg.get() { + match crate::db::purge_expired_sessions(&conn) { + Ok(n) if n > 0 => info!("Purged {n} expired sessions"), + Err(e) => tracing::error!("Session purge error: {e}"), + Ok(_) => {} + } + } + } + }); + } + + // Background: WAL checkpoint — prevent WAL files growing unbounded. + // Runs PRAGMA wal_checkpoint(TRUNCATE) at the configured interval, plus + // PRAGMA optimize to keep query-planner statistics current (#18). + if CONFIG.wal_checkpoint_interval > 0 { + let bg = pool.clone(); + let interval_secs = CONFIG.wal_checkpoint_interval; + tokio::spawn(async move { + // Stagger the first run by half the interval so it doesn't fire + // immediately at startup alongside the session purge. + tokio::time::sleep(Duration::from_secs(interval_secs / 2 + 1)).await; + let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + iv.tick().await; + if let Ok(conn) = bg.get() { + match crate::db::run_wal_checkpoint(&conn) { + Ok((pages, moved, backfill)) => { + tracing::debug!("WAL checkpoint: {pages} pages total, {moved} moved, {backfill} backfilled"); + } + Err(e) => tracing::warn!("WAL checkpoint failed: {e}"), + } + // Fix #7: reuse `conn` instead of calling bg.get() again. + // A second acquire while the first is still alive deadlocks + // with a pool size of 1. + let _ = conn.execute_batch("PRAGMA optimize;"); + } + } + }); + } + + // Background: prune stale IPs from ACTIVE_IPS every 5 min + tokio::spawn(async move { + let mut iv = tokio::time::interval(Duration::from_secs(300)); + loop { + iv.tick().await; + let cutoff = Instant::now() + .checked_sub(Duration::from_secs(300)) + .unwrap_or_else(Instant::now); + ACTIVE_IPS.retain(|_, last_seen| *last_seen > cutoff); + } + }); + + // Background: prune expired entries from ADMIN_LOGIN_FAILS every 5 min. + // Prevents unbounded growth under a sustained brute-force attack that + // never produces a successful login (which would trigger the existing + // opportunistic prune path inside clear_login_fails). + tokio::spawn(async move { + let mut iv = tokio::time::interval(Duration::from_secs(300)); + loop { + iv.tick().await; + crate::handlers::admin::prune_login_fails(); + } + }); + + // 1.6: Scheduled database VACUUM — reclaim disk space from deleted posts + // and threads without requiring manual admin intervention. + if CONFIG.auto_vacuum_interval_hours > 0 { + let bg = pool.clone(); + let interval_secs = CONFIG.auto_vacuum_interval_hours * 3600; + tokio::spawn(async move { + // Stagger the first run by half the interval to avoid hammering the + // DB immediately at startup alongside WAL checkpoint and session purge. + tokio::time::sleep(Duration::from_secs(interval_secs / 2 + 7)).await; + let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + iv.tick().await; + let bg2 = bg.clone(); + tokio::task::spawn_blocking(move || { + if let Ok(conn) = bg2.get() { + let before = crate::db::get_db_size_bytes(&conn).unwrap_or(0); + match crate::db::run_vacuum(&conn) { + Ok(()) => { + let after = crate::db::get_db_size_bytes(&conn).unwrap_or(0); + let saved = before.saturating_sub(after); + info!( + "Scheduled VACUUM complete: {} → {} bytes ({} reclaimed)", + before, after, saved + ); + } + Err(e) => tracing::warn!("Scheduled VACUUM failed: {e}"), + } + } + }) + .await + .ok(); + } + }); + } + + // 1.7: Expired poll vote cleanup — purge per-IP vote rows for polls whose + // expiry is older than poll_cleanup_interval_hours, preventing the + // poll_votes table from growing indefinitely. + if CONFIG.poll_cleanup_interval_hours > 0 { + let bg = pool.clone(); + let interval_secs = CONFIG.poll_cleanup_interval_hours * 3600; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(600)).await; // initial delay + let mut iv = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + iv.tick().await; + let bg2 = bg.clone(); + let retention_cutoff_secs = interval_secs.cast_signed(); + tokio::task::spawn_blocking(move || { + if let Ok(conn) = bg2.get() { + let cutoff = chrono::Utc::now().timestamp() - retention_cutoff_secs; + match crate::db::cleanup_expired_poll_votes(&conn, cutoff) { + Ok(n) if n > 0 => { + info!("Poll vote cleanup: removed {} expired vote row(s)", n); + } + Ok(_) => {} + Err(e) => tracing::warn!("Poll vote cleanup failed: {e}"), + } + } + }) + .await + .ok(); + } + }); + } + + // 2.6: Waveform/thumbnail cache eviction — keep total size of all thumbs + // directories under CONFIG.waveform_cache_max_bytes by deleting the oldest + // files when the threshold is exceeded. Waveform PNGs can be regenerated + // by re-enqueueing the AudioWaveform job; image thumbnails can be + // regenerated from the originals. Uses 1-hour intervals. + if CONFIG.waveform_cache_max_bytes > 0 { + let max_bytes = CONFIG.waveform_cache_max_bytes; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(1800)).await; // initial stagger + let mut iv = tokio::time::interval(Duration::from_secs(3600)); + loop { + iv.tick().await; + let upload_dir = CONFIG.upload_dir.clone(); + tokio::task::spawn_blocking(move || { + crate::workers::evict_thumb_cache(&upload_dir, max_bytes); + }) + .await + .ok(); + } + }); + } + + let app = build_router(state); + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; + info!("Listening on http://{bind_addr}"); + info!("Admin panel http://{bind_addr}/admin"); + info!("Data dir {}", data_dir.display()); + println!(); + + super::console::spawn_keyboard_handler(pool, start_time); + + axum::serve( + listener, + app.into_make_service_with_connect_info::<SocketAddr>(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + // Signal background workers to drain and exit (#7). + info!("Signalling background workers to shut down…"); + worker_cancel.cancel(); + // Give workers up to 10 seconds to finish in-flight jobs. + tokio::time::sleep(Duration::from_secs(10)).await; + + info!("Server shut down gracefully."); + Ok(()) +} + +#[allow(clippy::too_many_lines)] +fn build_router(state: AppState) -> Router { + Router::new() + .route("/static/style.css", get(serve_css)) + .route("/static/main.js", get(serve_main_js)) + .route("/static/theme-init.js", get(serve_theme_init_js)) + .route("/", get(crate::handlers::board::index)) + .route("/{board}", get(crate::handlers::board::board_index)) + .route( + "/{board}", + post(crate::handlers::board::create_thread).layer(DefaultBodyLimit::max( + CONFIG.max_video_size.max(CONFIG.max_audio_size), + )), + ) + .route("/{board}/catalog", get(crate::handlers::board::catalog)) + .route( + "/{board}/archive", + get(crate::handlers::board::board_archive), + ) + .route("/{board}/search", get(crate::handlers::board::search)) + .route( + "/{board}/thread/{id}", + get(crate::handlers::thread::view_thread), + ) + .route( + "/{board}/thread/{id}", + post(crate::handlers::thread::post_reply).layer(DefaultBodyLimit::max( + CONFIG.max_video_size.max(CONFIG.max_audio_size), + )), + ) + .route( + "/{board}/post/{id}/edit", + get(crate::handlers::thread::edit_post_get), + ) + .route( + "/{board}/post/{id}/edit", + post(crate::handlers::thread::edit_post_post), + ) + .route( + "/report", + post(crate::handlers::board::file_report).layer(DefaultBodyLimit::max(65_536)), + ) + .route( + "/appeal", + post(crate::handlers::board::submit_appeal).layer(DefaultBodyLimit::max(65_536)), + ) + .route( + "/vote", + post(crate::handlers::thread::vote_handler).layer(DefaultBodyLimit::max(65_536)), + ) + .route( + "/api/post/{board}/{post_id}", + get(crate::handlers::board::api_post_preview), + ) + .route( + "/{board}/post/{post_id}", + get(crate::handlers::board::redirect_to_post), + ) + .route( + "/admin/post/ban-delete", + post(crate::handlers::admin::admin_ban_and_delete), + ) + .route( + "/admin/appeal/dismiss", + post(crate::handlers::admin::dismiss_appeal), + ) + .route( + "/admin/appeal/accept", + post(crate::handlers::admin::accept_appeal), + ) + .route( + "/{board}/thread/{id}/updates", + get(crate::handlers::thread::thread_updates), + ) + // Wildcard board media route: handles all /boards/** requests. + // For .mp4 files that have been transcoded away to .webm, issues a + // permanent redirect. All other paths are served directly from disk + // via tower-http ServeFile (Range, ETag, Content-Type handled correctly). + .route( + "/boards/{*media_path}", + get(crate::handlers::board::serve_board_media), + ) + .route("/admin", get(crate::handlers::admin::admin_index)) + .route( + "/admin/login", + post(crate::handlers::admin::admin_login).layer(DefaultBodyLimit::max(65_536)), + ) + .route("/admin/logout", post(crate::handlers::admin::admin_logout)) + .route("/admin/panel", get(crate::handlers::admin::admin_panel)) + .route( + "/admin/board/create", + post(crate::handlers::admin::create_board), + ) + .route( + "/admin/board/delete", + post(crate::handlers::admin::delete_board), + ) + .route( + "/admin/board/settings", + post(crate::handlers::admin::update_board_settings), + ) + .route( + "/admin/thread/action", + post(crate::handlers::admin::thread_action), + ) + .route( + "/admin/thread/delete", + post(crate::handlers::admin::admin_delete_thread), + ) + .route( + "/admin/post/delete", + post(crate::handlers::admin::admin_delete_post), + ) + .route("/admin/ban/add", post(crate::handlers::admin::add_ban)) + .route( + "/admin/ban/remove", + post(crate::handlers::admin::remove_ban), + ) + .route( + "/admin/report/resolve", + post(crate::handlers::admin::resolve_report), + ) + .route("/admin/mod-log", get(crate::handlers::admin::mod_log_page)) + .route( + "/admin/filter/add", + post(crate::handlers::admin::add_filter), + ) + .route( + "/admin/filter/remove", + post(crate::handlers::admin::remove_filter), + ) + .route( + "/admin/site/settings", + post(crate::handlers::admin::update_site_settings), + ) + .route("/admin/vacuum", post(crate::handlers::admin::admin_vacuum)) + .route( + "/admin/ip/{ip_hash}", + get(crate::handlers::admin::admin_ip_history), + ) + .route("/admin/backup", get(crate::handlers::admin::admin_backup)) + // Admin restore routes have no body-size cap — backups can be multi-GB + // and these endpoints require a valid admin session, so there is no + // anonymous upload risk. + .route( + "/admin/restore", + post(crate::handlers::admin::admin_restore).layer(DefaultBodyLimit::disable()), + ) + .route( + "/admin/board/backup/{board}", + get(crate::handlers::admin::board_backup), + ) + .route( + "/admin/board/restore", + post(crate::handlers::admin::board_restore).layer(DefaultBodyLimit::disable()), + ) + // ── Disk-based backup management routes ────────────────────────────── + .route( + "/admin/backup/create", + post(crate::handlers::admin::create_full_backup), + ) + .route( + "/admin/board/backup/create", + post(crate::handlers::admin::create_board_backup), + ) + .route( + "/admin/backup/download/{kind}/{filename}", + get(crate::handlers::admin::download_backup), + ) + .route( + "/admin/backup/progress", + get(crate::handlers::admin::backup_progress_json), + ) + .route( + "/admin/backup/delete", + post(crate::handlers::admin::delete_backup), + ) + .route( + "/admin/backup/restore-saved", + post(crate::handlers::admin::restore_saved_full_backup), + ) + .route( + "/admin/board/backup/restore-saved", + post(crate::handlers::admin::restore_saved_board_backup), + ) + .layer(axum_middleware::from_fn( + crate::middleware::rate_limit_middleware, + )) + .layer(DefaultBodyLimit::max(CONFIG.max_video_size)) + .layer(axum_middleware::from_fn(track_requests)) + // 3.3: Gzip/Brotli/Zstd response compression. HTML pages compress 5–10× + // with gzip and even better with Brotli. tower-http respects the client's + // Accept-Encoding header and negotiates the best supported algorithm. + // Applied before the trailing-slash normaliser so compressed responses + // are served correctly for all paths including redirects. + .layer(tower_http::compression::CompressionLayer::new()) + // Normalize trailing slashes before routing: redirect /path/ → /path (301). + // Applied last (outermost) so it fires before any other middleware sees the URI. + .layer(axum_middleware::from_fn( + crate::middleware::normalize_trailing_slash, + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("x-content-type-options"), + header::HeaderValue::from_static("nosniff"), + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("x-frame-options"), + header::HeaderValue::from_static("SAMEORIGIN"), + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("referrer-policy"), + header::HeaderValue::from_static("same-origin"), + )) + // FIX[NEW-H1]: 'unsafe-inline' removed from script-src. All JavaScript + // has been moved to /static/main.js (loaded with 'self') and + // /static/theme-init.js. Inline event handlers (onclick= etc.) have + // been replaced with data-* attributes handled by main.js event + // delegation, so no inline script execution is required. + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("content-security-policy"), + header::HeaderValue::from_static( + "default-src 'self'; \ + script-src 'self'; \ + style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: blob: https://img.youtube.com; \ + media-src 'self' blob:; \ + font-src 'self'; \ + connect-src 'self'; \ + frame-src https://www.youtube-nocookie.com https://streamable.com; \ + frame-ancestors 'none'; \ + object-src 'none'; \ + base-uri 'self'", + ), + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("permissions-policy"), + header::HeaderValue::from_static( + "geolocation=(), camera=(), microphone=(), payment=()", + ), + )) + // Fix #8: HSTS (RFC 6797 §7.2) MUST only be sent over HTTPS. + // Sending it over plain HTTP (localhost dev, Tor .onion) is incorrect + // and can cause Tor-aware clients to misbehave. The middleware below + // checks both the request scheme and the X-Forwarded-Proto header + // (set by TLS-terminating proxies) before adding the header. + .layer(axum_middleware::from_fn(hsts_middleware)) + // Structured per-request tracing: logs method, URI, status, and + // latency for every HTTP request. Spans are emitted at `info` level; + // failures at `error`. tower_http filter in RUST_LOG controls noise. + .layer( + tower_http::trace::TraceLayer::new_for_http() + .make_span_with(|request: &axum::http::Request<_>| { + tracing::info_span!( + "http_request", + method = %request.method(), + uri = %request.uri(), + ) + }) + .on_response( + |response: &axum::http::Response<_>, + latency: std::time::Duration, + _span: &tracing::Span| { + tracing::info!( + status = response.status().as_u16(), + latency_ms = latency.as_millis(), + "response sent", + ); + }, + ) + .on_failure( + |error: tower_http::classify::ServerErrorsFailureClass, + latency: std::time::Duration, + _span: &tracing::Span| { + tracing::error!( + %error, + latency_ms = latency.as_millis(), + "request failed", + ); + }, + ), + ) + .with_state(state) +} + +/// Middleware that adds `Strict-Transport-Security` only when the connection +/// is confirmed to be HTTPS (RFC 6797 §7.2). Checks both the URI scheme +/// (set by some reverse proxies) and the `X-Forwarded-Proto` header. +async fn hsts_middleware( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let is_https = req.uri().scheme_str() == Some("https") + || req + .headers() + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("https")); + + let mut resp = next.run(req).await; + if is_https { + resp.headers_mut().insert( + header::HeaderName::from_static("strict-transport-security"), + header::HeaderValue::from_static("max-age=31536000; includeSubDomains"), + ); + } + resp +} + +async fn serve_css() -> impl IntoResponse { + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "text/css; charset=utf-8"), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + STYLE_CSS, + ) +} + +async fn serve_main_js() -> impl IntoResponse { + ( + StatusCode::OK, + [ + ( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + MAIN_JS, + ) +} + +async fn serve_theme_init_js() -> impl IntoResponse { + ( + StatusCode::OK, + [ + ( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + THEME_INIT_JS, + ) +} + +// ─── Request tracking middleware ────────────────────────────────────────────── + +async fn track_requests( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + REQUEST_COUNT.fetch_add(1, Ordering::Relaxed); + IN_FLIGHT.fetch_add(1, Ordering::Relaxed); + + // FIX[AUDIT-2]: Bind the in-flight decrement to a RAII guard so it fires + // even if this future is cancelled (e.g. client disconnect, handler panic). + let _in_flight_guard = ScopedDecrement(&IN_FLIGHT); + + // Attach a per-request UUID to every tracing span so correlated log lines + // can be grouped by request even under concurrent load (#12). + let req_id = uuid::Uuid::new_v4(); + let method = req.method().clone(); + let path = req.uri().path().to_owned(); + let span = tracing::info_span!( + "request", + req_id = %req_id, + method = %method, + path = %path, + ); + + // Record the client IP for the "users online" display. + // CRIT-5: Store a SHA-256 hash of the IP (not the raw address) to avoid + // retaining PII in process memory and coredumps. + // CRIT-2: Use extract_ip() so proxy-forwarded real IPs are used instead + // of the raw socket address (which would always be the proxy's IP). + // Cap at 10,000 entries to prevent unbounded memory growth under a + // Sybil/bot attack rotating IPs (#11). The count is cosmetic so + // dropping inserts beyond the cap has no functional impact. + { + use sha2::{Digest, Sha256}; + let real_ip = crate::middleware::extract_ip(&req); + let mut h = Sha256::new(); + h.update(real_ip.as_bytes()); + let ip_hash = hex::encode(h.finalize()); + if ACTIVE_IPS.len() < 10_000 { + ACTIVE_IPS.insert(ip_hash, Instant::now()); + } + } + + // Detect file uploads by Content-Type + let is_upload = req + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.contains("multipart/form-data")); + + // FIX[AUDIT-2]: Bind upload decrement to a RAII guard for the same reason. + // Option<ScopedDecrement> is None when is_upload is false — zero-cost branch. + let _upload_guard = is_upload.then(|| { + ACTIVE_UPLOADS.fetch_add(1, Ordering::Relaxed); + ScopedDecrement(&ACTIVE_UPLOADS) + }); + + next.run(req).instrument(span).await +} + +// ─── Graceful shutdown ──────────────────────────────────────────────────────── + +async fn shutdown_signal() { + use tokio::signal; + let ctrl_c = async { + if let Err(e) = signal::ctrl_c().await { + tracing::error!("Failed to listen for Ctrl+C: {e}"); + } + }; + #[cfg(unix)] + let terminate = async { + match signal::unix::signal(signal::unix::SignalKind::terminate()) { + Ok(mut sig) => { + sig.recv().await; + } + Err(e) => { + tracing::error!("Failed to register SIGTERM handler: {e}"); + std::future::pending::<()>().await; + } + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + () = ctrl_c => info!("Received Ctrl+C"), + () = terminate => info!("Received SIGTERM"), + } +} diff --git a/src/templates/forms.rs b/src/templates/forms.rs index 22d2fcf..4fd3984 100644 --- a/src/templates/forms.rs +++ b/src/templates/forms.rs @@ -174,6 +174,23 @@ pub(super) fn reply_form( "" }; + // PoW CAPTCHA block — only rendered when the board has it enabled. + let captcha_row = if board.allow_captcha { + let difficulty: u32 = crate::utils::crypto::POW_DIFFICULTY; + format!( + r#" <tr id="captcha-row-{b}-reply"><td>captcha</td> + <td> + <span id="captcha-status-{b}-reply" style="font-size:0.8rem;color:var(--text-dim)">solving proof-of-work… (this takes a moment)</span> + <input type="hidden" name="pow_nonce" id="pow-nonce-{b}-reply" value="" + data-pow-board="{b}" data-pow-difficulty="{diff}"> + </td></tr>"#, + b = escape_html(board_short), + diff = difficulty, + ) + } else { + String::new() + }; + format!( r#"<div class="post-form-container reply-form-container"> <div class="post-form-title">[ reply to thread ]</div> @@ -191,6 +208,7 @@ pub(super) fn reply_form( {audio_combo_row} <tr><td>options</td> <td><label class="sage-label"><input type="checkbox" name="sage" value="1"> sage <span class="sage-hint">(don't bump thread)</span></label></td></tr> {edit_token_row} + {captcha_row} </table> </form> </div>"#, @@ -201,5 +219,6 @@ pub(super) fn reply_form( file_hint = file_hint, audio_combo_row = audio_combo_row, edit_token_row = edit_token_row, + captcha_row = captcha_row, ) } 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#"<a href="{}{sep}page={}">[prev]</a> "#, 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#" <a href="{}{sep}page={}">[next]</a>"#, 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..98e6798 100644 --- a/src/templates/thread.rs +++ b/src/templates/thread.rs @@ -180,20 +180,6 @@ pub fn thread_page( </div> <div class="post-form-wrap" id="post-form-wrap" style="display:none"> {form_html} -</div> - -<!-- Mobile sticky reply drawer — visible only on small screens via CSS --> -<div class="mobile-reply-fab" id="mobile-reply-fab" data-action="toggle-mobile-drawer"> - ✏ Reply -</div> -<div class="mobile-reply-drawer" id="mobile-reply-drawer"> - <div class="mobile-drawer-header"> - <span>reply to thread</span> - <button class="mobile-drawer-close" data-action="toggle-mobile-drawer">✕</button> - </div> - <div class="mobile-drawer-body"> - {form_html} - </div> </div>"# ); } @@ -260,7 +246,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 { @@ -462,7 +448,7 @@ pub fn render_post( html, r#"<div class="file-container audio-container"> <div class="file-info"> - <a href="/boards/{f}">{orig}</a> ({sz}) + <a href="/boards/{f}" target="_blank" rel="noreferrer">{orig}</a> ({sz}) </div> <div class="audio-thumb"> <img class="thumb" src="/boards/{th}" loading="lazy" alt="audio"> @@ -483,7 +469,7 @@ pub fn render_post( html, r#"<div class="file-container"> <div class="file-info"> - <a href="/boards/{f}">{orig}</a> ({sz}) + <a href="/boards/{f}" target="_blank" rel="noreferrer">{orig}</a> ({sz}) <button class="media-close-btn" data-action="collapse-media" style="display:none">✕ close</button> </div> <div class="media-preview" data-action="expand-media" title="click to play"> @@ -506,7 +492,7 @@ pub fn render_post( html, r#"<div class="file-container"> <div class="file-info"> - <a href="/boards/{f}">{orig}</a> ({sz}) + <a href="/boards/{f}" target="_blank" rel="noreferrer">{orig}</a> ({sz}) <button class="media-close-btn" data-action="collapse-media" style="display:none">✕ close</button> </div> <div class="media-preview" data-action="expand-media" title="click to expand"> @@ -537,7 +523,7 @@ pub fn render_post( html, r#"<div class="file-container audio-container audio-combo"> <div class="file-info"> - <a href="/boards/{f}">{orig}</a> ({sz}) + <a href="/boards/{f}" target="_blank" rel="noreferrer">{orig}</a> ({sz}) </div> <audio controls preload="none" class="audio-player"> <source src="/boards/{f}" type="{mime}"> @@ -563,7 +549,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#" <a class="edit-btn" href="/{board}/post/{pid}/edit" title="Edit post">edit</a>"#, diff --git a/src/theme-init.js b/src/theme-init.js deleted file mode 100644 index e405244..0000000 --- a/src/theme-init.js +++ /dev/null @@ -1,19 +0,0 @@ -// theme-init.js — loaded in <head> to apply saved theme before first paint, -// preventing a flash of the default terminal theme on page load. -// -// On first visit (no localStorage entry) we fall back to the site-configured -// default theme, which the server injects as data-default-theme on <html>. -// The chosen value is then persisted to localStorage so subsequent pages load -// without re-reading the attribute. -try { - var _t = localStorage.getItem('rustchan_theme'); - if (!_t) { - // First visit — check for a server-configured default. - var _d = document.documentElement.getAttribute('data-default-theme'); - if (_d && _d !== 'terminal') { - _t = _d; - localStorage.setItem('rustchan_theme', _t); - } - } - if (_t && _t !== 'terminal') document.documentElement.setAttribute('data-theme', _t); -} catch (e) {} diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 33181be..c410277 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; } @@ -238,8 +240,23 @@ fn leading_zero_bits(bytes: &[u8]) -> u32 { count } +// ─── Password validation ────────────────────────────────────────────────────── + +/// Validate an admin password meets minimum requirements. +/// Minimum 8 characters (enforced here; tighten as needed). +/// +/// # Errors +/// Returns an error if the password does not meet the minimum requirements. +pub fn validate_password(p: &str) -> anyhow::Result<()> { + if p.len() < 8 { + anyhow::bail!("Password must be at least 8 characters."); + } + Ok(()) +} + #[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..43f2bb2 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; @@ -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.")); @@ -160,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 `<svg` or an XML declaration `<?xml`. + // Security note: SVG files can embed JavaScript via event handlers. + // The server must serve them with Content-Security-Policy or as attachments. + // Stored as-is; the media pipeline does not transcode SVG. + { + // Inspect up to 512 bytes of (possibly UTF-8 with BOM) text. + let text_peek = data.get(..data.len().min(512)).unwrap_or(data); + // Strip a UTF-8 BOM if present. + let text_peek = text_peek.strip_prefix(b"\xef\xbb\xbf").unwrap_or(text_peek); + if text_peek.starts_with(b"<svg") + || text_peek.starts_with(b"<SVG") + || text_peek.starts_with(b"<?xml") + { + return Ok("image/svg+xml"); + } + } // ── Audio: ID3-tagged MP3 ───────────────────────────────────────────────── if header.starts_with(b"ID3") { @@ -189,8 +221,8 @@ pub fn detect_mime_type(data: &[u8]) -> 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" )) } @@ -211,7 +243,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!( @@ -239,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 @@ -256,6 +292,7 @@ pub fn save_upload( max_video_size: usize, max_audio_size: usize, ffmpeg_available: bool, + ffmpeg_webp_available: bool, ) -> Result<UploadedFile> { if data.is_empty() { return Err(anyhow::anyhow!("File is empty.")); @@ -290,21 +327,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<Vec<u8>> = if mime_type == "image/jpeg" { match strip_jpeg_exif(data) { Ok(clean) => { @@ -332,111 +366,155 @@ 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. + check_disk_space(&dest_dir, data.len())?; - // 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())?; + // ── 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 _; + // Give the temp file the correct extension so ffmpeg can reliably + // detect the input format by extension (in addition to magic bytes). + // Without an extension, some ffmpeg builds fail to select a demuxer + // for formats like JPEG even when the magic bytes are valid. + let ext = mime_to_ext(mime_type); + let mut tmp = tempfile::Builder::new() + .suffix(&format!(".{ext}")) + .tempfile_in(&dest_dir) + .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 + }; - // 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). + // ── Run media conversion + thumbnail generation via MediaProcessor ──────── + let processor = + crate::media::MediaProcessor::new_with_ffmpeg_caps(ffmpeg_available, ffmpeg_webp_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") { - 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")?; + 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. @@ -540,456 +618,16 @@ fn strip_jpeg_exif(data: &[u8]) -> Result<Vec<u8>> { 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<Vec<u8>> { - 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##"<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250" viewBox="0 0 250 250"> - <rect width="250" height="250" fill="#0a0f0a"/> - <circle cx="125" cy="125" r="60" fill="#0d120d" stroke="#00c840" stroke-width="2"/> - <polygon points="108,95 108,155 165,125" fill="#00c840"/> - <text x="125" y="215" text-anchor="middle" fill="#3a4a3a" font-family="monospace" font-size="12">VIDEO</text> -</svg>"##; - 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##"<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250" viewBox="0 0 250 250"> - <rect width="250" height="250" fill="#0a0f0a"/> - <circle cx="125" cy="125" r="60" fill="#0d120d" stroke="#00c840" stroke-width="2"/> - <text x="125" y="140" text-anchor="middle" fill="#00c840" font-family="monospace" font-size="48">♫</text> - <text x="125" y="215" text-anchor="middle" fill="#3a4a3a" font-family="monospace" font-size="12">AUDIO</text> -</svg>"##; - 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 { @@ -997,6 +635,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 @@ -1010,66 +651,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<String> { - 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<String> { - ffprobe_video_codec(file_path) -} - /// Delete a file from the filesystem, ignoring not-found errors. /// /// # Security @@ -1119,34 +700,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<Vec<u8>> { - 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 ───────────────────────────────────────────────────── @@ -1320,7 +880,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); } @@ -1329,7 +889,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); } @@ -1337,7 +897,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); } @@ -1345,7 +905,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); } @@ -1353,8 +913,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"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>"; + assert_eq!(detect_mime_type(data).expect("svg"), "image/svg+xml"); + } + + #[test] + fn detect_svg_xml_declaration() { + let data = b"<?xml version=\"1.0\"?><svg></svg>"; + 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/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<Regex> = LazyLock::new(|| Regex::new(r">>(\d+)").expect("RE_REPLY is valid")); +#[allow(clippy::expect_used)] static RE_CROSSLINK: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r">>>/([a-z0-9]+)/(\d+)?").expect("RE_CROSSLINK is valid") }); +#[allow(clippy::expect_used)] static RE_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(https?://[^\s&<>]{3,300})").expect("RE_URL is valid")); +#[allow(clippy::expect_used)] static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").expect("RE_BOLD is valid")); +#[allow(clippy::expect_used)] static RE_ITALIC: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").expect("RE_ITALIC is valid")); +#[allow(clippy::expect_used)] static RE_SPOILER: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"\[spoiler\]([\s\S]*?)\[/spoiler\]").expect("RE_SPOILER is valid") }); +#[allow(clippy::expect_used)] static RE_DICE: LazyLock<Regex> = 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<String> { // youtu.be/ID if let Some(pos) = url.find("youtu.be/") { @@ -103,6 +111,7 @@ fn extract_yt_id(url: &str) -> Option<String> { extract_yt_id_from_watch_param(url) } +#[allow(clippy::arithmetic_side_effects)] fn extract_yt_id_from_watch_param(url: &str) -> Option<String> { 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<String> { None } +#[allow(clippy::arithmetic_side_effects)] fn extract_streamable_id(url: &str) -> Option<String> { // 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>, 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 `<body>`). #[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<String>) { /// 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..39456af 100644 --- a/src/workers/mod.rs +++ b/src/workers/mod.rs @@ -183,6 +183,7 @@ impl JobQueue { pub fn start_worker_pool( queue: &Arc<JobQueue>, ffmpeg_available: bool, + ffmpeg_vp9_available: bool, ) -> Vec<tokio::task::JoinHandle<()>> { let n = std::thread::available_parallelism() .map(NonZero::get) @@ -195,7 +196,7 @@ pub fn start_worker_pool( .map(|idx| { let q = queue.clone(); tokio::spawn(async move { - worker_loop(idx, q, ffmpeg_available).await; + worker_loop(idx, q, ffmpeg_available, ffmpeg_vp9_available).await; }) }) .collect() @@ -203,7 +204,12 @@ pub fn start_worker_pool( // ─── Worker loop ───────────────────────────────────────────────────────────── -async fn worker_loop(id: usize, queue: Arc<JobQueue>, ffmpeg_available: bool) { +async fn worker_loop( + id: usize, + queue: Arc<JobQueue>, + ffmpeg_available: bool, + ffmpeg_vp9_available: bool, +) { debug!("Worker {id} started"); // FIX[HIGH-6]: Track consecutive errors for exponential back-off. // Reset to 0 whenever a job is successfully claimed or the queue is empty. @@ -234,6 +240,7 @@ async fn worker_loop(id: usize, queue: Arc<JobQueue>, ffmpeg_available: bool) { job_id, &payload, ffmpeg_available, + ffmpeg_vp9_available, queue.pool.clone(), queue.in_progress.clone(), ) @@ -315,6 +322,7 @@ async fn worker_loop(id: usize, queue: Arc<JobQueue>, 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; @@ -334,6 +342,7 @@ async fn handle_job( job_id: i64, payload: &str, ffmpeg_available: bool, + ffmpeg_vp9_available: bool, pool: DbPool, in_progress: Arc<DashMap<String, bool>>, ) -> Result<()> { @@ -362,6 +371,7 @@ async fn handle_job( file_path.clone(), board_short, ffmpeg_available, + ffmpeg_vp9_available, pool, ) .await; @@ -418,12 +428,17 @@ async fn handle_job( /// Transcode an MP4 upload to `WebM` (VP9 + Opus), then update the post's /// `file_path` and `mime_type`. The original MP4 is deleted on success. /// +/// Requires both `ffmpeg_available` (binary present) and `ffmpeg_vp9_available` +/// (libvpx-vp9 + libopus compiled in). If either flag is false the job is +/// skipped gracefully — no error is returned and the file remains as-is. +/// /// A hard timeout of `CONFIG.ffmpeg_timeout_secs` is applied (2.3). async fn transcode_video( post_id: i64, file_path: String, board_short: String, ffmpeg_available: bool, + ffmpeg_vp9_available: bool, pool: DbPool, ) -> Result<()> { if !ffmpeg_available { @@ -431,6 +446,14 @@ async fn transcode_video( return Ok(()); } + if !ffmpeg_vp9_available { + warn!( + "VideoTranscode skipped for post {post_id}: libvpx-vp9 or libopus not available. \ + Install ffmpeg with VP9 + Opus support to enable MP4→WebM transcoding." + ); + return Ok(()); + } + let timeout_secs = CONFIG.ffmpeg_timeout_secs; let ffmpeg_timeout = Duration::from_secs(timeout_secs); @@ -460,6 +483,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); @@ -488,7 +513,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"); } @@ -520,39 +545,38 @@ 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. + // 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. // - // 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}"))?; - } + // The temp file is given a .webm extension so that ffmpeg can identify the + // correct muxer from the output filename. An extensionless temp file causes + // ffmpeg to fail immediately because it cannot determine the output format. + let tmp = tempfile::Builder::new() + .suffix(".webm") + .tempfile_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")?; @@ -630,6 +654,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); @@ -646,6 +672,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; @@ -657,7 +685,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)?; @@ -669,10 +709,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", @@ -737,3 +775,84 @@ fn run_spam_check(post_id: i64, ip_hash: &str, body_len: usize) { } let _ = ip_hash; } + +// ─── Thumbnail / waveform cache eviction ───────────────────────────────────── + +/// Walk every board's `thumbs/` subdirectory, collect all files with their +/// modification times, and delete the oldest ones until the total size of +/// the remaining set is under `max_bytes`. +/// +/// 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)] +pub 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(); + let Ok(boards_iter) = std::fs::read_dir(upload_dir) else { + return; + }; + for board_entry in boards_iter.flatten() { + let thumbs_dir = board_entry.path().join("thumbs"); + if !thumbs_dir.is_dir() { + continue; + } + let Ok(thumbs_iter) = std::fs::read_dir(&thumbs_dir) else { + continue; + }; + for entry in thumbs_iter.flatten() { + let path = entry.path(); + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_secs()); + files.push((mtime, path, meta.len())); + } + } + } + } + + // FIX[AUDIT-7]: Dereference `sz` explicitly and annotate the sum type for + // clarity. `Iterator<Item = &u64>` implements `Sum<&u64>` in std so this + // compiled before, but the explicit form is more readable and avoids the + // implicit coercion. + let total: u64 = files.iter().map(|(_, _, sz)| *sz).sum::<u64>(); + if total <= max_bytes { + return; // already within budget + } + + // Sort oldest-first so we delete the least-recently-used files first. + files.sort_unstable_by_key(|(mtime, _, _)| *mtime); + + let mut remaining = total; + let mut deleted = 0u64; + let mut deleted_bytes = 0u64; + for (_, path, size) in &files { + if remaining <= max_bytes { + break; + } + match std::fs::remove_file(path) { + Ok(()) => { + remaining = remaining.saturating_sub(*size); + deleted += 1; + // FIX[AUDIT-7]: Dereference `size` for clarity. + deleted_bytes += *size; + } + Err(e) => { + warn!("evict_thumb_cache: failed to delete {:?}: {}", path, e); + } + } + } + if deleted > 0 { + info!( + "evict_thumb_cache: removed {} file(s) ({} KiB), cache now {} KiB / {} KiB limit", + deleted, + deleted_bytes / 1024, + remaining / 1024, + max_bytes / 1024, + ); + } +} diff --git a/static/main.js b/static/main.js index ac1905d..126104f 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; } @@ -114,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'; } @@ -585,7 +565,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 +1068,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;