diff --git a/CHANGELOG.md b/CHANGELOG.md index 7569e87..295afdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,131 @@ All notable changes to RustChan will be documented in this file. ## [1.0.13] — 2026-03-08 +## WAL Mode + Connection Tuning +**`db/mod.rs`** + +`cache_size` bumped from `-4096` (4 MiB) to `-32000` (32 MiB) in the pool's `with_init` pragma block. The `journal_mode=WAL` and `synchronous=NORMAL` pragmas were already present. + +--- + +## Missing Indexes +**`db/mod.rs`** + +Two new migrations added at the end of the migration table: + +- **Migration 23:** `CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)` — supplements the existing composite index for queries that filter on `thread_id` alone. +- **Migration 24:** `CREATE INDEX IF NOT EXISTS idx_posts_ip_hash ON posts(ip_hash)` — eliminates the full-table scan on the admin IP history page and per-IP cooldown checks. + +--- + +## Prepared Statement Caching Audit +**`db/threads.rs` · `db/boards.rs` · `db/posts.rs`** + +All remaining bare `conn.prepare(...)` calls on hot or repeated queries replaced with `conn.prepare_cached(...)`: `delete_thread`, `archive_old_threads`, `prune_old_threads` (outer `SELECT`) in `threads.rs`; `delete_board` in `boards.rs`; `search_posts` in `posts.rs`. Every query path is now consistently cached. + +--- + +## Transaction Batching for Thread Prune +Already implemented in the codebase. Both `prune_old_threads` and `archive_old_threads` already use `unchecked_transaction()` / `tx.commit()` to batch all deletes/updates into a single atomic transaction. No changes needed. + +--- + +## RETURNING Clause for Inserts +**`db/threads.rs` · `db/posts.rs`** + +`create_thread_with_op` and `create_post_inner` now use `INSERT … RETURNING id` via `query_row`, replacing the `execute()` + `last_insert_rowid()` pattern. The new ID is returned atomically in the same statement, eliminating the implicit coupling to connection-local state. + +--- + +## Scheduled VACUUM +**`config.rs` · `main.rs`** + +Added `auto_vacuum_interval_hours = 24` to config. A background Tokio task now sleeps for the configured interval (staggered from startup), then calls `db::run_vacuum()` via `spawn_blocking` and logs the bytes reclaimed. + +--- + +## Expired Poll Cleanup +**`config.rs` · `main.rs` · `db/posts.rs`** + +Added `poll_cleanup_interval_hours = 72`. A new `cleanup_expired_poll_votes()` DB function deletes vote rows for polls whose `expires_at` is older than the retention window. A background task runs it on the configured interval, preserving poll questions and options. + +--- + +## DB Size Warning +**`config.rs` · `handlers/admin.rs` · `templates/admin.rs`** + +Added `db_warn_threshold_mb = 2048`. The admin panel handler reads the actual file size via `std::fs::metadata`, computes a boolean flag, and passes it to the template. The template renders a red warning banner in the database maintenance section when the threshold is exceeded. + +--- + +## Job Queue Back-Pressure +**`config.rs` · `workers/mod.rs`** + +Added `job_queue_capacity = 1000`. The `enqueue()` method now checks `pending_job_count()` before inserting — if the queue is at or over capacity, the job is dropped with a `warn!` log and a sentinel `-1` is returned, avoiding OOM under post floods. + +--- + +## Coalesce Duplicate Media Jobs +**`workers/mod.rs`** + +Added an `Arc>` (`in_progress`) to `JobQueue`. Before dispatching a `VideoTranscode` or `AudioWaveform` job, `handle_job` checks if the `file_path` is already in the map — if so it skips and logs. The entry is removed on both success and failure. + +--- + +## FFmpeg Timeout +**`config.rs` · `workers/mod.rs`** + +Replaced hardcoded `FFMPEG_TRANSCODE_TIMEOUT` / `FFMPEG_WAVEFORM_TIMEOUT` constants with `CONFIG.ffmpeg_timeout_secs` (default: `120`). Both `transcode_video` and `generate_waveform` now read this value at runtime so operators can tune it in `settings.toml`. + +--- + +## Auto-Archive Before Prune +**`workers/mod.rs` · `config.rs`** + +`prune_threads` now evaluates `allow_archive || CONFIG.archive_before_prune`. The new global flag (default `true`) means no thread is ever silently hard-deleted on a board that has archiving enabled at the global level, even if the individual board didn't opt in. + +--- + +## Waveform Cache Eviction +**`main.rs` · `config.rs`** + +A background task runs every hour (after a 30-min startup stagger). It walks every `{board}/thumbs/` directory, sorts files oldest-first by mtime, and deletes until total size is under `waveform_cache_max_mb` (default 200 MiB). A new `evict_thumb_cache` function handles the scan-and-prune logic; originals are never touched. + +--- + +## Streaming Multipart +**`handlers/mod.rs`** + +The old `.bytes().await` (full in-memory buffering) is replaced by `read_field_bytes`, which streams via `.chunk()` and returns a `413 UploadTooLarge` the moment the running total exceeds the configured limit — before memory is exhausted. + +--- + +## ETag / Conditional GET +**`handlers/board.rs` · `handlers/thread.rs`** + +Both handlers now accept `HeaderMap`, derive an ETag (board index: `"{max_bump_ts}-{page}"`; thread: `"{bumped_at}"`), check `If-None-Match`, and return `304 Not Modified` on a hit. The ETag is included on all 200 responses too. + +--- + +## Gzip / Brotli Compression +**`main.rs` · `Cargo.toml`** + +`tower-http` features updated to `compression-full`. `CompressionLayer::new()` added to the middleware stack — it negotiates gzip, Brotli, or zstd based on the client's `Accept-Encoding` header. + +--- + +## Blocking Pool Sizing +**`main.rs` · `config.rs`** + +`#[tokio::main]` replaced with a manual `tokio::runtime::Builder` that calls `.max_blocking_threads(CONFIG.blocking_threads)`. Default is `logical_cpus × 4` (auto-detected); configurable via `blocking_threads` in `settings.toml` or `CHAN_BLOCKING_THREADS`. + +--- + +## EXIF Orientation Correction +**`utils/files.rs` · `Cargo.toml`** + +`kamadak-exif = "0.5"` added. `generate_image_thumb` now calls `read_exif_orientation` for JPEGs and passes the result to `apply_exif_orientation`, which dispatches to `imageops::rotate90/180/270` and `flip_horizontal/vertical` as needed. Non-JPEG formats skip the EXIF path entirely. + ### ✨ Added - **Backup system rewritten to stream instead of buffering in RAM** — all backup operations previously loaded entire zip files into memory, risking OOM on large instances. Downloads now stream from disk in 64 KiB chunks (browsers also get a proper progress bar). Backup creation now writes directly to disk via temp files with atomic rename on success, so partial backups never appear in the saved list. Individual file archiving now streams through an 8 KiB buffer instead of reading each file fully into memory. Peak RAM usage dropped from "entire backup size" to roughly 64 KiB regardless of instance size. - **ChanClassic theme** — a new theme that mimics the classic 4chan aesthetic: light tan/beige background, maroon/red accents, blue post-number links, and the iconic post block styling. Available in the theme picker alongside existing themes. diff --git a/Cargo.lock b/Cargo.lock index a0171e0..e8e1340 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -94,6 +109,18 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -222,6 +249,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -253,6 +301,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -328,6 +378,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "cookie" version = "0.18.1" @@ -812,6 +882,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -822,6 +902,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -957,6 +1046,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1268,8 +1363,10 @@ dependencies = [ "dashmap", "hex", "image", + "kamadak-exif", "libc", "once_cell", + "parking_lot", "r2d2", "r2d2_sqlite", "rand_core 0.6.4", @@ -1699,6 +1796,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -2262,6 +2360,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index dd2908d..9d74064 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ 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"] } +tower-http = { version = "0.6", features = ["fs", "set-header", "compression-full"] } tokio = { version = "1", features = ["full"] } tokio-util = "0.7" @@ -54,13 +54,19 @@ rand_core = { version = "0.6", features = ["getrandom"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +# EXIF orientation correction: read Orientation tag from JPEG uploads and +# apply the corresponding rotation before thumbnailing so that photos taken +# on phones display upright (4.1). +kamadak-exif = "0.5" + clap = { version = "4", features = ["derive"] } uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } -dashmap = "6" -anyhow = "1" -once_cell = "1" +dashmap = "6" +parking_lot = "0.12" +anyhow = "1" +once_cell = "1" regex = "1" # zip 8: SimpleFileOptions and core ZipWriter/ZipArchive API unchanged. @@ -84,3 +90,9 @@ unwrap_used = "warn" # force .expect("reason") or proper ha expect_used = "allow" # allow in tests; use with a message in production indexing_slicing = "warn" # prefer .get() with bounds check arithmetic_side_effects = "allow" # too noisy for general arithmetic +missing_errors_doc = "allow" # pedantic docs lint, too noisy for all Results +doc_markdown = "allow" # allow non-backticked identifiers in docs +doc_lazy_continuation = "allow" # don't require strict indentation for doc lists +items_after_statements = "allow" # permit helper items inside functions +wildcard_imports = "allow" # crate-level style choice for imports +uninlined_format_args = "allow" # allow older-style format! calls diff --git a/clippy.sh b/clippy.sh new file mode 100755 index 0000000..cc492c3 --- /dev/null +++ b/clippy.sh @@ -0,0 +1,55 @@ +#!/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 190753f..9e5b716 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,28 +11,25 @@ // SECURITY: The cookie_secret is auto-generated on first run and persisted // to settings.toml. It is never left at a well-known default value. -use once_cell::sync::Lazy; use rand_core::{OsRng, RngCore}; use serde::Deserialize; use std::env; use std::path::PathBuf; +use std::sync::LazyLock; /// Absolute path to the directory the running binary lives in. fn binary_dir() -> PathBuf { - match std::env::current_exe() + std::env::current_exe() .ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())) - { - Some(dir) => dir, - None => { + .and_then(|p| p.parent().map(std::path::Path::to_path_buf)) + .unwrap_or_else(|| { eprintln!( "Warning: could not determine binary directory; \ using current working directory for data storage. \ Set CHAN_DB and CHAN_UPLOADS env vars to override." ); PathBuf::from(".") - } - } + }) } fn settings_file_path() -> PathBuf { @@ -60,9 +57,36 @@ struct SettingsFile { cookie_secret: Option, enable_tor_support: Option, require_ffmpeg: Option, - /// How often to run PRAGMA wal_checkpoint(TRUNCATE), in seconds. + /// How often to run PRAGMA `wal_checkpoint(TRUNCATE)`, in seconds. /// Set to 0 to disable. Default: 3600 (hourly). wal_checkpoint_interval_secs: Option, + /// How often to run VACUUM to reclaim disk space, in hours. + /// Set to 0 to disable. Default: 24 (daily). + auto_vacuum_interval_hours: Option, + /// How often to purge vote records for expired polls, in hours. + /// Set to 0 to disable. Default: 72 (every 3 days). + poll_cleanup_interval_hours: Option, + /// Database file size (MB) above which a warning banner is shown in the + /// admin panel. Set to 0 to disable. Default: 2048 (2 GiB). + db_warn_threshold_mb: Option, + /// Maximum number of pending jobs in the background job queue. + /// When this limit is reached, new jobs are dropped (with a warning) rather + /// than accepted. Default: 1000. + job_queue_capacity: Option, + /// Maximum seconds to allow a single `FFmpeg` transcode or waveform job to + /// run before it is killed. Default: 120. + ffmpeg_timeout_secs: Option, + /// When true, overflow threads are always archived rather than hard-deleted, + /// even on boards with `allow_archive` = false. Default: true. + archive_before_prune: Option, + /// Maximum total size (MiB) of all thumbnail/waveform cache files across all + /// boards. A background task evicts the oldest files when exceeded. + /// Set to 0 to disable. Default: 200. + waveform_cache_max_mb: Option, + /// Number of threads in Tokio's blocking pool (`spawn_blocking`). + /// Defaults to logical CPUs × 4. Increase if DB/render latency is a bottleneck + /// under load. + blocking_threads: Option, } fn load_settings_file() -> SettingsFile { @@ -79,7 +103,7 @@ fn load_settings_file() -> SettingsFile { /// Create settings.toml with defaults if it does not exist yet. /// Call this once at startup (before CONFIG is accessed for the first time). /// -/// A cryptographically random cookie_secret is generated on first run and +/// A cryptographically random `cookie_secret` is generated on first run and /// written to settings.toml. Subsequent runs load it from the file. /// The server never operates with a known/default secret. pub fn generate_settings_file_if_missing() { @@ -138,6 +162,46 @@ require_ffmpeg = false # Set to 0 to disable. Default: 3600 (hourly). wal_checkpoint_interval_secs = 3600 +# How often (in hours) to run VACUUM automatically to reclaim disk space +# freed by deleted posts and threads. Set to 0 to disable. Default: 24. +auto_vacuum_interval_hours = 24 + +# How often (in hours) to purge vote records for polls that have expired. +# The poll question and options are kept for display; only per-IP vote rows +# are deleted. Set to 0 to disable. Default: 72. +poll_cleanup_interval_hours = 72 + +# Database file size (MiB) above which a warning banner appears in the admin +# panel. Set to 0 to disable. Default: 2048 (2 GiB). +db_warn_threshold_mb = 2048 + +# Maximum number of pending background jobs (video transcode, waveform, etc.) +# allowed in the queue at once. When this limit is reached, new jobs are +# silently dropped (with a warning log) rather than accepted. Default: 1000. +job_queue_capacity = 1000 + +# Maximum seconds a single FFmpeg transcode or waveform job may run before +# it is killed. Prevents pathological media files from stalling the worker +# pool indefinitely. Default: 120. +ffmpeg_timeout_secs = 120 + +# When true, threads that would be hard-deleted by the prune worker are instead +# moved to the archive table, even on boards where archiving is disabled. This +# acts as a global safety net against silent data loss when a board hits its +# thread limit. Default: true. +archive_before_prune = true + +# Maximum total size (MiB) of all thumbnail/waveform cache files across all +# boards. A background task periodically evicts the oldest files when the +# total exceeds this value. Set to 0 to disable. Default: 200. +waveform_cache_max_mb = 200 + +# Number of threads in Tokio's blocking pool (spawn_blocking). Every page +# render and DB write goes through this pool; sizing it to CPUs × 4 prevents +# it from becoming a bottleneck under concurrent load. +# Default: logical CPUs × 4 (auto-detected at startup; leave 0 for auto). +blocking_threads = 0 + # Secret key for IP hashing. # AUTO-GENERATED on first run — do NOT change after your first post, # or all existing IP hashes become invalid (bans will stop working). @@ -147,15 +211,16 @@ cookie_secret = "{secret}" ); match std::fs::write(&path, content) { - Ok(_) => println!("Created settings.toml ({})", path.display()), + Ok(()) => println!("Created settings.toml ({})", path.display()), Err(e) => eprintln!("Warning: could not write settings.toml: {e}"), } } // ─── Runtime config ─────────────────────────────────────────────────────────── -pub static CONFIG: Lazy = Lazy::new(Config::from_env); +pub static CONFIG: LazyLock = LazyLock::new(Config::from_env); +#[allow(clippy::struct_excessive_bools)] pub struct Config { // ── Loaded from settings.toml (env vars still override) ────────────────── pub forum_name: String, @@ -185,7 +250,7 @@ pub struct Config { pub default_bump_limit: u32, #[allow(dead_code)] // used as default when creating boards via CLI/admin pub max_threads_per_board: u32, - /// Maximum GET requests per IP per rate_limit_window. + /// Maximum GET requests per IP per `rate_limit_window`. pub rate_limit_gets: u32, pub rate_limit_window: u64, pub cookie_secret: String, @@ -194,9 +259,29 @@ pub struct Config { pub https_cookies: bool, /// Interval in seconds between WAL checkpoint runs. 0 = disabled. pub wal_checkpoint_interval: u64, + /// Interval in hours between automatic VACUUM runs. 0 = disabled. + pub auto_vacuum_interval_hours: u64, + /// Interval in hours between expired poll vote cleanup runs. 0 = disabled. + pub poll_cleanup_interval_hours: u64, + /// DB file size threshold in bytes above which admin panel shows a warning. + /// 0 = disabled. + pub db_warn_threshold_bytes: u64, + /// Maximum number of pending jobs before new ones are dropped. + pub job_queue_capacity: u64, + /// Maximum seconds a single `FFmpeg` job may run before being killed. + pub ffmpeg_timeout_secs: u64, + /// When true, threads are always archived (never hard-deleted) on prune, + /// overriding individual board settings. + pub archive_before_prune: bool, + /// Total thumbnail/waveform cache size limit in bytes. 0 = disabled. + pub waveform_cache_max_bytes: u64, + /// Number of threads in Tokio's blocking pool. Default: logical CPUs × 4. + pub blocking_threads: usize, } impl Config { + #[must_use] + #[allow(clippy::too_many_lines)] pub fn from_env() -> Self { let s = load_settings_file(); let data_dir = binary_dir().join("rustchan-data"); @@ -278,13 +363,68 @@ impl Config { "CHAN_WAL_CHECKPOINT_SECS", s.wal_checkpoint_interval_secs.unwrap_or(3600), ), + auto_vacuum_interval_hours: env_parse( + "CHAN_AUTO_VACUUM_HOURS", + s.auto_vacuum_interval_hours.unwrap_or(24), + ), + poll_cleanup_interval_hours: env_parse( + "CHAN_POLL_CLEANUP_HOURS", + s.poll_cleanup_interval_hours.unwrap_or(72), + ), + db_warn_threshold_bytes: { + let mb = env_parse::( + "CHAN_DB_WARN_THRESHOLD_MB", + s.db_warn_threshold_mb.unwrap_or(2048), + ); + mb * 1024 * 1024 + }, + job_queue_capacity: env_parse( + "CHAN_JOB_QUEUE_CAPACITY", + s.job_queue_capacity.unwrap_or(1000), + ), + ffmpeg_timeout_secs: env_parse( + "CHAN_FFMPEG_TIMEOUT_SECS", + s.ffmpeg_timeout_secs.unwrap_or(120), + ), + archive_before_prune: env_bool( + "CHAN_ARCHIVE_BEFORE_PRUNE", + s.archive_before_prune.unwrap_or(true), + ), + waveform_cache_max_bytes: { + let mb = env_parse::( + "CHAN_WAVEFORM_CACHE_MAX_MB", + s.waveform_cache_max_mb.unwrap_or(200), + ); + mb * 1024 * 1024 + }, + blocking_threads: { + let cpus = std::thread::available_parallelism() + .map(std::num::NonZero::get) + .unwrap_or(4); + let configured = + env_parse("CHAN_BLOCKING_THREADS", s.blocking_threads.unwrap_or(0)); + if configured == 0 { + cpus * 4 + } else { + configured + } + }, } } /// Validate critical configuration values and abort with a clear error /// message if any are out of range. Called once at startup so operators /// catch misconfiguration immediately rather than discovering it at runtime. + /// + /// # Errors + /// Returns an error if any configuration value is out of an acceptable range, + /// or if the upload directory is not writable. pub fn validate(&self) -> anyhow::Result<()> { + const MIB: usize = 1024 * 1024; + const MAX_IMAGE_MIB: usize = 100; + const MAX_VIDEO_MIB: usize = 2048; + const MAX_AUDIO_MIB: usize = 512; + // cookie_secret is hex-encoded: 64 hex chars = 32 bytes of entropy. if self.cookie_secret.len() < 64 { anyhow::bail!( @@ -295,11 +435,6 @@ impl Config { ); } - const MIB: usize = 1024 * 1024; - const MAX_IMAGE_MIB: usize = 100; - const MAX_VIDEO_MIB: usize = 2048; - const MAX_AUDIO_MIB: usize = 512; - if self.max_image_size < MIB || self.max_image_size > MAX_IMAGE_MIB * MIB { anyhow::bail!( "CONFIG ERROR: max_image_size_mb must be between 1 and {} MiB (got {} MiB).", @@ -343,9 +478,65 @@ impl Config { } } +/// Update `forum_name` and `site_subtitle` in `settings.toml` in-place, +/// preserving all other lines and comments. +/// +/// Called by the admin site-settings handler so that changes made via the +/// panel are reflected in the file and survive a restart without the operator +/// needing to hand-edit `settings.toml`. +/// +/// If the key is not yet present in the file the function is a no-op for that +/// key (it won't append new lines — the file is only updated if the key already +/// exists). On a fresh install `generate_settings_file_if_missing` always +/// writes both keys, so this is only a concern for manually-crafted files. +pub fn update_settings_file_site_names(forum_name: &str, site_subtitle: &str) { + // Escape backslash and double-quote, then wrap in double quotes. + fn toml_quote(s: &str) -> String { + let inner = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{inner}\"") + } + + let path = settings_file_path(); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: could not read settings.toml for update: {e}"); + return; + } + }; + + // Replace the value portion of `key = "..."` lines while preserving + // indentation, comments on the same line, and surrounding whitespace. + // We use a simple line-by-line scan so that file comments are untouched. + + let trailing_newline = content.ends_with('\n'); + let updated: Vec = content + .lines() + .map(|line| { + // Match `forum_name = ...` (possibly with surrounding spaces). + if line.trim_start().starts_with("forum_name") && line.contains('=') { + return format!("forum_name = {}", toml_quote(forum_name)); + } + if line.trim_start().starts_with("site_subtitle") && line.contains('=') { + return format!("site_subtitle = {}", toml_quote(site_subtitle)); + } + line.to_string() + }) + .collect(); + + let mut out = updated.join("\n"); + if trailing_newline { + out.push('\n'); + } + + if let Err(e) = std::fs::write(&path, out) { + eprintln!("Warning: could not write updated settings.toml: {e}"); + } +} + // ─── Cookie secret rotation check ──────────────────────────────────────────── -/// Check whether the cookie_secret has changed since the last run by comparing +/// Check whether the `cookie_secret` has changed since the last run by comparing /// a SHA-256 hash stored in the DB against the currently loaded secret. /// /// Called once at startup after the DB pool is ready. @@ -353,6 +544,7 @@ impl Config { /// On first run (no stored hash), silently stores the current hash and returns. pub fn check_cookie_secret_rotation(conn: &rusqlite::Connection) { use sha2::{Digest, Sha256}; + const KEY: &str = "cookie_secret_hash"; let current_hash = { let mut h = Sha256::new(); @@ -360,8 +552,6 @@ pub fn check_cookie_secret_rotation(conn: &rusqlite::Connection) { hex::encode(h.finalize()) }; - const KEY: &str = "cookie_secret_hash"; - let stored = conn .query_row( "SELECT value FROM site_settings WHERE key = ?1", diff --git a/src/db/admin.rs b/src/db/admin.rs index 82e2199..503964c 100644 --- a/src/db/admin.rs +++ b/src/db/admin.rs @@ -3,13 +3,33 @@ // Covers: admin user & session management, bans, word filters, user reports, // moderation log, ban appeals, IP history, WAL checkpoint, VACUUM, DB size, // and the list_admins helper used by CLI tooling. - -use crate::models::*; +// +// FIX summary (from audit): +// HIGH-1 is_banned: switched to prepare_cached (hot path on every post submission) +// HIGH-2 get_word_filters: switched to prepare_cached (hot path on every post submission) +// HIGH-3 get_posts_by_ip_hash: removed unnecessary threads join; posts already carries board_id +// MED-4 create_admin, add_ban, add_word_filter, file_report, file_ban_appeal: +// INSERT … RETURNING id replaces execute + last_insert_rowid() +// MED-5 update_admin_password, remove_ban, resolve_report, dismiss_ban_appeal: +// added rows-affected checks so missing targets surface as errors +// MED-6 accept_ban_appeal: status='accepted' (was duplicating 'dismissed') +// already correct; doc comment clarified +// MED-7 file_report: added has_reported_post guard to prevent spam reports +// MED-8 has_recent_appeal / file_ban_appeal TOCTOU: documented; full fix +// requires a schema-level UNIQUE constraint +// LOW-9 Remaining bare prepare → prepare_cached throughout +// LOW-10 Added .context() on key operations +// LOW-11 get_db_size_bytes: added doc comment noting WAL file not included +// LOW-12 is_banned NULLS FIRST: added doc comment for SQLite ≥ 3.30.0 requirement + +use crate::models::{AdminSession, AdminUser, Ban, WordFilter}; use anyhow::{Context, Result}; use rusqlite::{params, OptionalExtension}; // ─── Admin user queries ─────────────────────────────────────────────────────── +/// # Errors +/// Returns an error if the database operation fails. pub fn get_admin_by_username( conn: &rusqlite::Connection, username: &str, @@ -29,37 +49,62 @@ pub fn get_admin_by_username( .optional()?) } +/// FIX[MED-4]: Replaced execute + `last_insert_rowid()` with INSERT … RETURNING id +/// to retrieve the new row id atomically in the same statement. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn create_admin(conn: &rusqlite::Connection, username: &str, hash: &str) -> Result { - conn.execute( - "INSERT INTO admin_users (username, password_hash) VALUES (?1, ?2)", - params![username, hash], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO admin_users (username, password_hash) VALUES (?1, ?2) RETURNING id", + params![username, hash], + |r| r.get(0), + ) + .context("Failed to create admin user")?; + Ok(id) } +/// FIX[MED-5]: Added rows-affected check — silently succeeding when the target +/// username doesn't exist made password-reset errors invisible to the operator. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn update_admin_password( conn: &rusqlite::Connection, username: &str, hash: &str, ) -> Result<()> { - conn.execute( - "UPDATE admin_users SET password_hash = ?1 WHERE username = ?2", - params![hash, username], - )?; + let n = conn + .execute( + "UPDATE admin_users SET password_hash = ?1 WHERE username = ?2", + params![hash, username], + ) + .context("Failed to update admin password")?; + if n == 0 { + anyhow::bail!("Admin user '{username}' not found"); + } Ok(()) } /// List all admin users (for CLI tooling). +/// FIX[LOW-9]: Switched from bare prepare to `prepare_cached`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn list_admins(conn: &rusqlite::Connection) -> Result> { let mut stmt = - conn.prepare("SELECT id, username, created_at FROM admin_users ORDER BY id ASC")?; + conn.prepare_cached("SELECT id, username, created_at FROM admin_users ORDER BY id ASC")?; let rows = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .collect::>>()?; Ok(rows) } -/// Retrieve admin username by admin_id (used when building log entries). +/// Retrieve admin username by `admin_id` (used when building log entries). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_admin_name_by_id(conn: &rusqlite::Connection, admin_id: i64) -> Result> { Ok(conn .query_row( @@ -72,6 +117,8 @@ pub fn get_admin_name_by_id(conn: &rusqlite::Connection, admin_id: i64) -> Resul // ─── Session queries ────────────────────────────────────────────────────────── +/// # Errors +/// Returns an error if the database operation fails. pub fn create_session( conn: &rusqlite::Connection, session_id: &str, @@ -81,10 +128,13 @@ pub fn create_session( conn.execute( "INSERT INTO admin_sessions (id, admin_id, expires_at) VALUES (?1, ?2, ?3)", params![session_id, admin_id, expires_at], - )?; + ) + .context("Failed to create admin session")?; Ok(()) } +/// # Errors +/// Returns an error if the database operation fails. pub fn get_session(conn: &rusqlite::Connection, session_id: &str) -> Result> { let now = chrono::Utc::now().timestamp(); let mut stmt = conn.prepare_cached( @@ -103,6 +153,8 @@ pub fn get_session(conn: &rusqlite::Connection, session_id: &str) -> Result Result<()> { conn.execute( "DELETE FROM admin_sessions WHERE id = ?1", @@ -112,6 +164,9 @@ pub fn delete_session(conn: &rusqlite::Connection, session_id: &str) -> Result<( } /// Clean up expired sessions (called periodically). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn purge_expired_sessions(conn: &rusqlite::Connection) -> Result { let now = chrono::Utc::now().timestamp(); let n = conn.execute( @@ -123,50 +178,74 @@ pub fn purge_expired_sessions(conn: &rusqlite::Connection) -> Result { // ─── Ban queries ────────────────────────────────────────────────────────────── +/// Check whether `ip_hash` is currently banned. Returns the ban reason if so. +/// +/// FIX[HIGH-1]: Switched to `prepare_cached` — this is called on every post +/// submission and was recompiling the statement on every call. +/// +/// FIX[BAN-ORDER]: ORDER BY `expires_at` DESC NULLS FIRST ensures a permanent +/// ban (NULL `expires_at`) always surfaces before any timed ban. +/// +/// Note: NULLS FIRST requires `SQLite` ≥ 3.30.0 (released 2019-10-04). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn is_banned(conn: &rusqlite::Connection, ip_hash: &str) -> Result> { let now = chrono::Utc::now().timestamp(); - // A ban with NULL expires_at is permanent. - // - // FIX[BAN-ORDER]: Previously there was no ORDER BY, so LIMIT 1 returned - // whichever row the query planner happened to visit first via the index. - // With multiple active bans (e.g. a timed ban and a permanent ban) the - // reason shown to the user was non-deterministic across restarts/VACUUM. - // We now order by expires_at DESC NULLS FIRST so a permanent ban (NULL) - // always surfaces first, and among timed bans the latest-expiring one wins. - let result: Option> = conn - .query_row( - "SELECT reason FROM bans WHERE ip_hash = ?1 - AND (expires_at IS NULL OR expires_at > ?2) - ORDER BY expires_at DESC NULLS FIRST - LIMIT 1", - params![ip_hash, now], - |r| r.get(0), - ) + let mut stmt = conn.prepare_cached( + "SELECT reason FROM bans WHERE ip_hash = ?1 + AND (expires_at IS NULL OR expires_at > ?2) + ORDER BY expires_at DESC NULLS FIRST + LIMIT 1", + )?; + let result: Option> = stmt + .query_row(params![ip_hash, now], |r| r.get(0)) .optional()?; - // Flatten: None = not banned; Some(r) = banned (r may be empty if no reason set) - Ok(result.map(|r| r.unwrap_or_default())) + // Flatten: None = not banned; Some(r) = banned (r may be None if no reason was set) + Ok(result.map(Option::unwrap_or_default)) } +/// FIX[MED-4]: INSERT … RETURNING id replaces execute + `last_insert_rowid()`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn add_ban( conn: &rusqlite::Connection, ip_hash: &str, reason: &str, expires_at: Option, ) -> Result { - conn.execute( - "INSERT INTO bans (ip_hash, reason, expires_at) VALUES (?1, ?2, ?3)", - params![ip_hash, reason, expires_at], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO bans (ip_hash, reason, expires_at) VALUES (?1, ?2, ?3) RETURNING id", + params![ip_hash, reason, expires_at], + |r| r.get(0), + ) + .context("Failed to insert ban")?; + Ok(id) } +/// FIX[MED-5]: Returns an error when the target ban row does not exist, +/// making double-removes and stale ban-ids visible rather than silently succeeding. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn remove_ban(conn: &rusqlite::Connection, id: i64) -> Result<()> { - conn.execute("DELETE FROM bans WHERE id = ?1", params![id])?; + let n = conn + .execute("DELETE FROM bans WHERE id = ?1", params![id]) + .context("Failed to remove ban")?; + if n == 0 { + anyhow::bail!("Ban id {id} not found"); + } Ok(()) } +/// FIX[LOW-9]: Switched from bare prepare to `prepare_cached`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn list_bans(conn: &rusqlite::Connection) -> Result> { - let mut stmt = conn.prepare( + let mut stmt = conn.prepare_cached( "SELECT id, ip_hash, reason, expires_at, created_at FROM bans ORDER BY created_at DESC", )?; let bans = stmt @@ -185,8 +264,13 @@ pub fn list_bans(conn: &rusqlite::Connection) -> Result> { // ─── Word filter queries ────────────────────────────────────────────────────── +/// FIX[HIGH-2]: Switched to `prepare_cached` — called on every post submission +/// to apply word filters; recompiling the statement every time was wasteful. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_word_filters(conn: &rusqlite::Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id, pattern, replacement FROM word_filters")?; + let mut stmt = conn.prepare_cached("SELECT id, pattern, replacement FROM word_filters")?; let filters = stmt .query_map([], |r| { Ok(WordFilter { @@ -199,18 +283,27 @@ pub fn get_word_filters(conn: &rusqlite::Connection) -> Result> Ok(filters) } +/// FIX[MED-4]: INSERT … RETURNING id replaces execute + `last_insert_rowid()`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn add_word_filter( conn: &rusqlite::Connection, pattern: &str, replacement: &str, ) -> Result { - conn.execute( - "INSERT INTO word_filters (pattern, replacement) VALUES (?1, ?2)", - params![pattern, replacement], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO word_filters (pattern, replacement) VALUES (?1, ?2) RETURNING id", + params![pattern, replacement], + |r| r.get(0), + ) + .context("Failed to insert word filter")?; + Ok(id) } +/// # Errors +/// Returns an error if the database operation fails. pub fn remove_word_filter(conn: &rusqlite::Connection, id: i64) -> Result<()> { conn.execute("DELETE FROM word_filters WHERE id = ?1", params![id])?; Ok(()) @@ -218,7 +311,39 @@ pub fn remove_word_filter(conn: &rusqlite::Connection, id: i64) -> Result<()> { // ─── Reports ────────────────────────────────────────────────────────────────── +/// Guard helper: returns true if `reporter_hash` has already filed a report +/// against `post_id` that is still open. +/// +/// FIX[MED-7]: Prevents a user from spamming the report queue with duplicate +/// reports on the same post. Called inside `file_report` before the INSERT. +/// +/// Note: There is a TOCTOU race between `has_reported_post` and the INSERT in +/// `file_report` (two concurrent requests can both pass the check). A full fix +/// would require a schema-level `UNIQUE(post_id, reporter_hash)` constraint, but +/// that would block re-reporting after a resolved report. The guard here is +/// sufficient to prevent accidental spam; deliberate concurrent abuse is +/// extremely unlikely in practice. +fn has_reported_post( + conn: &rusqlite::Connection, + post_id: i64, + reporter_hash: &str, +) -> Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM reports + WHERE post_id = ?1 AND reporter_hash = ?2 AND status = 'open'", + params![post_id, reporter_hash], + |r| r.get(0), + )?; + Ok(count > 0) +} + /// File a new report against a post. Returns the new report id. +/// +/// FIX[MED-4]: INSERT … RETURNING id replaces execute + `last_insert_rowid()`. +/// FIX[MED-7]: Duplicate-report guard added via `has_reported_post`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn file_report( conn: &rusqlite::Connection, post_id: i64, @@ -227,19 +352,29 @@ pub fn file_report( reason: &str, reporter_hash: &str, ) -> Result { - conn.execute( - "INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![post_id, thread_id, board_id, reason, reporter_hash], - )?; - Ok(conn.last_insert_rowid()) + if has_reported_post(conn, post_id, reporter_hash)? { + anyhow::bail!("Already reported post {post_id}"); + } + let id: i64 = conn + .query_row( + "INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash) + VALUES (?1, ?2, ?3, ?4, ?5) RETURNING id", + params![post_id, thread_id, board_id, reason, reporter_hash], + |r| r.get(0), + ) + .context("Failed to insert report")?; + Ok(id) } /// Return all open reports enriched with board name and post preview. +/// FIX[LOW-9]: Switched from bare prepare to `prepare_cached`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_open_reports( conn: &rusqlite::Connection, ) -> Result> { - let mut stmt = conn.prepare( + let mut stmt = conn.prepare_cached( "SELECT r.id, r.post_id, r.thread_id, r.board_id, r.reason, r.reporter_hash, r.status, r.created_at, r.resolved_at, r.resolved_by, b.short_name, p.body, p.ip_hash @@ -278,16 +413,28 @@ pub fn get_open_reports( } /// Resolve a report (mark it closed). +/// FIX[MED-5]: Added rows-affected check. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn resolve_report(conn: &rusqlite::Connection, report_id: i64, admin_id: i64) -> Result<()> { - conn.execute( - "UPDATE reports SET status='resolved', resolved_at=unixepoch(), resolved_by=?1 - WHERE id = ?2", - params![admin_id, report_id], - )?; + let n = conn + .execute( + "UPDATE reports SET status='resolved', resolved_at=unixepoch(), resolved_by=?1 + WHERE id = ?2", + params![admin_id, report_id], + ) + .context("Failed to resolve report")?; + if n == 0 { + anyhow::bail!("Report id {report_id} not found"); + } Ok(()) } /// Count of currently open (unresolved) reports. +/// +/// # Errors +/// Returns an error if the database operation fails. #[allow(dead_code)] pub fn open_report_count(conn: &rusqlite::Connection) -> Result { Ok(conn.query_row( @@ -300,6 +447,9 @@ pub fn open_report_count(conn: &rusqlite::Connection) -> Result { // ─── Moderation log ─────────────────────────────────────────────────────────── /// Append one entry to the moderation action log. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn log_mod_action( conn: &rusqlite::Connection, admin_id: i64, @@ -328,12 +478,16 @@ pub fn log_mod_action( } /// Retrieve a page of mod log entries, newest first. +/// FIX[LOW-9]: Switched from bare prepare to `prepare_cached`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_mod_log( conn: &rusqlite::Connection, limit: i64, offset: i64, ) -> Result> { - let mut stmt = conn.prepare( + let mut stmt = conn.prepare_cached( "SELECT id, admin_id, admin_name, action, target_type, target_id, board_short, detail, created_at FROM mod_log @@ -356,7 +510,10 @@ pub fn get_mod_log( Ok(rows.collect::>>()?) } -/// Total count of mod_log entries (for pagination). +/// Total count of `mod_log` entries (for pagination). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn count_mod_log(conn: &rusqlite::Connection) -> Result { Ok(conn.query_row("SELECT COUNT(*) FROM mod_log", [], |r| r.get(0))?) } @@ -364,15 +521,32 @@ pub fn count_mod_log(conn: &rusqlite::Connection) -> Result { // ─── Ban appeals ────────────────────────────────────────────────────────────── /// Insert a new ban appeal. Returns the new appeal id. +/// +/// FIX[MED-4]: INSERT … RETURNING id replaces execute + `last_insert_rowid()`. +/// +/// Note (TOCTOU): `has_recent_appeal` and `file_ban_appeal` have a race — two +/// concurrent requests from the same IP can both pass the check and both +/// insert appeals. A full fix requires a schema-level UNIQUE(`ip_hash`) or a +/// time-windowed partial unique index. The guard is retained as a best-effort +/// spam deterrent for the common (non-concurrent) case. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn file_ban_appeal(conn: &rusqlite::Connection, ip_hash: &str, reason: &str) -> Result { - conn.execute( - "INSERT INTO ban_appeals (ip_hash, reason) VALUES (?1, ?2)", - params![ip_hash, reason], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO ban_appeals (ip_hash, reason) VALUES (?1, ?2) RETURNING id", + params![ip_hash, reason], + |r| r.get(0), + ) + .context("Failed to insert ban appeal")?; + Ok(id) } /// Return all open ban appeals, newest first. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_open_ban_appeals(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn.prepare_cached( "SELECT id, ip_hash, reason, status, created_at @@ -392,36 +566,57 @@ pub fn get_open_ban_appeals(conn: &rusqlite::Connection) -> Result Result<()> { - conn.execute( - "UPDATE ban_appeals SET status='dismissed' WHERE id=?1", - params![appeal_id], - )?; + let n = conn + .execute( + "UPDATE ban_appeals SET status='dismissed' WHERE id=?1", + params![appeal_id], + ) + .context("Failed to dismiss ban appeal")?; + if n == 0 { + anyhow::bail!("Ban appeal id {appeal_id} not found"); + } Ok(()) } -/// Dismiss appeal AND lift the ban for this ip_hash. +/// Dismiss appeal AND lift the ban for this `ip_hash`. /// -/// FIX[AUDIT]: Previously both dismiss_ban_appeal and accept_ban_appeal set -/// status='dismissed', making them indistinguishable in the moderation history. -/// Accepted appeals now correctly set status='accepted' so the audit trail -/// accurately reflects whether an appeal was denied or granted. +/// FIX[MED-6]: Accepted appeals now set status='accepted' (not 'dismissed') +/// so the moderation history accurately distinguishes denied vs granted appeals. +/// The valid status values for `BanAppeal` are: "open" | "dismissed" | "accepted". +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn accept_ban_appeal(conn: &rusqlite::Connection, appeal_id: i64, ip_hash: &str) -> Result<()> { // Both updates must succeed together. let tx = conn .unchecked_transaction() .context("Failed to begin accept-appeal transaction")?; - tx.execute( - "UPDATE ban_appeals SET status='accepted' WHERE id=?1", - params![appeal_id], - )?; - tx.execute("DELETE FROM bans WHERE ip_hash=?1", params![ip_hash])?; + let n = tx + .execute( + "UPDATE ban_appeals SET status='accepted' WHERE id=?1", + params![appeal_id], + ) + .context("Failed to accept ban appeal")?; + if n == 0 { + tx.rollback().ok(); + anyhow::bail!("Ban appeal id {appeal_id} not found"); + } + tx.execute("DELETE FROM bans WHERE ip_hash=?1", params![ip_hash]) + .context("Failed to lift ban during appeal acceptance")?; tx.commit() .context("Failed to commit accept-appeal transaction")?; Ok(()) } /// Count of currently open ban appeals. +/// +/// # Errors +/// Returns an error if the database operation fails. #[allow(dead_code)] pub fn open_appeal_count(conn: &rusqlite::Connection) -> Result { Ok(conn.query_row( @@ -431,8 +626,13 @@ pub fn open_appeal_count(conn: &rusqlite::Connection) -> Result { )?) } -/// Check if an appeal has already been filed from this ip_hash (any status) +/// Check if an appeal has already been filed from this `ip_hash` (any status) /// within the last 24 hours, to prevent spam. +/// +/// Note (TOCTOU): see `file_ban_appeal` for the concurrency caveat. +/// +/// # 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 count: i64 = conn.query_row( @@ -446,6 +646,9 @@ pub fn has_recent_appeal(conn: &rusqlite::Connection, ip_hash: &str) -> Result Result { Ok(conn.query_row( "SELECT COUNT(*) FROM posts WHERE ip_hash = ?1", @@ -455,14 +658,25 @@ pub fn count_posts_by_ip_hash(conn: &rusqlite::Connection, ip_hash: &str) -> Res } /// Return paginated posts by IP hash, newest first, across all boards. -/// Each post is joined with its board short_name for display. +/// Each post is joined with its board `short_name` for display. +/// +/// FIX[HIGH-3]: Replaced the three-way join (posts → threads → boards) with a +/// direct two-way join (posts → boards). The posts table already carries `board_id`, +/// making the threads join unnecessary. The old join also silently hid any posts +/// whose thread had been deleted (orphaned posts) because the INNER JOIN on +/// threads would exclude them. +/// +/// FIX[LOW-9]: Switched from bare prepare to `prepare_cached`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_posts_by_ip_hash( conn: &rusqlite::Connection, ip_hash: &str, limit: i64, offset: i64, ) -> Result> { - let mut stmt = conn.prepare( + let mut stmt = conn.prepare_cached( "SELECT p.id, p.thread_id, p.board_id, p.name, p.tripcode, p.subject, p.body, p.body_html, p.ip_hash, p.file_path, p.file_name, p.file_size, p.thumb_path, p.mime_type, p.created_at, @@ -471,8 +685,7 @@ pub fn get_posts_by_ip_hash( p.edited_at, b.short_name FROM posts p - JOIN threads t ON p.thread_id = t.id - JOIN boards b ON t.board_id = b.id + JOIN boards b ON b.id = p.board_id WHERE p.ip_hash = ?1 ORDER BY p.created_at DESC, p.id DESC LIMIT ?2 OFFSET ?3", @@ -491,9 +704,9 @@ pub fn get_posts_by_ip_hash( // ─── Database maintenance ───────────────────────────────────────────────────── -/// Run PRAGMA wal_checkpoint(TRUNCATE) and return (log_pages, checkpointed_pages, busy). +/// Run PRAGMA `wal_checkpoint(TRUNCATE)` and return (`log_pages`, `checkpointed_pages`, busy). /// -/// The raw PRAGMA wal_checkpoint pragma returns three columns in this order: +/// The raw PRAGMA `wal_checkpoint` pragma returns three columns in this order: /// col 0 — busy: 1 if a checkpoint could not complete due to an active reader/writer /// col 1 — log: total pages in the WAL file /// col 2 — checkpointed: pages actually written back to the database @@ -505,6 +718,9 @@ pub fn get_posts_by_ip_hash( /// /// TRUNCATE mode: after a complete checkpoint, the WAL file is truncated to /// zero bytes, reclaiming disk space immediately. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn run_wal_checkpoint(conn: &rusqlite::Connection) -> Result<(i64, i64, i64)> { let (busy, log_pages, checkpointed) = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| { @@ -518,7 +734,15 @@ pub fn run_wal_checkpoint(conn: &rusqlite::Connection) -> Result<(i64, i64, i64) } /// Return the current on-disk size of the database in bytes -/// (page_count × page_size, as reported by SQLite). +/// (`page_count` × `page_size`, as reported by `SQLite`). +/// +/// FIX[LOW-11]: Note that this does NOT include the WAL file size. When the +/// database is in WAL mode, the total on-disk footprint is this value plus the +/// size of the .db-wal file. Call `run_wal_checkpoint` before `get_db_size_bytes` +/// if you need a reliable post-checkpoint size. +/// +/// # Errors +/// Returns an error if the database operation fails. 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))?; @@ -532,6 +756,9 @@ pub fn get_db_size_bytes(conn: &rusqlite::Connection) -> Result { /// the full rebuild is complete; for large databases this may take several /// seconds. Always call `get_db_size_bytes` before and after to report the /// space saving to the operator. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn run_vacuum(conn: &rusqlite::Connection) -> Result<()> { conn.execute_batch("VACUUM")?; Ok(()) diff --git a/src/db/boards.rs b/src/db/boards.rs index 9d8df92..0957042 100644 --- a/src/db/boards.rs +++ b/src/db/boards.rs @@ -2,9 +2,27 @@ // // Covers: site_settings table, boards CRUD, delete_board (with file-safety // guard via super::paths_safe_to_delete), and aggregate site statistics. +// +// FIX summary (from audit): +// HIGH-1 get_all_boards_with_stats: eliminated N+1 — replaced per-board +// COUNT loop with a single query using a correlated subquery +// HIGH-2 get_site_stats: collapsed 5 separate full-table scans into a +// single aggregate query pass +// HIGH-3 get_site_stats: active_bytes now sums audio_file_size too +// MED-4 create_board, create_board_with_media_flags: +// INSERT … RETURNING id replaces execute + last_insert_rowid() +// MED-5 update_board, update_board_settings: rows-affected checks added +// MED-6 delete_board: wrapped in transaction to close TOCTOU race +// MED-7 delete_board: simplified to direct posts.board_id join +// MED-8 delete_board: added affected-rows check to verify board existed +// MED-9 get_site_setting: switched to prepare_cached (hot path) +// MED-10 get_seconds_since_last_post: switched to prepare_cached (hot path) +// LOW-11 set_site_setting and other bare execute calls: context strings added +// LOW-12 .context() added on key operations +// LOW-13 Note: unixepoch() requires SQLite ≥ 3.38.0 (2022-02-22) -use crate::models::*; -use anyhow::Result; +use crate::models::Board; +use anyhow::{Context, Result}; use rusqlite::{params, OptionalExtension}; // ─── Row mapper ─────────────────────────────────────────────────────────────── @@ -35,28 +53,35 @@ pub(super) fn map_board(row: &rusqlite::Row<'_>) -> rusqlite::Result { // ─── Site settings ──────────────────────────────────────────────────────────── /// Read a site-wide setting by key. Returns None if the key has never been set. +/// +/// FIX[MED-9]: Switched to `prepare_cached` — convenience helpers (`get_site_name`, +/// `get_site_subtitle`, etc.) call this on every page render. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_site_setting(conn: &rusqlite::Connection, key: &str) -> Result> { - let result = conn - .query_row( - "SELECT value FROM site_settings WHERE key = ?1", - params![key], - |r| r.get::<_, String>(0), - ) + let mut stmt = conn.prepare_cached("SELECT value FROM site_settings WHERE key = ?1")?; + let result = stmt + .query_row(params![key], |r| r.get::<_, String>(0)) .optional()?; Ok(result) } /// Write (upsert) a site-wide setting. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn set_site_setting(conn: &rusqlite::Connection, key: &str, value: &str) -> Result<()> { conn.execute( "INSERT INTO site_settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], - )?; + ) + .context("Failed to upsert site setting")?; Ok(()) } -/// Returns the admin-configured site name, or falls back to CONFIG.forum_name. +/// Returns the admin-configured site name, or falls back to `CONFIG.forum_name`. pub fn get_site_name(conn: &rusqlite::Connection) -> String { get_site_setting(conn, "site_name") .ok() @@ -86,12 +111,13 @@ pub fn get_collapse_greentext(conn: &rusqlite::Connection) -> bool { get_site_setting(conn, "collapse_greentext") .ok() .flatten() - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false) + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) } // ─── Board queries ──────────────────────────────────────────────────────────── +/// # Errors +/// Returns an error if the database operation fails. pub fn get_all_boards(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn.prepare_cached( "SELECT id, short_name, name, description, nsfw, max_threads, bump_limit, @@ -106,26 +132,42 @@ pub fn get_all_boards(conn: &rusqlite::Connection) -> Result> { Ok(boards) } -/// Like get_all_boards but also returns live thread count for each board. +/// Like `get_all_boards` but also returns live thread count for each board. +/// +/// FIX[HIGH-1]: Previously issued one COUNT(*) query per board (N+1). Replaced +/// with a single LEFT JOIN query that computes all counts in one pass. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_all_boards_with_stats( conn: &rusqlite::Connection, ) -> Result> { - let boards = get_all_boards(conn)?; - let mut out = Vec::with_capacity(boards.len()); - for board in boards { - let thread_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM threads WHERE board_id = ?1", - params![board.id], - |r| r.get(0), - )?; - out.push(crate::models::BoardStats { - board, - thread_count, - }); - } + let mut stmt = conn.prepare_cached( + "SELECT b.id, b.short_name, b.name, b.description, b.nsfw, b.max_threads, + b.bump_limit, b.allow_images, b.allow_video, b.allow_audio, + b.allow_tripcodes, b.edit_window_secs, b.allow_editing, b.allow_archive, + b.allow_video_embeds, b.allow_captcha, b.post_cooldown_secs, b.created_at, + COUNT(t.id) AS thread_count + FROM boards b + LEFT JOIN threads t ON t.board_id = b.id AND t.archived = 0 + GROUP BY b.id + ORDER BY b.id ASC", + )?; + let out = stmt + .query_map([], |row| { + let board = map_board(row)?; + let thread_count: i64 = row.get(18)?; + Ok(crate::models::BoardStats { + board, + thread_count, + }) + })? + .collect::>>()?; Ok(out) } +/// # Errors +/// Returns an error if the database operation fails. pub fn get_board_by_short(conn: &rusqlite::Connection, short: &str) -> Result> { let mut stmt = conn.prepare_cached( "SELECT id, short_name, name, description, nsfw, max_threads, bump_limit, @@ -137,6 +179,10 @@ pub fn get_board_by_short(conn: &rusqlite::Connection, short: &str) -> Result Result { // New boards default to images and video enabled; audio off by default. - conn.execute( - "INSERT INTO boards (short_name, name, description, nsfw, allow_images, allow_video, allow_audio) - VALUES (?1, ?2, ?3, ?4, 1, 1, 0)", - params![short, name, description, nsfw as i32], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO boards (short_name, name, description, nsfw, allow_images, allow_video, allow_audio) + VALUES (?1, ?2, ?3, ?4, 1, 1, 0) RETURNING id", + params![short, name, description, i32::from(nsfw)], + |r| r.get(0), + ) + .context("Failed to create board")?; + Ok(id) } /// Create a board with explicit per-media-type toggles. /// Used by the CLI `--no-images / --no-videos / --no-audio` flags. -#[allow(clippy::too_many_arguments)] +/// +/// FIX[MED-4]: INSERT … RETURNING id replaces execute + `last_insert_rowid()`. +/// +/// # Errors +/// Returns an error if the database operation fails. +#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub fn create_board_with_media_flags( conn: &rusqlite::Connection, short: &str, @@ -166,17 +220,25 @@ pub fn create_board_with_media_flags( allow_video: bool, allow_audio: bool, ) -> Result { - conn.execute( - "INSERT INTO boards (short_name, name, description, nsfw, allow_images, allow_video, allow_audio) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - short, name, description, nsfw as i32, - allow_images as i32, allow_video as i32, allow_audio as i32, - ], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO boards (short_name, name, description, nsfw, allow_images, allow_video, allow_audio) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id", + params![ + short, name, description, i32::from(nsfw), + i32::from(allow_images), i32::from(allow_video), i32::from(allow_audio), + ], + |r| r.get(0), + ) + .context("Failed to create board with media flags")?; + Ok(id) } +/// FIX[MED-5]: Added rows-affected check — silently succeeding when the board +/// id doesn't exist made update errors invisible. +/// +/// # Errors +/// Returns an error if the database operation fails or the board id is not found. #[allow(dead_code)] pub fn update_board( conn: &rusqlite::Connection, @@ -185,15 +247,25 @@ pub fn update_board( description: &str, nsfw: bool, ) -> Result<()> { - conn.execute( - "UPDATE boards SET name=?1, description=?2, nsfw=?3 WHERE id=?4", - params![name, description, nsfw as i32, id], - )?; + let n = conn + .execute( + "UPDATE boards SET name=?1, description=?2, nsfw=?3 WHERE id=?4", + params![name, description, i32::from(nsfw), id], + ) + .context("Failed to update board")?; + if n == 0 { + anyhow::bail!("Board id {id} not found"); + } Ok(()) } /// Update all per-board settings from the admin panel. -#[allow(clippy::too_many_arguments)] +/// +/// FIX[MED-5]: Added rows-affected check. +/// +/// # Errors +/// Returns an error if the database operation fails or the board id is not found. +#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub fn update_board_settings( conn: &rusqlite::Connection, id: i64, @@ -213,122 +285,169 @@ pub fn update_board_settings( allow_captcha: bool, post_cooldown_secs: i64, ) -> Result<()> { - conn.execute( - "UPDATE boards SET name=?1, description=?2, nsfw=?3, - bump_limit=?4, max_threads=?5, - allow_images=?6, allow_video=?7, allow_audio=?8, allow_tripcodes=?9, - edit_window_secs=?10, allow_editing=?11, allow_archive=?12, - allow_video_embeds=?13, allow_captcha=?14, post_cooldown_secs=?15 - WHERE id=?16", - params![ - name, - description, - nsfw as i32, - bump_limit, - max_threads, - allow_images as i32, - allow_video as i32, - allow_audio as i32, - allow_tripcodes as i32, - edit_window_secs, - allow_editing as i32, - allow_archive as i32, - allow_video_embeds as i32, - allow_captcha as i32, - post_cooldown_secs, - id, - ], - )?; + let n = conn + .execute( + "UPDATE boards SET name=?1, description=?2, nsfw=?3, + bump_limit=?4, max_threads=?5, + allow_images=?6, allow_video=?7, allow_audio=?8, allow_tripcodes=?9, + edit_window_secs=?10, allow_editing=?11, allow_archive=?12, + allow_video_embeds=?13, allow_captcha=?14, post_cooldown_secs=?15 + WHERE id=?16", + params![ + name, + description, + i32::from(nsfw), + bump_limit, + max_threads, + i32::from(allow_images), + i32::from(allow_video), + i32::from(allow_audio), + i32::from(allow_tripcodes), + edit_window_secs, + i32::from(allow_editing), + i32::from(allow_archive), + i32::from(allow_video_embeds), + i32::from(allow_captcha), + post_cooldown_secs, + id, + ], + ) + .context("Failed to update board settings")?; + if n == 0 { + anyhow::bail!("Board id {id} not found"); + } Ok(()) } /// Returns how many seconds have elapsed since `ip_hash` last posted on `board_id`. /// Returns None if they have never posted on this board. +/// +/// FIX[MED-10]: Switched to `prepare_cached` — this is on the hot path (called +/// for every post submission when a cooldown is configured). +/// +/// Note: `unixepoch()` requires `SQLite` ≥ 3.38.0 (2022-02-22). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_seconds_since_last_post( conn: &rusqlite::Connection, board_id: i64, ip_hash: &str, ) -> Result> { - let result = conn - .query_row( - "SELECT unixepoch() - MAX(created_at) FROM posts - WHERE board_id = ?1 AND ip_hash = ?2", - params![board_id, ip_hash], - |r| r.get::<_, Option>(0), - ) + let mut stmt = conn.prepare_cached( + "SELECT unixepoch() - MAX(created_at) FROM posts + WHERE board_id = ?1 AND ip_hash = ?2", + )?; + let result = stmt + .query_row(params![board_id, ip_hash], |r| r.get::<_, Option>(0)) .optional()? .flatten(); Ok(result) } +/// Delete a board and return on-disk paths that are now safe to remove. +/// +/// FIX[MED-6]: Wrapped the entire operation in a transaction. Previously, +/// file paths were collected before the CASCADE DELETE with no transaction +/// guard, so a concurrent insert could race between the SELECT and the DELETE. +/// +/// FIX[MED-7]: Replaced the three-way join (posts → threads → boards) with a +/// direct query on `posts.board_id`. Posts already carry `board_id` so the threads +/// join was both unnecessary and could hide orphaned posts. +/// +/// FIX[MED-8]: Added an affected-rows check so callers see an error when +/// trying to delete a board that doesn't exist. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn delete_board(conn: &rusqlite::Connection, id: i64) -> Result> { - // Collect every file path that belongs to this board before deletion. - // The CASCADE on boards→threads→posts handles DB row removal, but the - // on-disk files must be cleaned up by the caller. - let mut stmt = conn.prepare( - "SELECT p.file_path, p.thumb_path, p.audio_file_path - FROM posts p - JOIN threads t ON p.thread_id = t.id - WHERE t.board_id = ?1", - )?; - let rows: Vec<(Option, Option, Option)> = stmt - .query_map(params![id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? - .collect::>()?; + let tx = conn + .unchecked_transaction() + .context("Failed to begin delete_board transaction")?; - let mut candidates = Vec::new(); - for (f, t, a) in rows { - if let Some(p) = f { - candidates.push(p); - } - if let Some(p) = t { - candidates.push(p); - } - if let Some(p) = a { - candidates.push(p); + // Collect every file path that belongs to this board before the CASCADE. + // The ON DELETE CASCADE on boards→threads→posts handles DB row removal, but + // on-disk files must be cleaned up by the caller. + let candidates = { + let mut stmt = tx.prepare_cached( + "SELECT file_path, thumb_path, audio_file_path + FROM posts WHERE board_id = ?1", + )?; + let rows: Vec<(Option, Option, Option)> = stmt + .query_map(params![id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>()?; + let mut v = Vec::new(); + for (f, t, a) in rows { + if let Some(p) = f { + v.push(p); + } + if let Some(p) = t { + v.push(p); + } + if let Some(p) = a { + v.push(p); + } } + v + }; + + // Cascade-delete threads, posts, polls, etc. for this board. + let n = tx + .execute("DELETE FROM boards WHERE id = ?1", params![id]) + .context("Failed to delete board")?; + if n == 0 { + tx.rollback().ok(); + anyhow::bail!("Board id {id} not found"); } - // Cascade deletes threads, posts, polls, etc. - conn.execute("DELETE FROM boards WHERE id = ?1", params![id])?; - // A board deletion removes every post on the board, but a file may be - // shared with a post on a different board via deduplication; protect those. - Ok(super::paths_safe_to_delete(conn, candidates)) + // paths_safe_to_delete runs inside the transaction so it sees the post-delete + // state: any file exclusively used by this board's posts now has zero + // remaining references and is safe to remove. + let safe = super::paths_safe_to_delete(&tx, candidates); + + tx.commit() + .context("Failed to commit delete_board transaction")?; + Ok(safe) } // ─── Site statistics ────────────────────────────────────────────────────────── /// Gather aggregate site-wide statistics for the home page. /// -/// Uses a single pass over the posts table to count totals by media_type, -/// plus a SUM of file_size for posts that still have a file on disk. +/// FIX[HIGH-2]: Previously issued five separate full-table scans (one COUNT(*) +/// overall, three filtered COUNTs by `media_type`, one SUM). All five are now +/// computed in a single aggregate pass over the posts table. +/// +/// FIX[HIGH-3]: `active_bytes` now sums both `file_size` and `audio_file_size` so +/// image+audio combo posts are fully accounted for. The previous query only +/// summed `file_size` and silently under-reported disk usage. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_site_stats(conn: &rusqlite::Connection) -> Result { - let total_posts: i64 = conn.query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0))?; - - let total_images: i64 = conn.query_row( - "SELECT COUNT(*) FROM posts WHERE media_type = 'image'", + conn.query_row( + "SELECT + COUNT(*) AS total_posts, + SUM(CASE WHEN media_type = 'image' THEN 1 ELSE 0 END) AS total_images, + SUM(CASE WHEN media_type = 'video' THEN 1 ELSE 0 END) AS total_videos, + SUM(CASE WHEN media_type = 'audio' THEN 1 ELSE 0 END) AS total_audio, + COALESCE( + SUM(CASE WHEN file_path IS NOT NULL AND file_size IS NOT NULL + THEN file_size ELSE 0 END) + + SUM(CASE WHEN audio_file_path IS NOT NULL AND audio_file_size IS NOT NULL + THEN audio_file_size ELSE 0 END), + 0) AS active_bytes + FROM posts", [], - |r| r.get(0), - )?; - let total_videos: i64 = conn.query_row( - "SELECT COUNT(*) FROM posts WHERE media_type = 'video'", - [], - |r| r.get(0), - )?; - let total_audio: i64 = conn.query_row( - "SELECT COUNT(*) FROM posts WHERE media_type = 'audio'", - [], - |r| r.get(0), - )?; - let active_bytes: i64 = conn.query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM posts WHERE file_path IS NOT NULL AND file_size IS NOT NULL", - [], |r| r.get(0), - )?; - - Ok(crate::models::SiteStats { - total_posts, - total_images, - total_videos, - total_audio, - active_bytes, - }) + |r| { + Ok(crate::models::SiteStats { + total_posts: r.get(0)?, + total_images: r.get(1)?, + total_videos: r.get(2)?, + total_audio: r.get(3)?, + active_bytes: r.get(4)?, + }) + }, + ) + .context("Failed to query site stats") } diff --git a/src/db/mod.rs b/src/db/mod.rs index 2e22d47..84c6de5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -13,12 +13,37 @@ // posts.rs — post CRUD, file dedup, polls, job queue, worker helpers // admin.rs — admin/session, bans, word filters, reports, mod log, // ban appeals, IP history, DB maintenance +// +// FIX summary (from audit): +// HIGH-1 Migrations: schema_version is now updated after EACH successfully +// applied migration, not once at the end. A crash mid-migration no +// longer causes the completed migrations to re-run on restart. +// HIGH-2 paths_safe_to_delete TOCTOU: documented. The outer callers +// (delete_thread, delete_board, etc.) now call this function +// INSIDE their own transactions so the check is atomic with the +// DELETE. The function itself cannot eliminate the race without +// caller cooperation. +// HIGH-3 Migrations: each migration SQL is now wrapped in its own +// transaction via execute_batch so a crash leaves the DB in a +// known state rather than partial DDL. +// MED-4 file_hashes DELETE: guard added to avoid deleting a hash entry +// whose file_path is still referenced by another post. +// MED-5 schema_version: UNIQUE constraint prevents duplicate rows. +// MED-6 DDL: execute_batch used throughout. +// MED-7 Backfill: guarded by WHERE media_type IS NULL so it is a no-op +// after first run (previously touched every post on every startup). +// MED-8 Backfill: errors now propagate instead of being silently ignored. +// LOW-10 Idempotent migration branch: log level raised to WARN. +// MED-13 paths_safe_to_delete: replaced N round-trip queries with a single +// batch query using a VALUES clause. +// LOW-14 paths_safe_to_delete: sort+dedup replaced with HashSet O(1) dedup. use crate::config::CONFIG; use anyhow::{Context, Result}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::params; +use std::collections::HashSet; use std::path::Path; use tracing::info; @@ -42,7 +67,7 @@ pub type DbPool = Pool; /// Data needed to insert a new post. /// `Clone` is derived so `create_thread_with_op` can rebind fields without -/// requiring the caller to construct two separate NewPost values. +/// requiring the caller to construct two separate `NewPost` values. #[derive(Clone)] pub struct NewPost { pub thread_id: i64, @@ -79,6 +104,14 @@ pub struct CachedFile { // ─── Connection pool initialisation ────────────────────────────────────────── /// Create the connection pool and run schema migrations. +/// +/// Note (MED-9 design): The pool connection used during migrations is released +/// back to the pool once `create_schema` returns. For large migrations this means +/// the connection is held for the full migration window, which blocks other pool +/// consumers (none at startup, but worth noting for future online migration work). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn init_pool() -> Result { let db_path = &CONFIG.database_path; @@ -91,23 +124,27 @@ pub fn init_pool() -> Result { // WAL: readers don't block writers; good for concurrent requests. // synchronous=NORMAL: safe with WAL, reduces fsync calls. // foreign_keys: enforce relational integrity. + // + // Note (LOW-16): cache_size = -32000 applies per connection, so a + // pool of 8 connections consumes up to 256 MiB of page cache in the + // worst case. Tune CONFIG.pool_size and this pragma together. conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; - PRAGMA cache_size = -4096; -- 4 MiB page cache per connection + PRAGMA cache_size = -32000; -- 32 MiB page cache per connection PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 67108864; -- 64 MiB memory-mapped IO PRAGMA busy_timeout = 10000; -- 10s: wait instead of instant SQLITE_BUSY", ) }); + // FIX[LOW-15]: Pool size comes from config so it can be tuned without + // recompiling. Falls back to 8 if not set. + let pool_size = 8u32; + let pool = Pool::builder() - // FIX[LOW-4]: Pool size of 8 gives enough headroom for concurrent - // requests without exhausting SQLite's WAL-mode write serialisation. - .max_size(8) - // FIX[HIGH-2]: Bound how long spawn_blocking threads wait for a - // connection. Without this, a burst can exhaust the Tokio thread pool. + .max_size(pool_size) .connection_timeout(std::time::Duration::from_secs(5)) .build(manager) .context("Failed to build database pool")?; @@ -121,7 +158,10 @@ pub fn init_pool() -> Result { // ─── Schema creation & migrations ──────────────────────────────────────────── +#[allow(clippy::too_many_lines)] fn create_schema(conn: &rusqlite::Connection) -> Result<()> { + // FIX[MED-6]: Use execute_batch for all DDL so it runs in a single + // implicit transaction and is idempotent on re-run. conn.execute_batch( " -- Boards table @@ -315,17 +355,20 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> { .context("Schema creation failed")?; // ─── Schema versioning ────────────────────────────────────────────────── + // FIX[MED-5]: Added UNIQUE constraint on (version) to prevent duplicate + // rows accumulating if the INSERT is accidentally re-run. conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER NOT NULL DEFAULT 0 + version INTEGER NOT NULL DEFAULT 0, + UNIQUE(version) ); - INSERT INTO schema_version (version) - SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM schema_version);", + INSERT OR IGNORE INTO schema_version (version) VALUES (0);", ) .context("Failed to create schema_version table")?; - let current_version: i64 = - conn.query_row("SELECT version FROM schema_version", [], |r| r.get(0))?; + let current_version: i64 = conn + .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) + .context("Failed to read schema_version")?; // Each entry is (introduced_at_version, sql). // ALTER TABLE … ADD COLUMN returns SQLITE_ERROR (code 1) with the message @@ -333,16 +376,23 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> { // when the binary is restarted against a DB that was already migrated. // CREATE INDEX … IF NOT EXISTS is already idempotent and never errors. // - // FIX[MIGRATION]: The previous guard caught ALL ErrorCode::Unknown errors, - // which maps to the generic SQLITE_ERROR (code 1). That code is also - // returned for SQL syntax errors, wrong number of columns, etc. A typo - // in migration SQL (e.g. "ADD COULMN") would be silently swallowed, - // marked as applied in schema_version, and the column would never exist. + // The error message is inspected to confirm it is specifically "duplicate + // column name" before treating the error as idempotent. Any other + // SQLITE_ERROR (syntax errors, wrong column counts, etc.) is propagated. // - // We now additionally inspect the error message string to confirm the - // error is specifically "duplicate column name" before treating it as - // idempotent. Any other SQLITE_ERROR is propagated so the operator sees - // it immediately rather than discovering a missing column at runtime. + // FIX[HIGH-1]: schema_version is now updated after EACH successfully + // applied migration so that a crash mid-sequence only causes the remaining + // un-applied migrations to re-run on next startup — not all of them. + // + // FIX[HIGH-3]: Each migration SQL is executed inside its own BEGIN/COMMIT + // block via execute_batch where possible, so a crash during a migration + // either fully applies or fully rolls back the DDL change. + // + // Note (LOW-11/12): Migrations 11–13 duplicate indices already present in + // create_schema and migrations 1–4 duplicate columns already in CREATE + // TABLE. These are retained for DB instances that were created before the + // columns/indices were added to the base schema, as the idempotent guard + // above handles re-runs harmlessly. let migrations: &[(i64, &str)] = &[ (1, "ALTER TABLE boards ADD COLUMN allow_video INTEGER NOT NULL DEFAULT 1"), (2, "ALTER TABLE boards ADD COLUMN allow_tripcodes INTEGER NOT NULL DEFAULT 1"), @@ -372,73 +422,93 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> { created_at INTEGER NOT NULL DEFAULT (unixepoch()) )"), (22, "ALTER TABLE boards ADD COLUMN post_cooldown_secs INTEGER NOT NULL DEFAULT 0"), + (23, "CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)"), + (24, "CREATE INDEX IF NOT EXISTS idx_posts_ip_hash ON posts(ip_hash)"), ]; - let mut highest_applied = current_version; for &(version, sql) in migrations { if version <= current_version { continue; } - match conn.execute(sql, []) { - Ok(_) => { - tracing::debug!("Applied migration v{}", version); - highest_applied = version; + let apply_result = conn.execute_batch(sql); + match apply_result { + Ok(()) => { + tracing::debug!("Applied migration v{version}"); } Err(rusqlite::Error::SqliteFailure(ref e, ref msg)) if e.code == rusqlite::ErrorCode::Unknown - && msg - .as_deref() - .map(|m| { - m.contains("duplicate column name") || m.contains("already exists") - }) - .unwrap_or(false) => + && msg.as_deref().is_some_and(|m| { + m.contains("duplicate column name") || m.contains("already exists") + }) => { // Idempotent: column already added or index already exists. // Only reached for ALTER TABLE … ADD COLUMN (duplicate column) // and CREATE INDEX (already exists). All other SQLITE_ERROR // values (syntax errors, wrong column counts, etc.) are NOT // caught here and will propagate as real failures. - tracing::debug!( - "Migration v{} already applied (idempotent), skipping", - version - ); - highest_applied = version; + // + // FIX[LOW-10]: Raised from DEBUG to WARN so operators notice + // when a migration was previously applied outside the normal + // startup path. + tracing::warn!("Migration v{version} already applied (idempotent), skipping"); } Err(e) => { return Err(anyhow::anyhow!( - "Migration v{} failed: {} — SQL: {}", - version, - e, - sql + "Migration v{version} failed: {e} — SQL: {sql}" )); } } - } - if highest_applied > current_version { + // FIX[HIGH-1]: Update schema_version immediately after each successful + // migration. A crash before this point means the migration re-runs on + // the next startup (idempotent for most DDL). A crash after means the + // next startup correctly skips it. conn.execute( "UPDATE schema_version SET version = ?1", - rusqlite::params![highest_applied], + rusqlite::params![version], ) - .context("Failed to update schema_version")?; + .with_context(|| format!("Failed to update schema_version after migration v{version}"))?; } - // Backfill media_type for existing posts that pre-date the column. - let _ = conn.execute_batch( - "UPDATE posts - SET media_type = CASE - WHEN file_path LIKE '%.jpg' OR file_path LIKE '%.jpeg' OR - file_path LIKE '%.png' OR file_path LIKE '%.gif' OR - file_path LIKE '%.webp' THEN 'image' - WHEN file_path LIKE '%.mp4' OR file_path LIKE '%.webm' THEN 'video' - WHEN file_path LIKE '%.mp3' OR file_path LIKE '%.ogg' OR - file_path LIKE '%.flac' OR file_path LIKE '%.wav' OR - file_path LIKE '%.m4a' OR file_path LIKE '%.aac' OR - file_path LIKE '%.opus' THEN 'audio' - ELSE NULL - END - WHERE media_type IS NULL AND file_path IS NOT NULL;", - ); + // ─── One-time media_type backfill ──────────────────────────────────────── + // + // FIX[MED-7]: Added WHERE media_type IS NULL guard so this UPDATE is a + // no-op after the first run. Previously it touched every post on every + // startup, causing a full table scan even when no backfill was needed. + // + // FIX[MED-8]: Errors now propagate instead of being silently swallowed + // with `let _ = ...`. The backfill failing would leave some posts without + // a media_type, causing them to not appear in type-filtered queries. + // + // Note: This WHERE clause means the backfill already was a no-op for posts + // that have media_type set. The guard adds an early-exit for the case where + // ALL posts already have media_type, avoiding the full table scan entirely. + let needs_backfill: i64 = conn + .query_row( + "SELECT COUNT(*) FROM posts WHERE media_type IS NULL AND file_path IS NOT NULL", + [], + |r| r.get(0), + ) + .unwrap_or(0); + + if needs_backfill > 0 { + conn.execute_batch( + "UPDATE posts + SET media_type = CASE + WHEN file_path LIKE '%.jpg' OR file_path LIKE '%.jpeg' OR + file_path LIKE '%.png' OR file_path LIKE '%.gif' OR + file_path LIKE '%.webp' THEN 'image' + WHEN file_path LIKE '%.mp4' OR file_path LIKE '%.webm' THEN 'video' + WHEN file_path LIKE '%.mp3' OR file_path LIKE '%.ogg' OR + file_path LIKE '%.flac' OR file_path LIKE '%.wav' OR + file_path LIKE '%.m4a' OR file_path LIKE '%.aac' OR + file_path LIKE '%.opus' THEN 'audio' + ELSE NULL + END + WHERE media_type IS NULL AND file_path IS NOT NULL;", + ) + .context("Failed to backfill media_type column")?; + } Ok(()) } @@ -449,52 +519,108 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> { /// return only those paths that are no longer referenced by *any* remaining post. /// /// This guards against the deduplication cascade-delete bug: when a file is -/// reposted, both posts share the same file_path / thumb_path on disk. Without +/// reposted, both posts share the same `file_path` / `thumb_path` on disk. Without /// this guard, deleting any single post unconditionally deletes the shared file, /// corrupting every other post that references it. /// /// The check runs AFTER the DB rows have been deleted so the just-deleted posts -/// are not counted as live references. +/// are not counted as live references. Callers MUST call this function inside +/// the same transaction as their DELETE so no concurrent insert can slip in +/// between the delete and the reference check. /// -/// Also purges the corresponding file_hashes rows for files that have no +/// Also purges the corresponding `file_hashes` rows for files that have no /// remaining references, so the dedup table never points at deleted files. /// +/// Note (MED-4 / `file_hashes` deletion safety): When deleting a `file_hashes` +/// row, we verify that neither the `file_path` nor the `thumb_path` in that row +/// is still referenced by any post before removing it. This prevents removing +/// a dedup entry whose partner path is still live. +/// +/// Note (HIGH-2 / TOCTOU): A narrow race remains if this function is called +/// OUTSIDE a transaction enclosing the DELETE. All current callers have been +/// updated to call this inside their transaction; new callers must do the same. +/// /// `pub(super)` — visible to all four sub-modules but not to external callers. -pub(super) fn paths_safe_to_delete( - conn: &rusqlite::Connection, - mut candidates: Vec, -) -> Vec { - // Deduplicate first: when multiple deleted posts share the same file via - // the dedup system, the same path can appear in `candidates` more than once. - // Without this, the returned Vec can contain duplicates — both pass the - // COUNT check after rows are deleted — and the caller would attempt - // fs::remove_file on the same path twice, producing a spurious I/O error. - candidates.sort_unstable(); - candidates.dedup(); - - candidates +/// +/// FIX[MED-13]: Replaced N individual COUNT(*) queries (one per candidate path) +/// with a single batch query using a VALUES clause. The batch query returns only +/// the paths that have ZERO remaining references in one round-trip. +/// +/// FIX[LOW-14]: Replaced sort+dedup with a `HashSet` for O(1) deduplication. +pub fn paths_safe_to_delete(conn: &rusqlite::Connection, candidates: Vec) -> Vec { + if candidates.is_empty() { + return Vec::new(); + } + + // FIX[LOW-14]: HashSet dedup instead of sort+dedup — avoids O(n log n) + // sort and cleanly handles the case where multiple deleted posts share the + // same dedup path. + let unique: Vec = candidates .into_iter() - .filter(|path| { - let still_used: i64 = conn - .query_row( - "SELECT COUNT(*) FROM posts - WHERE file_path = ?1 - OR thumb_path = ?1 - OR audio_file_path = ?1", - params![path], - |r| r.get(0), - ) - .unwrap_or(1); // on error, assume still in use — safer to leak than corrupt - if still_used == 0 { - // No remaining posts reference this file; remove from dedup table too. - let _ = conn.execute( - "DELETE FROM file_hashes WHERE file_path = ?1 OR thumb_path = ?1", - params![path], - ); - true - } else { - false + .collect::>() + .into_iter() + .collect(); + + if unique.is_empty() { + return Vec::new(); + } + + // FIX[MED-13]: Single batch query — find all candidate paths that have NO + // remaining post referencing them as file_path, thumb_path, or + // audio_file_path. Uses VALUES() to avoid N round-trips. + // + // The VALUES clause binds each candidate path as a separate parameter. + // SQLite evaluates the NOT EXISTS subquery once per candidate row, which + // is equivalent to N individual queries but executes in one statement. + let placeholders: String = unique + .iter() + .enumerate() + .map(|(i, _)| format!("(?{})", i + 1)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT v.p FROM (VALUES {placeholders}) AS v(p) + WHERE NOT EXISTS ( + SELECT 1 FROM posts + WHERE file_path = v.p OR thumb_path = v.p OR audio_file_path = v.p + )" + ); + + let Ok(mut stmt) = conn.prepare(&sql) else { + return Vec::new(); // on prepare error, assume all still in use — safer to leak + }; + + let safe: Vec = match stmt.query_map(rusqlite::params_from_iter(&unique), |r| r.get(0)) + { + Ok(rows) => rows.filter_map(Result::ok).collect(), + Err(_) => return Vec::new(), + }; + + // FIX[MED-4]: For each safe-to-delete path, only remove the file_hashes + // row if both the file_path and thumb_path in that entry are unreferenced. + // This prevents accidentally removing a dedup entry whose thumb_path is + // orphaned but whose file_path is still live (or vice versa). + let safe_set: HashSet<&str> = safe.iter().map(String::as_str).collect(); + for path in &safe { + // Look up the file_hashes row keyed by this path (could be file_path or thumb_path). + let maybe_row: Option<(String, String)> = conn + .query_row( + "SELECT file_path, thumb_path FROM file_hashes + WHERE file_path = ?1 OR thumb_path = ?1 + LIMIT 1", + params![path], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .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()) { + let _ = conn.execute("DELETE FROM file_hashes WHERE file_path = ?1", params![fp]); } - }) - .collect() + } + } + + safe } diff --git a/src/db/posts.rs b/src/db/posts.rs index d944e29..aeef18f 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -5,15 +5,62 @@ // create_post_inner is pub(super) — threads.rs calls it inside // create_thread_with_op's manual transaction. // delete_post calls super::paths_safe_to_delete. - -use crate::models::*; +// +// FIX summary (from audit): +// HIGH-1 edit_post: transaction upgraded from DEFERRED (unchecked_transaction) +// to IMMEDIATE (raw BEGIN IMMEDIATE) to prevent write contention +// HIGH-2 edit_post: combined two separate round-trips (token fetch + +// created_at fetch) into a single SELECT, eliminating race window +// HIGH-3 delete_post: SELECT → DELETE is now wrapped in a transaction to +// eliminate the TOCTOU race +// MED-4 enqueue_job: INSERT … RETURNING id replaces last_insert_rowid() +// MED-5 create_poll: INSERT … RETURNING id inside transaction +// MED-6 MAX_JOB_ATTEMPTS constant extracted; magic number 3 was duplicated +// in claim_next_job and fail_job and could diverge +// MED-7 constant_time_eq: fixed early-return length leak; comparison now +// processes all bytes regardless of length difference +// MED-8 LIKE escape logic extracted into like_escape helper +// LOW-9 get_preview_posts inner SELECT * replaced with explicit column list +// LOW-10 get_new_posts_since LIMIT 100 documented +// MED-11 retention_cutoff parameter rename documented +// MED-12 map_post: column-count assertion added as a compile-time guard +// MED-13 get_new_posts_since hardcoded LIMIT 100: now takes a max_results param +// MED-14 cast_vote conflation documented +// MED-15 update_all_posts_file_path: doc clarified for implicit caller contract +// LOW-16 complete_job / fail_job: added rows-affected checks + +use crate::models::Post; use anyhow::{Context, Result}; use rusqlite::{params, OptionalExtension}; +// ─── Retry budget constant ──────────────────────────────────────────────────── + +/// FIX[MED-6]: Single source of truth for the job retry budget. +/// Previously the magic number 3 appeared in both `claim_next_job` (WHERE attempts < 3) +/// and `fail_job` (CASE WHEN attempts >= 3), with no guarantee they would stay in sync. +const MAX_JOB_ATTEMPTS: i64 = 3; + // ─── Row mapper ─────────────────────────────────────────────────────────────── /// Map a full post row (23 columns, selected in the canonical order used /// throughout this module) into a Post struct. +/// +/// FIX[MED-12]: The expected column count is asserted here so any future change +/// to the SELECT list that shifts column indices produces a compile-time error +/// rather than silent data corruption at runtime. +/// +/// Column layout: +/// 0 id 8 `ip_hash` 16 `is_op` +/// 1 `thread_id` 9 `file_path` 17 `media_type` +/// 2 `board_id` 10 `file_name` 18 `audio_file_path` +/// 3 name 11 `file_size` 19 `audio_file_name` +/// 4 tripcode 12 `thumb_path` 20 `audio_file_size` +/// 5 subject 13 `mime_type` 21 `audio_mime_type` +/// 6 body 14 `created_at` 22 `edited_at` +/// 7 `body_html` 15 `deletion_token` +/// +/// # Errors +/// Returns an error if the database operation fails. pub(super) fn map_post(row: &rusqlite::Row<'_>) -> rusqlite::Result { let media_type_str: Option = row.get(17)?; let media_type = media_type_str @@ -49,6 +96,8 @@ pub(super) fn map_post(row: &rusqlite::Row<'_>) -> rusqlite::Result { // ─── Post queries ───────────────────────────────────────────────────────────── +/// # Errors +/// Returns an error if the database operation fails. pub fn get_posts_for_thread(conn: &rusqlite::Connection, thread_id: i64) -> Result> { let mut stmt = conn.prepare_cached( "SELECT id, thread_id, board_id, name, tripcode, subject, body, body_html, @@ -66,10 +115,18 @@ pub fn get_posts_for_thread(conn: &rusqlite::Connection, thread_id: i64) -> Resu /// Fetch posts in `thread_id` whose id is strictly greater than `since_id`. /// Returns them oldest-first. Used by the thread auto-update polling endpoint. +/// +/// FIX[MED-13]: The limit is now an explicit parameter instead of a hardcoded +/// magic number. Callers should pass a sensible cap (e.g. 100 for live polling) +/// to prevent runaway result sets on very active threads. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_new_posts_since( conn: &rusqlite::Connection, thread_id: i64, since_id: i64, + max_results: i64, ) -> Result> { let mut stmt = conn.prepare_cached( "SELECT id, thread_id, board_id, name, tripcode, subject, body, body_html, @@ -79,15 +136,21 @@ pub fn get_new_posts_since( edited_at FROM posts WHERE thread_id = ?1 AND id > ?2 ORDER BY id ASC - LIMIT 100", + LIMIT ?3", )?; let posts = stmt - .query_map(params![thread_id, since_id], map_post)? + .query_map(params![thread_id, since_id, max_results], map_post)? .collect::>>()?; Ok(posts) } /// Get last N posts for a thread (for board index preview). +/// +/// FIX[LOW-9]: The inner subquery used `SELECT *` which silently breaks if the +/// schema adds or reorders columns. Replaced with explicit column list. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_preview_posts(conn: &rusqlite::Connection, thread_id: i64, n: i64) -> Result> { // Subquery gets the last N, outer query re-orders ascending for display. let mut stmt = conn.prepare_cached( @@ -97,7 +160,12 @@ pub fn get_preview_posts(conn: &rusqlite::Connection, thread_id: i64, n: i64) -> audio_file_path, audio_file_name, audio_file_size, audio_mime_type, edited_at FROM ( - SELECT * FROM posts WHERE thread_id = ?1 AND is_op = 0 + SELECT id, thread_id, board_id, name, tripcode, subject, body, body_html, + ip_hash, file_path, file_name, file_size, thumb_path, mime_type, + created_at, deletion_token, is_op, media_type, + audio_file_path, audio_file_name, audio_file_size, audio_mime_type, + edited_at + FROM posts WHERE thread_id = ?1 AND is_op = 0 ORDER BY created_at DESC, id DESC LIMIT ?2 ) ORDER BY created_at ASC, id ASC", )?; @@ -111,14 +179,18 @@ pub fn get_preview_posts(conn: &rusqlite::Connection, thread_id: i64, n: i64) -> /// inside its manual BEGIN IMMEDIATE transaction, and wrapped by `create_post`. /// /// `pub(super)` so sibling modules can call it without exposing it externally. +/// +/// # Errors +/// Returns an error if the database operation fails. pub(super) fn create_post_inner(conn: &rusqlite::Connection, p: &super::NewPost) -> Result { - conn.execute( + let post_id: i64 = conn.query_row( "INSERT INTO posts (thread_id, board_id, name, tripcode, subject, body, body_html, ip_hash, file_path, file_name, file_size, thumb_path, mime_type, deletion_token, is_op, media_type, audio_file_path, audio_file_name, audio_file_size, audio_mime_type) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)", + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20) + RETURNING id", params![ p.thread_id, p.board_id, @@ -134,21 +206,26 @@ pub(super) fn create_post_inner(conn: &rusqlite::Connection, p: &super::NewPost) p.thumb_path, p.mime_type, p.deletion_token, - p.is_op as i32, + i32::from(p.is_op), p.media_type, p.audio_file_path, p.audio_file_name, p.audio_file_size, p.audio_mime_type, ], + |r| r.get(0), )?; - Ok(conn.last_insert_rowid()) + Ok(post_id) } +/// # Errors +/// Returns an error if the database operation fails. pub fn create_post(conn: &rusqlite::Connection, p: &super::NewPost) -> Result { create_post_inner(conn, p) } +/// # Errors +/// Returns an error if the database operation fails. pub fn get_post(conn: &rusqlite::Connection, post_id: i64) -> Result> { let mut stmt = conn.prepare_cached( "SELECT id, thread_id, board_id, name, tripcode, subject, body, body_html, @@ -163,10 +240,8 @@ pub fn get_post(conn: &rusqlite::Connection, post_id: i64) -> Result>>/board/N` links therefore unambiguously -/// identify one post; this function validates the board membership so a crafted -/// link cannot leak posts from a different board. +/// # Errors +/// Returns an error if the database operation fails. pub fn get_post_on_board( conn: &rusqlite::Connection, board_short: &str, @@ -188,35 +263,75 @@ pub fn get_post_on_board( .optional()?) } -/// Delete a post by id; returns file paths for cleanup. +/// Delete a post by id; returns file paths safe to remove from disk. +/// +/// FIX[HIGH-3]: The previous implementation had a SELECT → DELETE TOCTOU race: +/// if the post was concurrently deleted between the `get_post` call and the +/// DELETE, the function silently returned an empty path list rather than an +/// error, and the caller would skip file cleanup assuming there was nothing to +/// clean. Both operations are now wrapped in a single transaction so no +/// interleaving is possible. `paths_safe_to_delete` is called inside the +/// transaction so it sees the post-delete state. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn delete_post(conn: &rusqlite::Connection, post_id: i64) -> Result> { - let mut candidates = Vec::new(); - if let Some(post) = get_post(conn, post_id)? { - if let Some(p) = post.file_path { - candidates.push(p); - } - if let Some(p) = post.thumb_path { - candidates.push(p); - } - if let Some(p) = post.audio_file_path { - candidates.push(p); + let tx = conn + .unchecked_transaction() + .context("Failed to begin delete_post transaction")?; + + let candidates = { + let mut candidates = Vec::new(); + let mut stmt = tx.prepare_cached( + "SELECT file_path, thumb_path, audio_file_path FROM posts WHERE id = ?1", + )?; + if let Some((f, t, a)) = stmt + .query_row(params![post_id], |r| { + Ok(( + r.get::<_, Option>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, Option>(2)?, + )) + }) + .optional()? + { + if let Some(p) = f { + candidates.push(p); + } + if let Some(p) = t { + candidates.push(p); + } + if let Some(p) = a { + candidates.push(p); + } } - } - conn.execute("DELETE FROM posts WHERE id = ?1", params![post_id])?; - Ok(super::paths_safe_to_delete(conn, candidates)) + candidates + }; + + tx.execute("DELETE FROM posts WHERE id = ?1", params![post_id]) + .context("Failed to delete post")?; + + // Check which paths are now safe — runs inside the transaction so it sees + // the just-deleted state. + let safe = super::paths_safe_to_delete(&tx, candidates); + + tx.commit() + .context("Failed to commit delete_post transaction")?; + Ok(safe) } -/// FIX[LOW-3]: Use constant-time byte comparison to prevent timing attacks on -/// deletion token verification. Tokens are 32-char random hex, making practical -/// timing attacks difficult, but constant-time is correct practice for any secret. +/// Use constant-time byte comparison to prevent timing side-channel attacks on +/// deletion token verification. +/// +/// Tokens are 32-char random hex, making practical timing attacks difficult, but +/// constant-time comparison is correct practice for any secret value. /// /// Note: `edit_post` inlines its own transactional token check, so this helper -/// is not currently called. It is kept for future handlers (e.g. user-facing -/// post deletion) that will need standalone token verification. -// `dead_code` does not fire on `pub` functions in a library crate (the compiler -// treats them as potentially used by external consumers), so #[expect(dead_code)] -// would itself become an "unfulfilled lint expectation" error. #[allow] is the -// correct attribute when the lint is structurally absent rather than suppressed. +/// is not currently called there. Kept for future handlers (e.g. user-facing +/// post deletion) that need standalone token verification. +/// +/// # Errors +/// Returns an error if the database operation fails. #[allow(dead_code)] pub fn verify_deletion_token( conn: &rusqlite::Connection, @@ -231,9 +346,7 @@ pub fn verify_deletion_token( ) .optional()?; - Ok(stored - .map(|s| constant_time_eq(s.as_bytes(), token.as_bytes())) - .unwrap_or(false)) + Ok(stored.is_some_and(|s| constant_time_eq(s.as_bytes(), token.as_bytes()))) } /// Edit a post's body, verified against the deletion token and a per-board edit window. @@ -243,13 +356,18 @@ pub fn verify_deletion_token( /// Returns `Ok(true)` on success, `Ok(false)` if the token is wrong or the /// edit window has closed; `Err` for database failures. /// -/// FIX[EDIT-TXN]: Previously the three DB round-trips (token verify → fetch -/// created_at → UPDATE) ran without a transaction. If the post was concurrently -/// deleted between the token check and the UPDATE, `execute` would affect 0 rows -/// but the function still returned `Ok(true)`. All three operations are now -/// wrapped in a single BEGIN IMMEDIATE transaction so no interleaving is possible, -/// and we check `conn.changes() > 0` after the UPDATE to confirm a row was -/// actually written before returning success. +/// FIX[HIGH-1]: Upgraded from DEFERRED (`unchecked_transaction`) to IMMEDIATE by +/// issuing BEGIN IMMEDIATE explicitly. A DEFERRED transaction on a write +/// operation can fail with `SQLITE_BUSY` when the write lock is contested; IMMEDIATE +/// acquires the write lock upfront, eliminating mid-transaction lock escalation. +/// +/// FIX[HIGH-2]: The previous two-round-trip design (one SELECT for the token, +/// a second SELECT for `created_at`) introduced a race window: the post could be +/// deleted between the token check and the timestamp fetch. Both values are now +/// fetched in a single SELECT inside the IMMEDIATE transaction. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn edit_post( conn: &rusqlite::Connection, post_id: i64, @@ -264,82 +382,89 @@ pub fn edit_post( edit_window_secs }; - let tx = conn - .unchecked_transaction() - .context("Failed to begin edit_post transaction")?; - - // Token check — runs inside the transaction so the post can't be deleted - // between this check and the UPDATE below. - let stored: Option = tx - .query_row( - "SELECT deletion_token FROM posts WHERE id = ?1", - params![post_id], - |r| r.get(0), - ) - .optional()?; - - let token_ok = stored - .map(|s| constant_time_eq(s.as_bytes(), token.as_bytes())) - .unwrap_or(false); - - if !token_ok { - tx.rollback().ok(); - return Ok(false); - } - - let created_at: Option = tx - .query_row( - "SELECT created_at FROM posts WHERE id = ?1", - params![post_id], - |r| r.get(0), - ) - .optional()?; - - let created_at = match created_at { - Some(t) => t, - None => { - tx.rollback().ok(); + // BEGIN IMMEDIATE acquires the write lock now, preventing any concurrent + // writer from modifying the post between our SELECT and UPDATE. + conn.execute_batch("BEGIN IMMEDIATE") + .context("Failed to begin IMMEDIATE transaction for edit_post")?; + + let result: Result = (|| { + // FIX[HIGH-2]: Fetch token and created_at in a single round-trip. + let row: Option<(String, i64)> = conn + .query_row( + "SELECT deletion_token, created_at FROM posts WHERE id = ?1", + params![post_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + + let Some((stored_token, created_at)) = row else { + return Ok(false); // post does not exist + }; + + if !constant_time_eq(stored_token.as_bytes(), token.as_bytes()) { return Ok(false); } - }; - - let now = chrono::Utc::now().timestamp(); - if now - created_at > window { - tx.rollback().ok(); - return Ok(false); - } - tx.execute( - "UPDATE posts SET body = ?1, body_html = ?2, edited_at = ?3 WHERE id = ?4", - params![new_body, new_body_html, now, post_id], - )?; + let now = chrono::Utc::now().timestamp(); + if now - created_at > window { + return Ok(false); + } - // Confirm the row was actually updated (it could have been deleted by a - // concurrent admin action between our SELECT and this UPDATE — both now - // happen under the same transaction, but in DEFERRED mode a concurrent - // writer could have slipped in; IMMEDIATE below prevents this, but we - // check changes() as a belt-and-suspenders guard regardless). - let updated = tx.changes() > 0; + conn.execute( + "UPDATE posts SET body = ?1, body_html = ?2, edited_at = ?3 WHERE id = ?4", + params![new_body, new_body_html, now, post_id], + )?; - tx.commit() - .context("Failed to commit edit_post transaction")?; + // Belt-and-suspenders: confirm the row was actually written. + Ok(conn.changes() > 0) + })(); - Ok(updated) + match result { + Ok(updated) => { + conn.execute_batch("COMMIT") + .context("Failed to commit edit_post transaction")?; + Ok(updated) + } + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } } /// Constant-time byte slice comparison to prevent timing side-channel attacks. +/// +/// FIX[MED-7]: The previous implementation returned false immediately when +/// lengths differed, leaking token length as a timing signal. The comparison +/// now processes all bytes from the longer slice regardless of length, folding +/// the length mismatch into the accumulator. fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; + let max_len = a.len().max(b.len()); + // Non-zero when lengths differ. + let mut diff = u8::try_from(a.len() ^ b.len()).unwrap_or(u8::MAX); + for i in 0..max_len { + let x = a.get(i).copied().unwrap_or(0); + let y = b.get(i).copied().unwrap_or(0); + diff |= x ^ y; } - let diff = a - .iter() - .zip(b.iter()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)); diff == 0 } +// ─── LIKE escape helper ─────────────────────────────────────────────────────── + +/// FIX[MED-8]: Extracted from `search_posts` and `count_search_results` to avoid +/// duplicating the escape logic. Escapes `%` and `_` metacharacters so that +/// user-supplied query strings are treated as literal substrings. +fn like_escape(query: &str) -> String { + format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")) +} + +// ─── Search ─────────────────────────────────────────────────────────────────── + /// Full-text search across post bodies. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn search_posts( conn: &rusqlite::Connection, board_id: i64, @@ -347,8 +472,8 @@ pub fn search_posts( limit: i64, offset: i64, ) -> Result> { - let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")); - let mut stmt = conn.prepare( + let pattern = like_escape(query); + let mut stmt = conn.prepare_cached( "SELECT id, thread_id, board_id, name, tripcode, subject, body, body_html, ip_hash, file_path, file_name, file_size, thumb_path, mime_type, created_at, deletion_token, is_op, media_type, @@ -363,12 +488,14 @@ pub fn search_posts( Ok(posts) } +/// # Errors +/// Returns an error if the database operation fails. pub fn count_search_results( conn: &rusqlite::Connection, board_id: i64, query: &str, ) -> Result { - let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")); + let pattern = like_escape(query); Ok(conn.query_row( "SELECT COUNT(*) FROM posts WHERE board_id = ?1 AND body LIKE ?2 ESCAPE '\\'", params![board_id, pattern], @@ -379,6 +506,9 @@ pub fn count_search_results( // ─── File deduplication ─────────────────────────────────────────────────────── /// Look up an existing upload by its SHA-256 hash. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn find_file_by_hash( conn: &rusqlite::Connection, sha256: &str, @@ -398,6 +528,9 @@ pub fn find_file_by_hash( } /// Record a newly saved upload in the deduplication table. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn record_file_hash( conn: &rusqlite::Connection, sha256: &str, @@ -416,6 +549,13 @@ pub fn record_file_hash( // ─── Poll queries ───────────────────────────────────────────────────────────── /// Create a poll with its options atomically. +/// +/// FIX[MED-5]: Replaced `last_insert_rowid()` with INSERT … RETURNING id so the +/// poll id is retrieved atomically in the same statement rather than relying on +/// connection-local state. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn create_poll( conn: &rusqlite::Connection, thread_id: i64, @@ -429,22 +569,43 @@ pub fn create_poll( let tx = conn .unchecked_transaction() .context("Failed to begin poll transaction")?; - tx.execute( - "INSERT INTO polls (thread_id, question, expires_at) VALUES (?1, ?2, ?3)", - params![thread_id, question, expires_at], - )?; - let poll_id = tx.last_insert_rowid(); + + let poll_id: i64 = tx + .query_row( + "INSERT INTO polls (thread_id, question, expires_at) VALUES (?1, ?2, ?3) + RETURNING id", + params![thread_id, question, expires_at], + |r| r.get(0), + ) + .context("Failed to insert poll")?; + + let mut opt_stmt = tx + .prepare_cached("INSERT INTO poll_options (poll_id, text, position) VALUES (?1, ?2, ?3)")?; for (i, text) in options.iter().enumerate() { - tx.execute( - "INSERT INTO poll_options (poll_id, text, position) VALUES (?1, ?2, ?3)", - params![poll_id, text, i as i64], - )?; + opt_stmt + .execute(params![ + poll_id, + text, + i64::try_from(i).context("poll option index overflow")? + ]) + .context("Failed to insert poll option")?; } + drop(opt_stmt); // release borrow on tx before commit + tx.commit().context("Failed to commit poll transaction")?; Ok(poll_id) } /// Fetch the full poll for a thread including vote counts and the user's choice. +/// +/// Note: poll expiry is checked against the application clock (`chrono::Utc::now`) +/// while `poll_votes` are pruned using the `SQLite` clock (`unixepoch()`). A skew +/// between the two clocks (e.g. container time drift) could cause a poll to +/// appear expired to the application before `SQLite` prunes it, or vice versa. +/// In practice the skew is negligible for typical deployments. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_poll_for_thread( conn: &rusqlite::Connection, thread_id: i64, @@ -468,9 +629,8 @@ pub fn get_poll_for_thread( ) .optional()?; - let poll = match poll_row { - Some(p) => p, - None => return Ok(None), + let Some(poll) = poll_row else { + return Ok(None); }; let mut stmt = conn.prepare_cached( @@ -516,19 +676,22 @@ pub fn get_poll_for_thread( })) } -/// Cast a vote. Returns true if vote was recorded, false if already voted. +/// Cast a vote. Returns true if vote was recorded, false otherwise. +/// +/// FIX[CROSS-POLL]: Validates that `option_id` belongs to `poll_id` inside +/// the same INSERT statement via a correlated WHERE EXISTS. A mismatched +/// (`poll_id`, `option_id`) pair inserts nothing and returns false. /// -/// FIX[CROSS-POLL]: Previously there was no validation that `option_id` -/// belongs to `poll_id`. A user could submit poll_id=1, option_id=5 where -/// option 5 actually belongs to poll 2. The rogue row would be inserted and, -/// because the vote-count query joins on option_id alone (see get_poll_for_thread), -/// the vote would be counted for poll 2 / option 5 — inflating results on a -/// poll the attacker never legitimately participated in. +/// Note (MED-14): This function returns false for two distinct cases: +/// 1. The voter has already voted (UNIQUE constraint fires INSERT OR IGNORE) +/// 2. The `option_id` does not belong to `poll_id` (EXISTS check fails) /// -/// We now verify the option belongs to the poll inside the same INSERT -/// statement using a SELECT subquery. If the option does not exist in this -/// poll, the SELECT returns no rows, the INSERT inserts nothing, and we return -/// false. This is a single atomic operation with no TOCTOU gap. +/// Callers that need to distinguish these cases should call `cast_vote` and, on +/// false, separately query whether the IP has voted on this poll. A future +/// refactor could return a tri-state enum instead. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn cast_vote( conn: &rusqlite::Connection, poll_id: i64, @@ -547,7 +710,10 @@ pub fn cast_vote( Ok(result > 0) } -/// Resolve (poll_id, thread_id, board_short) from an option_id. +/// Resolve (`poll_id`, `thread_id`, `board_short`) from an `option_id`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_poll_context( conn: &rusqlite::Connection, option_id: i64, @@ -566,26 +732,68 @@ pub fn get_poll_context( .optional()?) } +// ─── Poll maintenance ───────────────────────────────────────────────────────── + +/// Delete vote rows for polls whose `expires_at` is older than the given cutoff timestamp. +/// +/// The poll question and options are preserved for historical display; only +/// the per-IP vote records are pruned. +/// +/// Returns the number of vote rows deleted. +/// +/// Note (MED-11): The parameter was previously named `retention_cutoff` in some +/// call sites, which is misleading — a lower value retains more votes, and a +/// higher value prunes more. It is more accurately described as an "expiry +/// cutoff": any poll that expired before this timestamp has its votes pruned. +/// +/// # Errors +/// Returns an error if the database operation fails. +pub fn cleanup_expired_poll_votes( + conn: &rusqlite::Connection, + expiry_cutoff: i64, +) -> Result { + let n = conn.execute( + "DELETE FROM poll_votes + WHERE poll_id IN ( + SELECT id FROM polls + WHERE expires_at IS NOT NULL AND expires_at < ?1 + )", + params![expiry_cutoff], + )?; + Ok(n) +} + // ─── Background job queue ───────────────────────────────────────────────────── // // Jobs flow through: pending → running → done | failed // claim_next_job uses UPDATE … RETURNING for atomic claim with no TOCTOU race. /// Persist a new job in the pending state. Returns the new row id. +/// +/// FIX[MED-4]: INSERT … RETURNING id replaces execute + `last_insert_rowid()`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn enqueue_job(conn: &rusqlite::Connection, job_type: &str, payload: &str) -> Result { - conn.execute( - "INSERT INTO background_jobs (job_type, payload, status, updated_at) - VALUES (?1, ?2, 'pending', unixepoch())", - params![job_type, payload], - )?; - Ok(conn.last_insert_rowid()) + let id: i64 = conn + .query_row( + "INSERT INTO background_jobs (job_type, payload, status, updated_at) + VALUES (?1, ?2, 'pending', unixepoch()) RETURNING id", + params![job_type, payload], + |r| r.get(0), + ) + .context("Failed to enqueue job")?; + Ok(id) } /// Atomically claim the highest-priority pending job that has not exhausted -/// its retry budget. Returns (job_id, payload) or None when the queue is empty. +/// its retry budget. Returns (`job_id`, payload) or None when the queue is empty. /// -/// The UPDATE … RETURNING subquery is a single atomic operation in SQLite's +/// The UPDATE … RETURNING subquery is a single atomic operation in `SQLite`'s /// WAL mode, so no two workers can claim the same job. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn claim_next_job(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn.prepare_cached( "UPDATE background_jobs @@ -594,43 +802,66 @@ pub fn claim_next_job(conn: &rusqlite::Connection) -> Result(0)?, r.get::<_, String>(1)?))) + .query_row(params![MAX_JOB_ATTEMPTS], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) + }) .optional()?; Ok(result) } /// Mark a job as successfully completed. +/// +/// FIX[LOW-16]: Added rows-affected check — silently succeeding for an unknown +/// `job_id` made double-complete bugs invisible. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn complete_job(conn: &rusqlite::Connection, id: i64) -> Result<()> { - conn.execute( + let n = conn.execute( "UPDATE background_jobs SET status = 'done', updated_at = unixepoch() - WHERE id = ?1", + WHERE id = ?1 AND status = 'running'", params![id], )?; + if n == 0 { + anyhow::bail!("Job {id} not found or not in 'running' state"); + } Ok(()) } -/// Record a job failure. After MAX_ATTEMPTS the job stays "failed" permanently. +/// Record a job failure. After `MAX_JOB_ATTEMPTS` the job stays "failed" permanently. +/// +/// FIX[LOW-16]: Added rows-affected check. +/// FIX[MED-6]: Uses `MAX_JOB_ATTEMPTS` constant instead of duplicating the magic number. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn fail_job(conn: &rusqlite::Connection, id: i64, error: &str) -> Result<()> { let err_trunc: String = error.chars().take(512).collect(); - conn.execute( + let n = conn.execute( "UPDATE background_jobs - SET status = CASE WHEN attempts >= 3 THEN 'failed' ELSE 'pending' END, + SET status = CASE WHEN attempts >= ?3 THEN 'failed' ELSE 'pending' END, last_error = ?2, updated_at = unixepoch() - WHERE id = ?1", - params![id, err_trunc], + WHERE id = ?1 AND status = 'running'", + params![id, err_trunc, MAX_JOB_ATTEMPTS], )?; + if n == 0 { + anyhow::bail!("Job {id} not found or not in 'running' state"); + } Ok(()) } /// Count jobs currently in the 'pending' state (used for monitoring). +/// +/// # Errors +/// Returns an error if the database operation fails. #[allow(dead_code)] pub fn pending_job_count(conn: &rusqlite::Connection) -> Result { let n: i64 = conn.query_row( @@ -643,7 +874,10 @@ pub fn pending_job_count(conn: &rusqlite::Connection) -> Result { // ─── Post update helpers (used by background workers) ──────────────────────── -/// Update a post's file_path and mime_type after background transcoding. +/// Update a post's `file_path` and `mime_type` after background transcoding. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn update_post_file_info( conn: &rusqlite::Connection, post_id: i64, @@ -660,14 +894,23 @@ pub fn update_post_file_info( /// Update every post that currently stores `old_path` as its `file_path`. /// /// Required after video transcoding: the deduplication system shares one -/// physical file across N posts. The VideoTranscode worker knows only the -/// post_id that triggered the job, but ALL posts that reference the same MP4 -/// must be migrated to the new WebM path before the MP4 is removed from disk. +/// physical file across N posts. The `VideoTranscode` worker knows only the +/// `post_id` that triggered the job, but ALL posts that reference the same MP4 +/// must be migrated to the new `WebM` path before the MP4 is removed from disk. /// Without this, `paths_safe_to_delete` counts zero references to the old MP4 -/// and marks it safe to delete — but the WebM it was replaced with would also +/// and marks it safe to delete — but the `WebM` it was replaced with would also /// be considered orphaned the next time any of those stale posts is deleted. /// +/// Note (MED-15): This function does NOT update the corresponding `file_hashes` +/// row. The caller MUST call `delete_file_hash_by_path(old_path)` and then +/// `record_file_hash(sha256`, `new_path`, ...) after this function returns, before +/// any subsequent `paths_safe_to_delete` call. Failure to do so leaves the dedup +/// table pointing at the old (now-deleted) path. +/// /// Returns the number of posts updated. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn update_all_posts_file_path( conn: &rusqlite::Connection, old_path: &str, @@ -681,7 +924,10 @@ pub fn update_all_posts_file_path( Ok(n) } -/// Update a post's thumb_path after background waveform / thumbnail generation. +/// Update a post's `thumb_path` after background waveform / thumbnail generation. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn update_post_thumb_path( conn: &rusqlite::Connection, post_id: i64, @@ -694,8 +940,11 @@ pub fn update_post_thumb_path( Ok(()) } -/// Retrieve just the thumb_path for a post (used by VideoTranscode worker to +/// Retrieve just the `thumb_path` for a post (used by `VideoTranscode` worker to /// preserve the existing thumbnail when refreshing the file-hash record). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_post_thumb_path(conn: &rusqlite::Connection, post_id: i64) -> Result> { let result = conn .query_row( @@ -708,8 +957,11 @@ pub fn get_post_thumb_path(conn: &rusqlite::Connection, post_id: i64) -> Result< Ok(result) } -/// Delete a file-hash record by its stored file_path (used when the worker -/// replaces an MP4 with the transcoded WebM and needs to refresh the index). +/// Delete a file-hash record by its stored `file_path` (used when the worker +/// replaces an MP4 with the transcoded `WebM` and needs to refresh the index). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn delete_file_hash_by_path(conn: &rusqlite::Connection, file_path: &str) -> Result<()> { conn.execute( "DELETE FROM file_hashes WHERE file_path = ?1", diff --git a/src/db/threads.rs b/src/db/threads.rs index 8728643..58bb718 100644 --- a/src/db/threads.rs +++ b/src/db/threads.rs @@ -7,61 +7,163 @@ // create_thread_with_op → super::posts::create_post_inner (OP insert) // delete_thread → super::paths_safe_to_delete (file safety) // prune_old_threads → super::paths_safe_to_delete (file safety) +// +// FIX summary (from audit): +// HIGH-1 delete_thread: SELECT+DELETE now atomic inside a transaction +// HIGH-2 prune_old_threads: paths_safe_to_delete moved inside transaction +// so it sees the post-delete DB state before any concurrent insert +// HIGH-3 archive_old_threads / prune_old_threads: ID collection query +// moved inside the transaction to close the TOCTOU race +// MED-4 create_thread_with_op: raw BEGIN/COMMIT replaced with structured +// helper using execute_batch for cleaner error flow +// MED-5 prune_old_threads: prepare_cached inside loop is now a single +// prepare_cached outside the loop (was documented as fixed but +// was not actually implemented) +// MED-6 bump_thread: not co-transactional with post insert — documented +// MED-7 set_thread_archived(false): no longer unconditionally unlocks; +// locked state is only changed when archiving +// MED-8 image_count correlated subquery: replaced with LEFT JOIN + COUNT +// MED-9 map_thread helper: extracted from 3 copy-pasted closures +// LOW-10 LIMIT -1 OFFSET ?: documented as SQLite-specific idiom +// LOW-11 File-path collection: extracted into collect_thread_file_paths helper +// MED-13 archive_old_threads / prune_old_threads: N single-row operations +// replaced with bulk WHERE id IN (...) +// MED-17 prune_old_threads: N per-thread file-path queries replaced with +// a single JOIN query -use crate::models::*; +use crate::models::Thread; use anyhow::{Context, Result}; use rusqlite::{params, OptionalExtension}; +// ─── Row mapper ─────────────────────────────────────────────────────────────── + +/// Map a thread row. Column layout (must match every SELECT that calls this): +/// 0 t.id 4 `t.bumped_at` 8 op.body 12 op.tripcode +/// 1 `t.board_id` 5 t.locked 9 `op.file_path` 13 op.id (`op_id`) +/// 2 t.subject 6 t.sticky 10 `op.thumb_path` 14 t.archived +/// 3 `t.created_at` 7 `t.reply_count` 11 op.name 15 `image_count` +/// +/// FIX[MED-9]: Extracted from three copy-pasted closures into a single helper, +/// eliminating the risk of the three copies diverging. +fn map_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Thread { + id: row.get(0)?, + board_id: row.get(1)?, + subject: row.get(2)?, + created_at: row.get(3)?, + bumped_at: row.get(4)?, + locked: row.get::<_, i32>(5)? != 0, + sticky: row.get::<_, i32>(6)? != 0, + reply_count: row.get(7)?, + op_body: row.get(8)?, + op_file: row.get(9)?, + op_thumb: row.get(10)?, + op_name: row.get(11)?, + op_tripcode: row.get(12)?, + op_id: row.get(13)?, + archived: row.get::<_, i32>(14)? != 0, + image_count: row.get(15)?, + }) +} + +// ─── File-path collection helper ────────────────────────────────────────────── + +/// Collect all file paths (`file_path`, `thumb_path`, `audio_file_path`) for every +/// post in the given set of thread ids. Returns a flat Vec of non-null paths. +/// +/// FIX[LOW-11]: Extracted from `delete_thread` and `prune_old_threads` to eliminate +/// copy-pasted collection loops. +/// FIX[MED-17]: Uses a single JOIN query instead of one query per thread. +/// +/// IMPORTANT: This must be called BEFORE the thread rows are deleted so that +/// the posts still exist. The CASCADE on threads→posts removes them atomically +/// with the thread row when you later execute DELETE FROM threads. +fn collect_thread_file_paths( + conn: &rusqlite::Connection, + thread_ids: &[i64], +) -> Result> { + if thread_ids.is_empty() { + return Ok(Vec::new()); + } + + // Build WHERE thread_id IN (?, ?, ...) dynamically. + let placeholders: String = thread_ids + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT file_path, thumb_path, audio_file_path + FROM posts WHERE thread_id IN ({placeholders})" + ); + + let mut stmt = conn.prepare(&sql)?; + let rows: Vec<(Option, Option, Option)> = stmt + .query_map(rusqlite::params_from_iter(thread_ids), |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })? + .collect::>()?; + + let mut paths = Vec::new(); + for (f, t, a) in rows { + if let Some(p) = f { + paths.push(p); + } + if let Some(p) = t { + paths.push(p); + } + if let Some(p) = a { + paths.push(p); + } + } + Ok(paths) +} + // ─── Board-index thread listing ─────────────────────────────────────────────── +/// The canonical thread SELECT fragment shared by all listing queries. +/// +/// FIX[MED-8]: Replaced the correlated `image_count` subquery (which ran once +/// per thread row) with a LEFT JOIN aggregation so the count is computed in a +/// single pass. The GROUP BY ensures one output row per thread. +const THREAD_SELECT: &str = " + SELECT t.id, t.board_id, t.subject, t.created_at, t.bumped_at, + t.locked, t.sticky, t.reply_count, + op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, + t.archived, + COUNT(DISTINCT fp.id) AS image_count + FROM threads t + JOIN posts op ON op.thread_id = t.id AND op.is_op = 1 + LEFT JOIN posts fp ON fp.thread_id = t.id AND fp.file_path IS NOT NULL"; + /// Get paginated threads for a board with OP preview data. +/// Sticky threads float to the top, then sorted by most recent bump. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_threads_for_board( conn: &rusqlite::Connection, board_id: i64, limit: i64, offset: i64, ) -> Result> { - // Sticky threads float to top, then sorted by most recent bump. - let mut stmt = conn.prepare_cached( - "SELECT t.id, t.board_id, t.subject, t.created_at, t.bumped_at, - t.locked, t.sticky, t.reply_count, - op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, - t.archived, - (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id - AND p.file_path IS NOT NULL - ) AS image_count - FROM threads t - JOIN posts op ON op.thread_id = t.id AND op.is_op = 1 + let sql = format!( + "{THREAD_SELECT} WHERE t.board_id = ?1 AND t.archived = 0 + GROUP BY t.id, op.id ORDER BY t.sticky DESC, t.bumped_at DESC - LIMIT ?2 OFFSET ?3", - )?; - + LIMIT ?2 OFFSET ?3" + ); + let mut stmt = conn.prepare_cached(&sql)?; let threads = stmt - .query_map(params![board_id, limit, offset], |row| { - Ok(Thread { - id: row.get(0)?, - board_id: row.get(1)?, - subject: row.get(2)?, - created_at: row.get(3)?, - bumped_at: row.get(4)?, - locked: row.get::<_, i32>(5)? != 0, - sticky: row.get::<_, i32>(6)? != 0, - reply_count: row.get(7)?, - op_body: row.get(8)?, - op_file: row.get(9)?, - op_thumb: row.get(10)?, - op_name: row.get(11)?, - op_tripcode: row.get(12)?, - op_id: row.get(13)?, - archived: row.get::<_, i32>(14)? != 0, - image_count: row.get(15)?, - }) - })? + .query_map(params![board_id, limit, offset], map_thread)? .collect::>>()?; Ok(threads) } +/// # Errors +/// Returns an error if the database operation fails. pub fn count_threads_for_board(conn: &rusqlite::Connection, board_id: i64) -> Result { Ok(conn.query_row( "SELECT COUNT(*) FROM threads WHERE board_id = ?1 AND archived = 0", @@ -70,72 +172,57 @@ pub fn count_threads_for_board(conn: &rusqlite::Connection, board_id: i64) -> Re )?) } +/// # Errors +/// Returns an error if the database operation fails. pub fn get_thread(conn: &rusqlite::Connection, thread_id: i64) -> Result> { - let mut stmt = conn.prepare_cached( - "SELECT t.id, t.board_id, t.subject, t.created_at, t.bumped_at, - t.locked, t.sticky, t.reply_count, - op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, - t.archived, - (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id - AND p.file_path IS NOT NULL - ) AS image_count - FROM threads t - JOIN posts op ON op.thread_id = t.id AND op.is_op = 1 - WHERE t.id = ?1", - )?; - Ok(stmt - .query_row(params![thread_id], |row| { - Ok(Thread { - id: row.get(0)?, - board_id: row.get(1)?, - subject: row.get(2)?, - created_at: row.get(3)?, - bumped_at: row.get(4)?, - locked: row.get::<_, i32>(5)? != 0, - sticky: row.get::<_, i32>(6)? != 0, - reply_count: row.get(7)?, - op_body: row.get(8)?, - op_file: row.get(9)?, - op_thumb: row.get(10)?, - op_name: row.get(11)?, - op_tripcode: row.get(12)?, - op_id: row.get(13)?, - archived: row.get::<_, i32>(14)? != 0, - image_count: row.get(15)?, - }) - }) - .optional()?) + let sql = format!( + "{THREAD_SELECT} + WHERE t.id = ?1 + GROUP BY t.id, op.id" + ); + let mut stmt = conn.prepare_cached(&sql)?; + Ok(stmt.query_row(params![thread_id], map_thread).optional()?) } // ─── Thread creation (atomic with OP post) ──────────────────────────────────── /// Create a thread AND its OP post atomically in a single transaction. /// -/// FIX[MEDIUM-3]: The previous design had two separate DB calls — create_thread -/// followed by create_post — with no transaction. A crash between the two calls -/// left an orphaned thread with no OP post, causing all board-listing queries -/// (which JOIN on is_op=1) to silently skip the thread forever. +/// The invariant guaranteed here: every thread row has exactly one corresponding +/// post with `is_op=1`. The previous design used two separate DB calls with no +/// transaction, leaving orphaned threads on crash. /// -/// This function is the single entry point for thread creation and wraps both -/// operations in a transaction, guaranteeing the invariant that every thread -/// row has exactly one corresponding post with is_op=1. +/// FIX[MED-4]: Replaced the raw `conn.execute("BEGIN IMMEDIATE", [])?` / +/// `conn.execute("COMMIT", [])` pattern with `execute_batch` calls that keep the +/// error handling structured and avoid the subtle issue of a raw string +/// transaction leaking through rusqlite's normal transaction tracking. /// -/// Returns (thread_id, post_id). +/// Returns (`thread_id`, `post_id`). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn create_thread_with_op( conn: &rusqlite::Connection, board_id: i64, subject: Option<&str>, post: &super::NewPost, ) -> Result<(i64, i64)> { - conn.execute("BEGIN IMMEDIATE", [])?; + // BEGIN IMMEDIATE acquires the write lock upfront to avoid SQLITE_BUSY + // during the lock-upgrade step that DEFERRED transactions perform on first + // write. With &Connection (not &mut Connection) we cannot use rusqlite's + // typed Transaction::new(Immediate), so we issue the pragma directly. + conn.execute_batch("BEGIN IMMEDIATE") + .context("Failed to begin IMMEDIATE transaction for create_thread_with_op")?; - let result = (|| -> Result<(i64, i64)> { - conn.execute( - "INSERT INTO threads (board_id, subject) VALUES (?1, ?2)", + let result: Result<(i64, i64)> = (|| { + let thread_id: i64 = conn.query_row( + "INSERT INTO threads (board_id, subject) VALUES (?1, ?2) RETURNING id", params![board_id, subject], + |r| r.get(0), )?; - let thread_id = conn.last_insert_rowid(); + // Bind thread_id and is_op into the post struct. We avoid a Clone of + // the entire struct by building a minimal wrapper with references. let post_with_thread = super::NewPost { thread_id, is_op: true, @@ -148,14 +235,12 @@ pub fn create_thread_with_op( match result { Ok(ids) => { - if let Err(e) = conn.execute("COMMIT", []) { - let _ = conn.execute("ROLLBACK", []); - return Err(anyhow::anyhow!("Transaction commit failed: {}", e)); - } + conn.execute_batch("COMMIT") + .context("Failed to commit create_thread_with_op transaction")?; Ok(ids) } Err(e) => { - let _ = conn.execute("ROLLBACK", []); + let _ = conn.execute_batch("ROLLBACK"); Err(e) } } @@ -163,6 +248,18 @@ pub fn create_thread_with_op( // ─── Thread mutation ────────────────────────────────────────────────────────── +/// Bump a thread's `bumped_at` timestamp and increment `reply_count`. +/// +/// Note (MED-6): `bump_thread` is called from the route handler after +/// `create_post` returns, not inside the same transaction as the post insert. +/// If the process crashes between the two calls, `reply_count` and `bumped_at` +/// can be one behind reality. A full fix would require moving `bump_thread` +/// into `create_post_inner`, which would change the API surface. Accepted as +/// a known minor inconsistency; the `reply_count` column is advisory and a +/// board reload corrects the displayed count. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn bump_thread(conn: &rusqlite::Connection, thread_id: i64) -> Result<()> { conn.execute( "UPDATE threads SET bumped_at = unixepoch(), reply_count = reply_count + 1 @@ -172,220 +269,261 @@ pub fn bump_thread(conn: &rusqlite::Connection, thread_id: i64) -> Result<()> { Ok(()) } +/// # Errors +/// Returns an error if the database operation fails. pub fn set_thread_sticky(conn: &rusqlite::Connection, thread_id: i64, sticky: bool) -> Result<()> { conn.execute( "UPDATE threads SET sticky = ?1 WHERE id = ?2", - params![sticky as i32, thread_id], + params![i32::from(sticky), thread_id], )?; Ok(()) } +/// # Errors +/// Returns an error if the database operation fails. pub fn set_thread_locked(conn: &rusqlite::Connection, thread_id: i64, locked: bool) -> Result<()> { conn.execute( "UPDATE threads SET locked = ?1 WHERE id = ?2", - params![locked as i32, thread_id], + params![i32::from(locked), thread_id], )?; Ok(()) } /// Move a thread to (or out of) the board archive. -/// Archiving also locks the thread so no new replies can be posted. +/// +/// FIX[MED-7]: The previous implementation used `SET archived = ?1, locked = ?1` +/// which unconditionally unlocked threads when called with archived=false. If a +/// moderator had explicitly locked a thread before archiving it, unarchiving +/// would silently unlock it, discarding the moderator's intent. +/// +/// The new logic: archiving always locks the thread; unarchiving only restores +/// the archived flag and leaves locked untouched. Callers that want to unlock +/// a thread after unarchiving should call `set_thread_locked` separately. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn set_thread_archived( conn: &rusqlite::Connection, thread_id: i64, archived: bool, ) -> Result<()> { conn.execute( - "UPDATE threads SET archived = ?1, locked = ?1 WHERE id = ?2", - params![archived as i32, thread_id], + "UPDATE threads + SET archived = ?1, + locked = CASE WHEN ?1 = 1 THEN 1 ELSE locked END + WHERE id = ?2", + params![i32::from(archived), thread_id], )?; Ok(()) } +/// Delete a thread and return on-disk paths that are now safe to remove. +/// +/// FIX[HIGH-1]: The previous SELECT → DELETE sequence was not wrapped in a +/// transaction. A concurrent delete (e.g. admin panel + prune running together) +/// could delete the posts between our SELECT and DELETE, causing the returned +/// path list to include paths that had already been cleaned up by the other +/// operation — producing spurious filesystem errors. +/// +/// The full sequence is now atomic: +/// 1. Collect file paths (while posts still exist) +/// 2. DELETE the thread (CASCADE removes posts) +/// 3. `paths_safe_to_delete` inside the transaction sees the post-delete state +/// 4. COMMIT +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn delete_thread(conn: &rusqlite::Connection, thread_id: i64) -> Result> { - let mut stmt = conn - .prepare("SELECT file_path, thumb_path, audio_file_path FROM posts WHERE thread_id = ?1")?; - let rows: Vec<(Option, Option, Option)> = stmt - .query_map(params![thread_id], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - })? - .collect::>()?; + let tx = conn + .unchecked_transaction() + .context("Failed to begin delete_thread transaction")?; - let mut candidates = Vec::new(); - for (f, t, a) in rows { - if let Some(p) = f { - candidates.push(p); - } - if let Some(p) = t { - candidates.push(p); - } - if let Some(p) = a { - candidates.push(p); - } - } + // Step 1: collect file paths from all posts in this thread. + let candidates = collect_thread_file_paths(&tx, &[thread_id])?; + + // Step 2: delete thread (CASCADE removes posts). + tx.execute("DELETE FROM threads WHERE id = ?1", params![thread_id]) + .context("Failed to delete thread")?; + + // Step 3: determine which paths are now unreferenced. + // paths_safe_to_delete sees the post-delete state because we're still inside + // the same transaction. + let safe = super::paths_safe_to_delete(&tx, candidates); - conn.execute("DELETE FROM threads WHERE id = ?1", params![thread_id])?; - Ok(super::paths_safe_to_delete(conn, candidates)) + tx.commit() + .context("Failed to commit delete_thread transaction")?; + Ok(safe) } // ─── Archive / prune ────────────────────────────────────────────────────────── -/// Archive oldest non-sticky threads that exceed the board's max_threads limit. -/// Archived threads are locked and marked read-only instead of deleted, so their -/// content remains accessible via /{board}/archive. -/// Returns the count of threads archived (no file deletion occurs). +/// Archive oldest non-sticky threads that exceed the board's `max_threads` limit. +/// +/// Archived threads are locked and marked read-only; their content remains +/// accessible via `/{board}/archive`. Returns the count of threads archived +/// (no file deletion occurs). +/// +/// FIX[HIGH-3]: The ID collection query is now inside the same transaction as +/// the UPDATEs, closing the TOCTOU race where a concurrent bump could change +/// the ordering between the SELECT and the UPDATE loop. /// -/// FIX[ARCHIVE-TXN]: Previously each per-thread UPDATE was auto-committed -/// individually. A crash mid-loop left the board partially archived: some -/// threads invisible from the board index, others still live. All updates are -/// now wrapped in a single transaction so the operation is all-or-nothing. +/// FIX[MED-13]: Replaced the N per-row UPDATE loop with a single bulk +/// UPDATE … WHERE id IN (…), which is both faster and more crash-safe. +/// +/// Note: LIMIT -1 OFFSET ? is a SQLite-specific idiom for "skip the first +/// max rows, return everything else". It is not standard SQL. The LIMIT -1 +/// means "no upper bound on the result set after the offset is applied". +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn archive_old_threads(conn: &rusqlite::Connection, board_id: i64, max: i64) -> Result { + let tx = conn + .unchecked_transaction() + .context("Failed to begin archive_old_threads transaction")?; + + // Collect inside the transaction to prevent races with concurrent bumps. let ids: Vec = { - let mut stmt = conn.prepare( + let mut stmt = tx.prepare_cached( "SELECT id FROM threads WHERE board_id = ?1 AND sticky = 0 AND archived = 0 ORDER BY bumped_at DESC LIMIT -1 OFFSET ?2", )?; - let ids = stmt + let x = stmt .query_map(params![board_id, max], |r| r.get(0))? .collect::>>()?; - ids + x }; + let count = ids.len(); if count == 0 { + tx.rollback().ok(); return Ok(0); } - let tx = conn - .unchecked_transaction() - .context("Failed to begin archive_old_threads transaction")?; - for id in ids { - tx.execute( - "UPDATE threads SET archived = 1, locked = 1 WHERE id = ?1", - params![id], - )?; - } + + // Single bulk UPDATE instead of N individual statements. + let placeholders: String = ids + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect::>() + .join(", "); + let sql = format!("UPDATE threads SET archived = 1, locked = 1 WHERE id IN ({placeholders})"); + tx.execute(&sql, rusqlite::params_from_iter(&ids)) + .context("Failed to bulk archive threads")?; + tx.commit() .context("Failed to commit archive_old_threads transaction")?; Ok(count) } -/// Hard-delete oldest non-sticky, non-archived threads that exceed max_threads. +/// Hard-delete oldest non-sticky, non-archived threads that exceed `max_threads`. /// Used when a board has archiving disabled — threads are permanently removed. /// /// Returns the on-disk paths that are now safe to delete (i.e. no longer /// referenced by any remaining post after the prune). The caller is responsible /// for actually removing these files from disk. /// -/// FIX[PRUNE-TXN]: Previously each per-thread DELETE was auto-committed -/// individually. A crash mid-loop left some threads deleted from the DB but -/// their file paths never returned to the caller, producing unreachable orphaned -/// files on disk. All deletes are now wrapped in a single transaction. +/// FIX[HIGH-3]: ID collection query is now inside the transaction (see above). +/// +/// FIX[HIGH-2]: `paths_safe_to_delete` is called INSIDE the transaction before +/// COMMIT so it sees the post-delete state atomically. Previously it ran after +/// COMMIT, leaving a narrow window where a concurrent post insert could +/// reference a just-pruned file before we checked it. +/// +/// FIX[MED-5]: `prepare_cached` is now used OUTSIDE the loop (was documented as +/// fixed but the `prepare_cached` call was still inside the loop). +/// +/// FIX[MED-13]: Replaced the N per-row DELETE loop with a single bulk DELETE. +/// +/// FIX[MED-17]: File-path collection is now a single JOIN query instead of +/// one query per thread id. /// -/// FIX[PREPARE-LOOP]: The inner prepare() was called once per loop iteration, -/// recompiling the same statement N times. Replaced with prepare_cached(). +/// Note: LIMIT -1 OFFSET ? is a SQLite-specific idiom — see `archive_old_threads`. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn prune_old_threads( conn: &rusqlite::Connection, board_id: i64, max: i64, ) -> Result> { + let tx = conn + .unchecked_transaction() + .context("Failed to begin prune_old_threads transaction")?; + + // Collect ids inside the transaction to prevent concurrent bumps from + // changing the ordering between the SELECT and the DELETE. let ids: Vec = { - let mut stmt = conn.prepare( + let mut stmt = tx.prepare_cached( "SELECT id FROM threads WHERE board_id = ?1 AND sticky = 0 AND archived = 0 ORDER BY bumped_at DESC LIMIT -1 OFFSET ?2", )?; - let ids = stmt + let x = stmt .query_map(params![board_id, max], |r| r.get(0))? .collect::>>()?; - ids + x }; if ids.is_empty() { + tx.rollback().ok(); return Ok(Vec::new()); } - let tx = conn - .unchecked_transaction() - .context("Failed to begin prune_old_threads transaction")?; + // Collect all file paths in a single query BEFORE the DELETEs. + let candidates = collect_thread_file_paths(&tx, &ids)?; - let mut candidates: Vec = Vec::new(); - for id in &ids { - let mut stmt = tx.prepare_cached( - "SELECT file_path, thumb_path, audio_file_path FROM posts WHERE thread_id = ?1", - )?; - let rows: Vec<(Option, Option, Option)> = stmt - .query_map(params![id], |r: &rusqlite::Row| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - })? - .collect::>()?; - for (f, t, a) in rows { - if let Some(p) = f { - candidates.push(p); - } - if let Some(p) = t { - candidates.push(p); - } - if let Some(p) = a { - candidates.push(p); - } - } - tx.execute("DELETE FROM threads WHERE id = ?1", params![id])?; - } + // Single bulk DELETE instead of N individual statements. + let placeholders: String = ids + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect::>() + .join(", "); + let sql = format!("DELETE FROM threads WHERE id IN ({placeholders})"); + tx.execute(&sql, rusqlite::params_from_iter(&ids)) + .context("Failed to bulk delete pruned threads")?; + + // Determine safe paths INSIDE the transaction so the check sees the + // post-delete state before any concurrent writer can insert new references. + let safe = super::paths_safe_to_delete(&tx, candidates); tx.commit() .context("Failed to commit prune_old_threads transaction")?; - Ok(super::paths_safe_to_delete(conn, candidates)) + Ok(safe) } // ─── Archive listing ────────────────────────────────────────────────────────── +/// Get paginated archived threads for a board. +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn get_archived_threads_for_board( conn: &rusqlite::Connection, board_id: i64, limit: i64, offset: i64, ) -> Result> { - let mut stmt = conn.prepare_cached( - "SELECT t.id, t.board_id, t.subject, t.created_at, t.bumped_at, - t.locked, t.sticky, t.reply_count, - op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, - t.archived, - (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id - AND p.file_path IS NOT NULL - ) AS image_count - FROM threads t - JOIN posts op ON op.thread_id = t.id AND op.is_op = 1 + let sql = format!( + "{THREAD_SELECT} WHERE t.board_id = ?1 AND t.archived = 1 + GROUP BY t.id, op.id ORDER BY t.bumped_at DESC - LIMIT ?2 OFFSET ?3", - )?; + LIMIT ?2 OFFSET ?3" + ); + let mut stmt = conn.prepare_cached(&sql)?; let threads = stmt - .query_map(params![board_id, limit, offset], |row| { - Ok(Thread { - id: row.get(0)?, - board_id: row.get(1)?, - subject: row.get(2)?, - created_at: row.get(3)?, - bumped_at: row.get(4)?, - locked: row.get::<_, i32>(5)? != 0, - sticky: row.get::<_, i32>(6)? != 0, - reply_count: row.get(7)?, - op_body: row.get(8)?, - op_file: row.get(9)?, - op_thumb: row.get(10)?, - op_name: row.get(11)?, - op_tripcode: row.get(12)?, - op_id: row.get(13)?, - archived: row.get::<_, i32>(14)? != 0, - image_count: row.get(15)?, - }) - })? + .query_map(params![board_id, limit, offset], map_thread)? .collect::>>()?; Ok(threads) } /// Count archived threads for a board (used for archive pagination). +/// +/// # Errors +/// Returns an error if the database operation fails. pub fn count_archived_threads_for_board(conn: &rusqlite::Connection, board_id: i64) -> Result { Ok(conn.query_row( "SELECT COUNT(*) FROM threads WHERE board_id = ?1 AND archived = 1", diff --git a/src/detect.rs b/src/detect.rs index ce7ba09..8bd2774 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -97,8 +97,8 @@ pub fn detect_ffmpeg(require_ffmpeg: bool) -> ToolStatus { /// Set up and launch a Tor hidden-service instance. /// /// Creates inside `data_dir`: -/// tor_data/ — Tor's DataDirectory (lock file, keys, etc.) -/// tor_hidden_service/ — HiddenServiceDir (private key + hostname) +/// `tor_data`/ — Tor's `DataDirectory` (lock file, keys, etc.) +/// `tor_hidden_service`/ — `HiddenServiceDir` (private key + hostname) /// torrc — auto-generated config /// /// Launches `tor -f ` as a background process then polls for the @@ -112,6 +112,7 @@ 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)] pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> ToolStatus { if !enable_tor_support { return ToolStatus::Missing; @@ -141,7 +142,7 @@ pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> return ToolStatus::Missing; }; - println!("[INFO] Tor binary: {}", tor_bin); + println!("[INFO] Tor binary: {tor_bin}"); // ── 2. Create directories ───────────────────────────────────────────────── let hs_dir = data_dir.join("tor_hidden_service"); @@ -233,7 +234,7 @@ pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> let mut child = match child { Err(e) => { - println!("[WARN] Tor: failed to start '{}': {}", tor_bin, e); + println!("[WARN] Tor: failed to start '{tor_bin}': {e}"); print_torrc_hint(&hs_dir, bind_port); return ToolStatus::Missing; } @@ -274,10 +275,7 @@ pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> })); let pid = child.lock().expect("child process mutex poisoned").id(); - println!( - "[INFO] Tor: process started (pid {}). Waiting for .onion address…", - pid - ); + println!("[INFO] Tor: process started (pid {pid}). Waiting for .onion address…"); // ── 5. Quick health-check + hostname polling (background thread) ────────── let hostname_path = hs_abs.join("hostname"); @@ -294,23 +292,24 @@ pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> // Fix #4 (early-exit check): use the shared child Arc instead of // a moved value so the same handle can also be used by // poll_for_hostname to detect crashes during polling. - match child_bg + let try_wait_result = child_bg .lock() .expect("child process mutex poisoned") - .try_wait() - { + .try_wait(); + match try_wait_result { Ok(Some(status)) => { // Process already exited — surface stderr for the operator. let lines = stderr_bg.lock().expect("stderr buffer mutex poisoned"); println!(); - println!("[ERR ] Tor: process exited early ({})", status); + println!("[ERR ] Tor: process exited early ({status})"); if !lines.is_empty() { println!("────── Tor stderr ──────────────────────────────"); for line in lines.iter().take(20) { - println!(" {}", line); + println!(" {line}"); } println!("────────────────────────────────────────────────"); } + drop(lines); println!(); print_diagnosis_hints(&torrc_display, &tor_bin_owned, bind_port); return; @@ -319,7 +318,7 @@ pub fn detect_tor(enable_tor_support: bool, bind_port: u16, data_dir: &Path) -> // Still running — good. } Err(e) => { - println!("[WARN] Tor: could not query process status: {}", e); + println!("[WARN] Tor: could not query process status: {e}"); // Continue to poll for the hostname file anyway. } } @@ -363,14 +362,15 @@ fn poll_for_hostname( if let Ok(Some(status)) = c.try_wait() { let lines = stderr_lines.lock().expect("stderr buffer mutex poisoned"); println!(); - println!("[ERR ] Tor: process crashed during startup ({})", status); + println!("[ERR ] Tor: process crashed during startup ({status})"); if !lines.is_empty() { println!("────── Tor stderr ──────────────────────────────"); for line in lines.iter().take(20) { - println!(" {}", line); + println!(" {line}"); } println!("────────────────────────────────────────────────"); } + drop(lines); println!(); print_diagnosis_hints(torrc_display, tor_bin, bind_port); return; @@ -388,17 +388,14 @@ fn poll_for_hostname( // Empty file — Tor is still writing; retry. } Err(e) => { - println!("[WARN] Tor: hostname unreadable: {}", e); + println!("[WARN] Tor: hostname unreadable: {e}"); } } } if std::time::Instant::now() >= deadline { println!(); - println!( - "[WARN] Tor: timed out after {}s waiting for hostname file.", - TIMEOUT_SECS - ); + println!("[WARN] Tor: timed out after {TIMEOUT_SECS}s waiting for hostname file."); println!(" Expected at: {}", hostname_path.display()); println!(); print_diagnosis_hints(torrc_display, tor_bin, bind_port); @@ -422,14 +419,14 @@ fn poll_for_hostname( fn print_onion_banner(onion: &str, hostname_path: &Path) { // v3 .onion URL: "http://" (7) + 56-char base32 address + ".onion" (6) = 69 chars. // Box inner width 72 gives 3 chars of right-margin after the longest URL. - let addr_line = format!("http://{}", onion); + let addr_line = format!("http://{onion}"); let key_dir = hostname_path.parent().unwrap_or(hostname_path); println!(); println!("╔════════════════════════════════════════════════════════════════════════╗"); println!("║ TOR ONION SERVICE ACTIVE ✓ ║"); println!("╠════════════════════════════════════════════════════════════════════════╣"); - println!("║ {:<70}║", addr_line); // fix #1: {:<70}, 2+70+1 = inner 72 + println!("║ {addr_line:<70}║"); // fix #1: {:<70}, 2+70+1 = inner 72 println!("║ ║"); println!("║ Share this with Tor Browser users. ║"); println!("║ Your private key is stored at: ║"); @@ -458,7 +455,7 @@ fn print_install_instructions(bind_port: u16) { println!(" Then add to your torrc:"); println!(" SocksPort 0"); println!(" HiddenServiceDir /path/to/tor_hidden_service/"); - println!(" HiddenServicePort 80 127.0.0.1:{}", bind_port); + println!(" HiddenServicePort 80 127.0.0.1:{bind_port}"); println!(); } @@ -467,7 +464,7 @@ fn print_diagnosis_hints(torrc_path: &str, tor_bin: &str, bind_port: u16) { println!(" ── Troubleshooting ─────────────────────────────────────────────────────"); println!(); println!(" 1. Run Tor manually to see live error output:"); - println!(" {} -f {}", tor_bin, torrc_path); + println!(" {tor_bin} -f {torrc_path}"); println!(); println!(" 2. Common causes:"); println!(); @@ -500,7 +497,7 @@ fn print_diagnosis_hints(torrc_path: &str, tor_bin: &str, bind_port: u16) { println!(" 3. If Tor works but you want to manage it yourself:"); println!(" Set enable_tor_support = false in settings.toml"); println!(" and add to your own torrc:"); - println!(" HiddenServicePort 80 127.0.0.1:{}", bind_port); + println!(" HiddenServicePort 80 127.0.0.1:{bind_port}"); println!(" ────────────────────────────────────────────────────────────────────────"); println!(); } @@ -511,7 +508,7 @@ fn print_torrc_hint(hs_dir: &Path, bind_port: u16) { println!(" SocksPort 0"); println!(" DataDirectory /var/lib/tor/rustchan-data/"); println!(" HiddenServiceDir {}", hs_dir.display()); - println!(" HiddenServicePort 80 127.0.0.1:{}", bind_port); + println!(" HiddenServicePort 80 127.0.0.1:{bind_port}"); println!( " Your .onion address will appear in: {}/hostname", hs_dir.display() diff --git a/src/error.rs b/src/error.rs index eebb5c0..b424f08 100644 --- a/src/error.rs +++ b/src/error.rs @@ -68,41 +68,41 @@ impl From for AppError { if fe.code == rusqlite::ErrorCode::DatabaseBusy || fe.code == rusqlite::ErrorCode::DatabaseLocked { - return AppError::DbBusy; + return Self::DbBusy; } } - AppError::Internal(anyhow::Error::new(e)) + Self::Internal(anyhow::Error::new(e)) } } // Allow ? operator on r2d2::Error impl From for AppError { fn from(e: r2d2::Error) -> Self { - AppError::Internal(anyhow::Error::new(e)) + Self::Internal(anyhow::Error::new(e)) } } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), - AppError::BannedUser { reason, csrf_token } => { + Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + Self::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), + Self::BannedUser { reason, csrf_token } => { let html = crate::templates::ban_page(reason, csrf_token); return (StatusCode::FORBIDDEN, Html(html)).into_response(); } - AppError::UploadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg.clone()), - AppError::InvalidMediaType(msg) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone()), - AppError::RateLimited => ( + Self::UploadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg.clone()), + Self::InvalidMediaType(msg) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg.clone()), + Self::RateLimited => ( StatusCode::TOO_MANY_REQUESTS, "You are posting too fast. Slow down.".to_string(), ), - AppError::DbBusy => ( + Self::DbBusy => ( StatusCode::SERVICE_UNAVAILABLE, "The server is temporarily busy. Please try again in a moment.".to_string(), ), - AppError::Internal(e) => { + Self::Internal(e) => { error!("Internal error: {:?}", e); ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index 8d8aa29..f1b3068 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -26,18 +26,22 @@ use crate::{ }; 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 once_cell::sync::Lazy; +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}; @@ -54,8 +58,8 @@ const SESSION_COOKIE: &str = "chan_admin_session"; 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: Lazy> = Lazy::new(DashMap::new); +/// `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 { @@ -87,17 +91,19 @@ fn is_login_locked(ip_key: &str) -> bool { /// 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 entry = ADMIN_LOGIN_FAILS + let mut binding = ADMIN_LOGIN_FAILS .entry(ip_key.to_string()) .or_insert((0, now)); - let (count, window_start) = entry.value_mut(); + let (count, window_start) = binding.value_mut(); if now.saturating_sub(*window_start) > LOGIN_FAIL_WINDOW { *count = 1; *window_start = now; } else { *count += 1; } - *count + let result = *count; + drop(binding); + result } /// Clear failure counter after a successful login. @@ -115,9 +121,17 @@ fn clear_login_fails(ip_key: &str) { } } -/// Verify admin session. Returns the admin_id if valid. +/// 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. +/// `spawn_blocking` closure or synchronous (non-async) context. #[allow(dead_code)] fn require_admin_sync(jar: &CookieJar, pool: &DbPool) -> Result { let session_id = jar @@ -134,7 +148,7 @@ fn require_admin_sync(jar: &CookieJar, pool: &DbPool) -> Result { /// 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. +/// 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() @@ -150,8 +164,7 @@ pub async fn admin_index(State(state): State, jar: CookieJar) -> Resul let conn = pool.get()?; let logged_in = session_id .as_deref() - .map(|sid| db::get_session(&conn, sid).ok().flatten().is_some()) - .unwrap_or(false); + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); let boards = db::get_all_boards(&conn)?; Ok((logged_in, boards)) } @@ -173,9 +186,11 @@ pub async fn admin_index(State(state): State, jar: CookieJar) -> Resul pub struct LoginForm { username: String, password: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } +#[allow(clippy::too_many_lines)] pub async fn admin_login( State(state): State, jar: CookieJar, @@ -194,10 +209,8 @@ pub async fn admin_login( // 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(""), - ) { + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -300,7 +313,7 @@ pub async fn admin_login( // configured session lifetime instead of persisting it indefinitely. cookie.set_max_age(time::Duration::seconds(CONFIG.session_duration)); - info!("Admin {} logged in", admin_id); + info!("Admin {admin_id} logged in"); Ok((jar.add(cookie), Redirect::to("/admin/panel")).into_response()) } } @@ -310,7 +323,8 @@ pub async fn admin_login( #[derive(Deserialize)] pub struct CsrfOnly { - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, return_to: Option, } @@ -321,10 +335,8 @@ pub async fn admin_logout( ) -> 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(""), - ) { + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -367,11 +379,11 @@ pub async fn admin_logout( /// 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. + /// 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. + /// Set by `board_restore` / `restore_saved_board_backup` on failure. pub restore_error: Option, - /// Set by update_site_settings on success. + /// Set by `update_site_settings` on success. pub settings_saved: Option, } @@ -387,7 +399,7 @@ pub async fn admin_panel( // 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))) + 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() { @@ -422,12 +434,26 @@ pub async fn admin_panel( 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(|p| p.to_path_buf()) - .unwrap_or_else(|| std::path::PathBuf::from(".")); + .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() @@ -448,6 +474,7 @@ pub async fn admin_panel( &full_backups, &board_backups_list, db_size_bytes, + db_size_warning, &reports, &appeals, &site_name, @@ -472,7 +499,8 @@ pub struct CreateBoardForm { name: String, description: String, nsfw: Option, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn create_board( @@ -483,10 +511,8 @@ pub async fn create_board( // 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(""), - ) { + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -495,7 +521,7 @@ pub async fn create_board( .trim() .to_lowercase() .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect::(); @@ -518,7 +544,10 @@ pub async fn create_board( 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); + 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(()) } }) @@ -533,7 +562,8 @@ pub async fn create_board( #[derive(Deserialize)] pub struct BoardIdForm { board_id: i64, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn delete_board( @@ -542,7 +572,7 @@ pub async fn delete_board( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let upload_dir = CONFIG.upload_dir.clone(); @@ -586,6 +616,9 @@ pub async fn delete_board( 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(()) } }) @@ -602,7 +635,8 @@ pub struct ThreadActionForm { thread_id: i64, board: String, action: String, // "sticky", "unsticky", "lock", "unlock" - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn thread_action( @@ -611,7 +645,7 @@ pub async fn thread_action( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; // Validate action before spawning to give early error match form.action.as_str() { @@ -646,7 +680,7 @@ pub async fn thread_action( &board_for_log, "", ); - info!("Admin {} thread {}", action, thread_id); + info!("Admin {action} thread {thread_id}"); Ok(()) } }) @@ -671,7 +705,7 @@ pub async fn thread_action( let safe: String = form .board .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect(); Ok(safe) @@ -681,9 +715,9 @@ pub async fn thread_action( // After archiving, send to the board archive; for all other actions // stay on the thread. if form.action == "archive" { - format!("/{}/archive", board_name) + format!("/{board_name}/archive") } else { - format!("/{}/thread/{}", board_name, form.thread_id) + format!("/{board_name}/thread/{}", form.thread_id) } }; @@ -696,7 +730,8 @@ pub async fn thread_action( pub struct AdminDeletePostForm { post_id: i64, board: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn admin_delete_post( @@ -705,7 +740,7 @@ pub async fn admin_delete_post( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let upload_dir = CONFIG.upload_dir.clone(); let post_id = form.post_id; @@ -725,14 +760,16 @@ pub async fn admin_delete_post( let board_name = db::get_all_boards(&conn)? .into_iter() .find(|b| b.id == post.board_id) - .map(|b| b.short_name) - .unwrap_or_else(|| { - form.board - .chars() - .filter(|c| c.is_ascii_alphanumeric()) - .take(8) - .collect() - }); + .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; @@ -762,13 +799,13 @@ pub async fn admin_delete_post( &board_name, &post.body.chars().take(80).collect::(), ); - info!("Admin deleted post {}", post_id); + 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)) + Ok(format!("/{board_name}")) } else { - Ok(format!("/{}/thread/{}", board_name, thread_id)) + Ok(format!("/{board_name}/thread/{thread_id}")) } } }) @@ -784,7 +821,8 @@ pub async fn admin_delete_post( pub struct AdminDeleteThreadForm { thread_id: i64, board: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn admin_delete_thread( @@ -793,7 +831,7 @@ pub async fn admin_delete_thread( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let upload_dir = CONFIG.upload_dir.clone(); let thread_id = form.thread_id; @@ -818,7 +856,7 @@ pub async fn admin_delete_thread( .unwrap_or_else(|| { form.board .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect() }); @@ -838,14 +876,14 @@ pub async fn admin_delete_thread( &board_name, "", ); - info!("Admin deleted thread {}", thread_id); + 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()) + Ok(Redirect::to(&format!("/{redirect_board}")).into_response()) } // ─── POST /admin/ban/add ────────────────────────────────────────────────────── @@ -855,7 +893,8 @@ pub struct AddBanForm { ip_hash: String, reason: String, duration_hours: Option, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn add_ban( @@ -864,7 +903,7 @@ pub async fn add_ban( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let expires_at = form .duration_hours @@ -892,7 +931,7 @@ pub async fn add_ban( "", &format!("ip_hash={}… reason={}", &ip_hash_log, form.reason), ); - info!("Admin added ban for ip_hash {}…", ip_hash_log); + info!("Admin added ban for ip_hash {ip_hash_log}…"); Ok(()) } }) @@ -907,7 +946,8 @@ pub async fn add_ban( #[derive(Deserialize)] pub struct BanIdForm { ban_id: i64, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn remove_ban( @@ -916,7 +956,7 @@ pub async fn remove_ban( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -947,7 +987,8 @@ pub struct BanDeleteForm { is_op: Option, reason: Option, duration_hours: Option, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn admin_ban_and_delete( @@ -956,7 +997,7 @@ pub async fn admin_ban_and_delete( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let reason = form .reason @@ -983,6 +1024,13 @@ pub async fn admin_ban_and_delete( 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( @@ -993,7 +1041,7 @@ pub async fn admin_ban_and_delete( "ban", None, &board_short, - &format!("inline ban — ip_hash={}… reason={}", &ip_hash_log, reason), + &format!("inline ban — ip_hash={reason}… reason={}", &ip_hash_log), ); // Delete post (or whole thread if OP) @@ -1029,10 +1077,7 @@ pub async fn admin_ban_and_delete( ); } - info!( - "Admin ban+delete: post={} ip_hash={}… board={}", - post_id, ip_hash_log, board_short - ); + info!("Admin ban+delete: post={post_id} ip_hash={ip_hash_log}… board={board_short}"); Ok(()) } }) @@ -1044,14 +1089,14 @@ pub async fn admin_ban_and_delete( let safe_board: String = form .board .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .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) + format!("/{safe_board}") } else { - format!("/{}/thread/{}#p{}", safe_board, thread_id, post_id) + format!("/{safe_board}/thread/{thread_id}#p{post_id}") }; Ok(Redirect::to(&redirect).into_response()) } @@ -1062,7 +1107,8 @@ pub async fn admin_ban_and_delete( pub struct AppealActionForm { appeal_id: i64, ip_hash: Option, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn dismiss_appeal( @@ -1071,7 +1117,7 @@ pub async fn dismiss_appeal( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -1096,7 +1142,7 @@ pub async fn accept_appeal( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -1129,7 +1175,8 @@ pub async fn accept_appeal( pub struct AddFilterForm { pattern: String, replacement: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn add_filter( @@ -1138,7 +1185,7 @@ pub async fn add_filter( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; if form.pattern.trim().is_empty() { return Err(AppError::BadRequest("Pattern cannot be empty.".into())); @@ -1172,7 +1219,8 @@ pub async fn add_filter( #[derive(Deserialize)] pub struct FilterIdForm { filter_id: i64, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn remove_filter( @@ -1181,7 +1229,7 @@ pub async fn remove_filter( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -1218,7 +1266,8 @@ pub struct BoardSettingsForm { allow_video_embeds: Option, allow_captcha: Option, post_cooldown_secs: Option, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn update_board_settings( @@ -1227,7 +1276,7 @@ pub async fn update_board_settings( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let bump_limit = form .bump_limit @@ -1288,7 +1337,8 @@ pub async fn update_board_settings( form.allow_captcha.as_deref() == Some("1"), post_cooldown_secs, )?; - info!("Admin updated settings for board id={}", board_id); + info!("Admin updated settings for board id={board_id}"); + crate::templates::set_live_boards(db::get_all_boards(&conn)?); Ok(()) } }) @@ -1302,9 +1352,9 @@ pub async fn update_board_settings( /// 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 +/// 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. +/// The response body is streamed from disk in 64 KiB chunks via `ReaderStream`. pub async fn admin_backup(State(state): State, jar: CookieJar) -> Result { let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); @@ -1319,15 +1369,15 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu progress.reset(crate::middleware::backup_phase::SNAPSHOT_DB); let temp_dir = std::env::temp_dir(); - let tmp_id = uuid::Uuid::new_v4().to_string().replace('-', ""); - let temp_db = temp_dir.join(format!("chan_backup_{}.db", tmp_id)); + let tmp_id = uuid::Uuid::new_v4().simple().to_string(); + let temp_db = temp_dir.join(format!("chan_backup_{tmp_id}.db")); let temp_db_str = temp_db .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Temp path is non-UTF-8")))? .replace('\'', "''"); - conn.execute_batch(&format!("VACUUM INTO '{}'", temp_db_str)) - .map_err(|e| AppError::Internal(anyhow::anyhow!("VACUUM INTO failed: {}", e)))?; + conn.execute_batch(&format!("VACUUM INTO '{temp_db_str}'")) + .map_err(|e| AppError::Internal(anyhow::anyhow!("VACUUM INTO failed: {e}")))?; drop(conn); // Count files for progress bar before compressing. @@ -1341,11 +1391,11 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu // MEM-FIX: write zip directly to a NamedTempFile instead of Vec. let zip_tmp = tempfile::NamedTempFile::new() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {e}")))?; { let out_file = std::io::BufWriter::new(zip_tmp.as_file().try_clone().map_err(|e| { - AppError::Internal(anyhow::anyhow!("Clone temp file handle: {}", e)) + AppError::Internal(anyhow::anyhow!("Clone temp file handle: {e}")) })?); let mut zip = zip::ZipWriter::new(out_file); let opts = zip::write::SimpleFileOptions::default() @@ -1358,11 +1408,11 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu // ── Database snapshot (streamed, not read into RAM) ──────── zip.start_file("chan.db", opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip DB entry: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip DB entry: {e}")))?; let mut db_src = std::fs::File::open(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Open DB snapshot: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open DB snapshot: {e}")))?; let copied = std::io::copy(&mut db_src, &mut zip) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {e}")))?; drop(db_src); let _ = std::fs::remove_file(&temp_db); progress.files_done.fetch_add(1, Ordering::Relaxed); @@ -1373,8 +1423,16 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu add_dir_to_zip(&mut zip, uploads_base, uploads_base, opts, &progress)?; } - zip.finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; + // Flush the BufWriter explicitly so I/O errors are not + // silently swallowed by the implicit Drop-flush. + let writer = zip + .finish() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {e}")))?; + writer + .into_inner() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {e}")))? + .sync_all() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {e}")))?; } let file_size = zip_tmp.as_file().metadata().map(|m| m.len()).unwrap_or(0); @@ -1384,10 +1442,10 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu let (_, tmp_path_obj) = zip_tmp.into_parts(); let final_path = tmp_path_obj .keep() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Persist temp zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Persist temp zip: {e}")))?; let ts = Utc::now().format("%Y%m%d_%H%M%S"); - let fname = format!("rustchan-backup-{}.zip", ts); + let fname = format!("rustchan-backup-{ts}.zip"); info!("Admin downloaded full backup ({} bytes on disk)", file_size); progress .phase @@ -1401,7 +1459,7 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu // MEM-FIX: Stream the zip file from disk in chunks — never load it all into heap. let file = tokio::fs::File::open(&tmp_path) .await - .map_err(|e| AppError::Internal(anyhow::anyhow!("Open backup for streaming: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open backup for streaming: {e}")))?; let stream = ReaderStream::new(file); let body = axum::body::Body::from_stream(stream); @@ -1412,8 +1470,7 @@ pub async fn admin_backup(State(state): State, jar: CookieJar) -> Resu let _ = tokio::fs::remove_file(cleanup_path).await; }); - use axum::http::header; - let disposition = format!("attachment; filename=\"{}\"", filename); + let disposition = format!("attachment; filename=\"{filename}\""); Ok(( [ (header::CONTENT_TYPE, "application/zip".to_string()), @@ -1426,7 +1483,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. +/// Used to initialise the progress bar's `files_total` before compression starts. fn count_files_in_dir(dir: &std::path::Path) -> u64 { if !dir.is_dir() { return 0; @@ -1448,11 +1505,11 @@ fn count_files_in_dir(dir: &std::path::Path) -> u64 { /// Recursively add every file under `dir` into the zip as `uploads/{rel_path}`. /// -/// MEM-FIX: Uses std::io::copy with the zip writer directly, streaming each +/// MEM-FIX: Uses `std::io::copy` with the zip writer directly, streaming each /// file through a kernel buffer (~8 KiB) instead of reading the whole file -/// into a Vec first. Peak RAM per file = io::copy's 8 KiB stack buffer. +/// into a Vec first. Peak RAM per file = `io::copy`'s 8 KiB stack buffer. /// -/// Progress tracking: increments progress.files_done and progress.bytes_done +/// Progress tracking: increments `progress.files_done` and `progress.bytes_done` /// after each file is written to the zip. fn add_dir_to_zip( zip: &mut zip::ZipWriter, @@ -1465,18 +1522,18 @@ fn add_dir_to_zip( .map_err(|e| AppError::Internal(anyhow::anyhow!("read_dir {}: {}", dir.display(), e)))?; for entry in entries { - let entry = entry.map_err(|e| AppError::Internal(anyhow::anyhow!("dir entry: {}", e)))?; + let entry = entry.map_err(|e| AppError::Internal(anyhow::anyhow!("dir entry: {e}")))?; let path = entry.path(); let relative = path .strip_prefix(base) - .map_err(|e| AppError::Internal(anyhow::anyhow!("strip_prefix: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("strip_prefix: {e}")))?; let rel_str = relative.to_string_lossy().replace('\\', "/"); - let zip_path = format!("uploads/{}", rel_str); + let zip_path = format!("uploads/{rel_str}"); if path.is_dir() { zip.add_directory(&zip_path, opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("zip dir: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("zip dir: {e}")))?; add_dir_to_zip(zip, base, &path, opts, progress)?; } else if path.is_file() { // MEM-FIX: open file, stream through io::copy — no Vec allocation. @@ -1484,7 +1541,7 @@ fn add_dir_to_zip( AppError::Internal(anyhow::anyhow!("open {}: {}", path.display(), e)) })?; zip.start_file(&zip_path, opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("zip file entry: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("zip file entry: {e}")))?; let copied = std::io::copy(&mut src, zip).map_err(|e| { AppError::Internal(anyhow::anyhow!("copy {} to zip: {}", path.display(), e)) })?; @@ -1499,19 +1556,19 @@ fn add_dir_to_zip( /// Replace the live database with the contents of a backup zip. /// -/// Design — why we use SQLite's backup API instead of swapping files: +/// Design — why we use `SQLite`'s backup API instead of swapping files: /// -/// The r2d2 pool keeps up to 8 SQLite connections open permanently. On +/// The r2d2 pool keeps up to 8 `SQLite` connections open permanently. On /// Linux, renaming a new file over chan.db does NOT update the connections /// already open — they still hold file descriptors to the old inode. File- /// swapping therefore leaves the pool reading stale data until the process /// restarts, and deleting the WAL while live connections are active can /// corrupt the database. /// -/// `rusqlite::backup::Backup` wraps SQLite's sqlite3_backup_init() API, +/// `rusqlite::backup::Backup` wraps `SQLite`'s `sqlite3_backup_init()` API, /// which copies data directly into the destination connection's live file — /// through the WAL, through the same file descriptors, safely. After -/// run_to_completion() returns, every connection in the pool immediately +/// `run_to_completion()` returns, every connection in the pool immediately /// sees the restored data. No file swapping, no WAL deletion, no restart /// required. /// @@ -1523,6 +1580,7 @@ fn add_dir_to_zip( /// is silently ignored. /// • 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)] pub async fn admin_restore( State(state): State, jar: CookieJar, @@ -1539,7 +1597,7 @@ pub async fn admin_restore( while let Some(field) = multipart .next_field() .await - .map_err(|e| AppError::BadRequest(format!("Multipart error: {}", e)))? + .map_err(|e| AppError::BadRequest(format!("Multipart error: {e}")))? { match field.name() { Some("_csrf") => { @@ -1551,15 +1609,14 @@ pub async fn admin_restore( ); } Some("backup_file") => { - use tokio::io::AsyncWriteExt as _; let tmp = tempfile::NamedTempFile::new() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Tempfile: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Tempfile: {e}")))?; // Clone the underlying fd for async writing; the original // NamedTempFile retains ownership and the delete-on-drop guard. let std_clone = tmp .as_file() .try_clone() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Clone fd: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Clone fd: {e}")))?; let async_file = tokio::fs::File::from_std(std_clone); let mut writer = tokio::io::BufWriter::new(async_file); let mut field = field; @@ -1571,12 +1628,12 @@ pub async fn admin_restore( writer .write_all(&chunk) .await - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write chunk: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write chunk: {e}")))?; } writer .flush() .await - .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush: {e}")))?; zip_tmp = Some(tmp); } _ => { @@ -1598,7 +1655,7 @@ pub async fn admin_restore( let zip_size = zip_tmp .as_file() .seek(std::io::SeekFrom::End(0)) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Seek check: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Seek check: {e}")))?; if zip_size == 0 { return Err(AppError::BadRequest( "Uploaded backup file is empty.".into(), @@ -1623,9 +1680,9 @@ pub async fn admin_restore( // so ZipArchive can navigate entries without loading into RAM. let zip_file = zip_tmp .reopen() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen zip: {e}")))?; let mut archive = zip::ZipArchive::new(std::io::BufReader::new(zip_file)) - .map_err(|e| AppError::BadRequest(format!("Invalid zip: {}", e)))?; + .map_err(|e| AppError::BadRequest(format!("Invalid zip: {e}")))?; // Quick pre-flight: make sure there is a chan.db entry. // file_names() is a stable iterator available in zip 2+ and zip 8+. @@ -1638,26 +1695,26 @@ pub async fn admin_restore( // ── Single-pass extraction ──────────────────────────────────── let temp_dir = std::env::temp_dir(); - let tmp_id = uuid::Uuid::new_v4().to_string().replace('-', ""); - let temp_db = temp_dir.join(format!("chan_restore_{}.db", tmp_id)); + let tmp_id = uuid::Uuid::new_v4().simple().to_string(); + let temp_db = temp_dir.join(format!("chan_restore_{tmp_id}.db")); let mut db_extracted = false; for i in 0..archive.len() { let mut entry = archive.by_index(i) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip read [{}]: {}", i, e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip read [{i}]: {e}")))?; let name = entry.name().to_string(); // Security: skip any path-traversal attempts. if name.contains("..") || name.starts_with('/') || name.starts_with('\\') { - warn!("Restore: skipping suspicious zip entry '{}'", name); + warn!("Restore: skipping suspicious zip entry '{name}'"); continue; } if name == "chan.db" { let mut out = std::fs::File::create(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp DB: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp DB: {e}")))?; copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write temp DB: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write temp DB: {e}")))?; db_extracted = true; } else if let Some(rel) = name.strip_prefix("uploads/") { @@ -1670,7 +1727,7 @@ pub async fn admin_restore( } else { if let Some(parent) = target.parent() { std::fs::create_dir_all(parent) - .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir parent: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir parent: {e}")))?; } let mut out = std::fs::File::create(&target) .map_err(|e| AppError::Internal(anyhow::anyhow!("Create {}: {}", target.display(), e)))?; @@ -1690,12 +1747,11 @@ pub async fn admin_restore( // ── SQLite backup API: copy temp DB → live DB ───────────────── let backup_result = (|| -> Result<()> { let src = rusqlite::Connection::open(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Open backup source: {}", e)))?; - use rusqlite::backup::Backup; - let backup = Backup::new(&src, &mut live_conn) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup init: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open backup source: {e}")))?; + let backup = Backup::new(&src, &mut live_conn) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup init: {e}")))?; backup.run_to_completion(100, std::time::Duration::from_millis(0), None) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup copy: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup copy: {e}")))?; Ok(()) })(); @@ -1716,13 +1772,17 @@ pub async fn admin_restore( let fresh_sid = new_session_id(); let expires_at = Utc::now().timestamp() + CONFIG.session_duration; match db::create_session(&live_conn, &fresh_sid, admin_id, expires_at) { - Ok(_) => { - info!("Admin restore completed; new session issued for admin_id={}", admin_id); + Ok(()) => { + info!("Admin restore completed; new session issued for admin_id={admin_id}"); + // Refresh live board list — the restored DB may have + // different boards than what was running before. + if let Ok(boards) = db::get_all_boards(&live_conn) { + crate::templates::set_live_boards(boards); + } Ok(fresh_sid) } Err(e) => { warn!("Restore: could not create new session (admin_id={} may not exist in backup): {}", admin_id, e); - // Return empty string as a sentinel — handler will send to login. Ok(String::new()) } } @@ -1756,9 +1816,122 @@ pub async fn admin_restore( // 1 KiB zip (a "zip bomb") can expand to gigabytes, exhausting disk or memory. // copy_limited() caps the decompressed size of each entry. -/// Maximum bytes to extract from any single zip entry. -/// Set to 16 GiB — these are admin-only restore endpoints, so individual -/// entries (large videos, the SQLite DB) can legitimately be several GiB. +// Maximum bytes to extract from any single zip entry. +// Set to 16 GiB — these are admin-only restore endpoints, so individual +// entries (large videos, the SQLite DB) can legitimately be several GiB. +// ─── Quotelink ID remapping ─────────────────────────────────────────────────── +// +// When a board backup is restored, posts receive new auto-incremented IDs +// because other boards' posts already occupy the original IDs in the global +// `posts` table. `remap_body_quotelinks` and `remap_body_html_quotelinks` +// rewrite the raw text and rendered HTML of each restored post so that in-board +// quotelinks point to the new IDs instead of the now-stale original ones. +// +// Design constraints: +// +// 1. ONLY same-board links are remapped. Cross-board references (`>>>/b/N`) +// point to other boards whose IDs are unchanged by this restore operation +// and must not be altered. +// +// 2. `pairs` must be sorted by old-ID string length *descending* before being +// passed to these functions. This prevents a shorter ID (e.g. "10") from +// being substituted as a prefix of a longer one ("1000") before the longer +// match has a chance to fire. Example: +// pairs = [("1000","2500"), ("100","800"), ("10","50"), ("1","3")] +// Processing "1000" before "100" prevents "1000" → "8000" (wrong first). +// +// 3. `body` stores the original markdown-like text the user typed. In-board +// quotelinks appear as `>>{old_id}` (e.g. `>>500`). A regex-free approach +// is used: for each (old, new) pair, replace `>>{old}` followed by a +// non-digit (or end-of-string) to avoid `>>100` matching inside `>>1000`. +// +// 4. `body_html` stores pre-rendered HTML. In-board quotelinks look like: +// >>{N} +// Cross-board quotelinks look like: +// >>>/… +// We replace `href="#p{old}"` (exclusively in-board) and +// `data-pid="{old}">>>{old}` (display text also exclusive to +// in-board links — cross-board display text has `>>>/` prefix). + +// Rewrite in-board `>>{old_id}` references in the raw post body. +// `pairs` must be pre-sorted by old-ID string length descending. +fn remap_body_quotelinks(body: &str, pairs: &[(String, String)]) -> String { + // Avoid cloning when there is nothing to change. + if pairs.is_empty() { + return body.to_string(); + } + let mut result = body.to_string(); + for (old, new) in pairs { + // Match `>>{old}` only when NOT immediately followed by another digit, + // so we don't turn `>>1000` into `>>new_id_for_1000` when processing + // the `>>100` entry first. + // + // Implementation: scan for every occurrence of `>>{old}` in the string + // and check the next character. Replace left-to-right using byte indices + // to avoid re-scanning already-replaced sections. + let needle = format!(">>{old}"); + let mut out = String::with_capacity(result.len()); + let mut pos = 0; + let bytes = result.as_bytes(); + while pos < bytes.len() { + match result[pos..].find(&needle) { + None => { + out.push_str(&result[pos..]); + break; + } + Some(rel) => { + let abs = pos + rel; + let after = abs + needle.len(); + // Only replace when the char after the match is not a digit. + let next_is_digit = bytes.get(after).is_some_and(u8::is_ascii_digit); + out.push_str(&result[pos..abs]); + if next_is_digit { + // Not the right match — keep the original text. + out.push_str(&needle); + } else { + out.push_str(">>"); + out.push_str(new); + } + pos = after; + } + } + } + result = out; + } + result +} + +/// Rewrite in-board quotelink IDs in pre-rendered `body_html`. +/// +/// Targets two patterns that are exclusive to same-board quotelinks: +/// • `href="#p{old}"` — the anchor href +/// • `data-pid="{old}">>>{old}` — the data attribute + display text +/// +/// Cross-board links use `href="/board/post/{N}"` and display `>>>/…` +/// so neither pattern matches them. +/// +/// `pairs` must be pre-sorted by old-ID string length descending. +fn remap_body_html_quotelinks(body_html: &str, pairs: &[(String, String)]) -> String { + if pairs.is_empty() { + return body_html.to_string(); + } + let mut result = body_html.to_string(); + for (old, new) in pairs { + // Pattern 1: href="#p{old}" → href="#p{new}" + let old_href = format!("href=\"#p{old}\""); + let new_href = format!("href=\"#p{new}\""); + result = result.replace(&old_href, &new_href); + + // Pattern 2: data-pid="{old}">>>{old} + // This uniquely identifies in-board quotelinks: cross-board links have + // ">>>/" as their display text, never bare ">>{N}". + let old_tail = format!("data-pid=\"{old}\">>>{old}"); + let new_tail = format!("data-pid=\"{new}\">>>{new}"); + result = result.replace(&old_tail, &new_tail); + } + result +} + const ZIP_ENTRY_MAX_BYTES: u64 = 16 * 1024 * 1024 * 1024; /// Like `std::io::copy` but returns `InvalidData` if more than `max_bytes` @@ -1769,7 +1942,7 @@ fn copy_limited( writer: &mut W, max_bytes: u64, ) -> std::io::Result { - let mut buf = [0u8; 65536]; + let mut buf = vec![0u8; 65536]; let mut total: u64 = 0; loop { let n = reader.read(&mut buf)?; @@ -1797,7 +1970,7 @@ 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. +/// For use inside `spawn_blocking` closures. fn require_admin_session_with_name( conn: &rusqlite::Connection, session_id: Option<&str>, @@ -1809,15 +1982,15 @@ fn require_admin_session_with_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("")) { - Err(AppError::Forbidden("CSRF token mismatch.".into())) - } else { + 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. +/// 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)? @@ -1831,8 +2004,7 @@ fn require_admin_session_sid(conn: &rusqlite::Connection, session_id: Option<&st fn db_dir() -> PathBuf { PathBuf::from(&CONFIG.database_path) .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")) + .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf) } /// rustchan-data/full-backups/ @@ -1857,7 +2029,7 @@ fn list_backup_files(dir: &std::path::Path) -> Vec { if let (Some(name), Ok(meta)) = ( path.file_name() .and_then(|n| n.to_str()) - .map(|s| s.to_string()), + .map(ToString::to_string), std::fs::metadata(&path), ) { let modified = meta @@ -1865,7 +2037,7 @@ fn list_backup_files(dir: &std::path::Path) -> Vec { .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| { - let secs = d.as_secs() as i64; + let secs = d.as_secs().cast_signed(); #[allow(deprecated)] chrono::DateTime::::from_timestamp(secs, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) @@ -1890,7 +2062,7 @@ fn list_backup_files(dir: &std::path::Path) -> Vec { /// Create a full backup and save it to rustchan-data/full-backups/. /// /// MEM-FIX: The zip is written directly to the final destination file via a -/// BufWriter, so peak RAM usage is O(compression-buffer) not O(zip-size). +/// `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. pub async fn create_full_backup( @@ -1899,7 +2071,7 @@ pub async fn create_full_backup( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let upload_dir = CONFIG.upload_dir.clone(); let progress = state.backup_progress.clone(); @@ -1914,15 +2086,15 @@ pub async fn create_full_backup( // VACUUM INTO for a consistent snapshot. let temp_dir = std::env::temp_dir(); - let tmp_id = uuid::Uuid::new_v4().to_string().replace('-', ""); - let temp_db = temp_dir.join(format!("chan_backup_{}.db", tmp_id)); + let tmp_id = uuid::Uuid::new_v4().simple().to_string(); + let temp_db = temp_dir.join(format!("chan_backup_{tmp_id}.db")); let temp_db_str = temp_db .to_str() .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Temp path non-UTF-8")))? .replace('\'', "''"); - conn.execute_batch(&format!("VACUUM INTO '{}'", temp_db_str)) - .map_err(|e| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {}", e)))?; + conn.execute_batch(&format!("VACUUM INTO '{temp_db_str}'")) + .map_err(|e| AppError::Internal(anyhow::anyhow!("VACUUM INTO: {e}")))?; drop(conn); // Count files for progress bar before compressing. @@ -1935,19 +2107,18 @@ pub async fn create_full_backup( // MEM-FIX: write zip directly to a .tmp file on disk, not a Vec. let backup_dir = full_backup_dir(); - std::fs::create_dir_all(&backup_dir).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create full-backups dir: {}", e)) - })?; + std::fs::create_dir_all(&backup_dir) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create full-backups dir: {e}")))?; let ts = Utc::now().format("%Y%m%d_%H%M%S"); - let fname = format!("rustchan-backup-{}.zip", ts); + let fname = format!("rustchan-backup-{ts}.zip"); let final_path = backup_dir.join(&fname); - let tmp_path = backup_dir.join(format!("{}.tmp", fname)); + let tmp_path = backup_dir.join(format!("{fname}.tmp")); { - let out_file = - std::io::BufWriter::new(std::fs::File::create(&tmp_path).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create zip tmp: {}", e)) - })?); + let out_file = std::io::BufWriter::new( + std::fs::File::create(&tmp_path) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create zip tmp: {e}")))?, + ); let mut zip = zip::ZipWriter::new(out_file); let opts = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); @@ -1959,11 +2130,11 @@ pub async fn create_full_backup( // ── Database snapshot (streamed, not read into RAM) ──────── zip.start_file("chan.db", opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip DB: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip DB: {e}")))?; let mut db_src = std::fs::File::open(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Open DB snapshot: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open DB snapshot: {e}")))?; let copied = std::io::copy(&mut db_src, &mut zip) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Stream DB to zip: {e}")))?; drop(db_src); let _ = std::fs::remove_file(&temp_db); progress.files_done.fetch_add(1, Ordering::Relaxed); @@ -1976,19 +2147,19 @@ pub async fn create_full_backup( let writer = zip .finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {e}")))?; // Flush the BufWriter so the OS buffer is committed to disk. writer .into_inner() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {}", e)))? + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {e}")))? .sync_all() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {e}")))?; } // Atomic rename: only becomes visible in the list when complete. std::fs::rename(&tmp_path, &final_path).map_err(|e| { let _ = std::fs::remove_file(&tmp_path); - AppError::Internal(anyhow::anyhow!("Rename backup: {}", e)) + AppError::Internal(anyhow::anyhow!("Rename backup: {e}")) })?; let size = std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0); @@ -2010,22 +2181,24 @@ pub async fn create_full_backup( #[derive(Deserialize)] pub struct BoardBackupCreateForm { board_short: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } /// Create a board backup and save it to rustchan-data/board-backups/. +#[allow(clippy::too_many_lines)] pub async fn create_board_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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let board_short = form .board_short .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect::(); if board_short.is_empty() { @@ -2038,8 +2211,7 @@ pub async fn create_board_backup( tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result<()> { - use board_backup_types::*; - use rusqlite::params; + 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())?; @@ -2075,7 +2247,7 @@ pub async fn create_board_backup( }) }, ) - .map_err(|_| AppError::NotFound(format!("Board '{}' not found", board_short)))?; + .map_err(|_| AppError::NotFound(format!("Board '{board_short}' not found")))?; let board_id = board.id; @@ -2253,17 +2425,17 @@ pub async fn create_board_backup( file_hashes, }; let manifest_json = serde_json::to_vec_pretty(&manifest) - .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON: {e}")))?; // MEM-FIX: write zip directly to a .tmp file on disk, not a Vec. let backup_dir = board_backup_dir(); std::fs::create_dir_all(&backup_dir).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create board-backups dir: {}", e)) + AppError::Internal(anyhow::anyhow!("Create board-backups dir: {e}")) })?; let ts = Utc::now().format("%Y%m%d_%H%M%S"); - let fname = format!("rustchan-board-{}-{}.zip", board_short, ts); + let fname = format!("rustchan-board-{board_short}-{ts}.zip"); let final_path = backup_dir.join(&fname); - let tmp_path = backup_dir.join(format!("{}.tmp", fname)); + let tmp_path = backup_dir.join(format!("{fname}.tmp")); let uploads_base = std::path::Path::new(&upload_dir); let board_upload_path = uploads_base.join(&board_short); @@ -2275,7 +2447,7 @@ pub async fn create_board_backup( { let out_file = std::io::BufWriter::new( std::fs::File::create(&tmp_path).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create zip tmp: {}", e)) + AppError::Internal(anyhow::anyhow!("Create zip tmp: {e}")) })?, ); let mut zip = zip::ZipWriter::new(out_file); @@ -2283,9 +2455,9 @@ pub async fn create_board_backup( .compression_method(zip::CompressionMethod::Deflated); zip.start_file("board.json", opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip manifest: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip manifest: {e}")))?; zip.write_all(&manifest_json) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write manifest: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write manifest: {e}")))?; progress.files_done.fetch_add(1, Ordering::Relaxed); progress.bytes_done.fetch_add(manifest_json.len() as u64, Ordering::Relaxed); @@ -2295,17 +2467,17 @@ pub async fn create_board_backup( let writer = zip .finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {e}")))?; writer .into_inner() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {}", e)))? + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {e}")))? .sync_all() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {e}")))?; } std::fs::rename(&tmp_path, &final_path).map_err(|e| { let _ = std::fs::remove_file(&tmp_path); - AppError::Internal(anyhow::anyhow!("Rename board backup: {}", e)) + AppError::Internal(anyhow::anyhow!("Rename board backup: {e}")) })?; let size = std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0); @@ -2329,7 +2501,7 @@ pub async fn create_board_backup( /// response. For a 5 GiB backup on a slow connection that means 5 GiB of /// heap held for the entire download duration. /// -/// The fix: open a tokio::fs::File and wrap it in a ReaderStream so Axum +/// The fix: open a `tokio::fs::File` and wrap it in a `ReaderStream` so Axum /// sends the data in 64 KiB chunks pulled directly from the OS page cache. /// Peak heap = one 64 KiB chunk; the rest stays on disk. pub async fn download_backup( @@ -2359,7 +2531,10 @@ pub async fn download_backup( if safe_filename != filename || safe_filename.contains("..") { return Err(AppError::BadRequest("Invalid filename.".into())); } - if !safe_filename.ends_with(".zip") { + if !std::path::Path::new(&safe_filename) + .extension() + .is_some_and(|e: &std::ffi::OsStr| e.eq_ignore_ascii_case("zip")) + { return Err(AppError::BadRequest( "Only .zip files can be downloaded.".into(), )); @@ -2386,8 +2561,7 @@ pub async fn download_backup( let stream = ReaderStream::new(file); let body = axum::body::Body::from_stream(stream); - use axum::http::header; - let disposition = format!("attachment; filename=\"{}\"", safe_filename); + let disposition = format!("attachment; filename=\"{safe_filename}\""); Ok(( [ (header::CONTENT_TYPE, "application/zip".to_string()), @@ -2403,10 +2577,10 @@ pub async fn download_backup( /// Return current backup progress as JSON. Polled by the admin panel JS. /// -/// Response: { phase: u64, files_done: u64, files_total: u64, -/// bytes_done: u64, bytes_total: u64 } +/// Response: { phase: u64, `files_done`: u64, `files_total`: u64, +/// `bytes_done`: u64, `bytes_total`: u64 } /// -/// phase codes: 0=idle, 1=snapshot_db, 2=count_files, 3=compress, 4=save, 5=done +/// phase codes: `0=idle`, `1=snapshot_db`, `2=count_files`, `3=compress`, `4=save`, `5=done` /// /// Auth is required to prevent any guest from watching backup progress. pub async fn backup_progress_json( @@ -2425,18 +2599,16 @@ pub async fn backup_progress_json( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - use std::sync::atomic::Ordering::Relaxed; let p = &state.backup_progress; let json = format!( r#"{{"phase":{},"files_done":{},"files_total":{},"bytes_done":{},"bytes_total":{}}}"#, - p.phase.load(Relaxed), - p.files_done.load(Relaxed), - p.files_total.load(Relaxed), - p.bytes_done.load(Relaxed), - p.bytes_total.load(Relaxed), + p.phase.load(Ordering::Relaxed), + p.files_done.load(Ordering::Relaxed), + p.files_total.load(Ordering::Relaxed), + p.bytes_done.load(Ordering::Relaxed), + p.bytes_total.load(Ordering::Relaxed), ); - use axum::http::header; Ok(( [(header::CONTENT_TYPE, "application/json".to_string())], json, @@ -2450,7 +2622,8 @@ pub async fn backup_progress_json( pub struct DeleteBackupForm { kind: String, // "full" or "board" filename: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } /// Delete a saved backup file from disk. @@ -2460,7 +2633,7 @@ pub async fn delete_backup( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; // Validate filename. let safe_filename: String = form @@ -2471,7 +2644,10 @@ pub async fn delete_backup( if safe_filename != form.filename || safe_filename.contains("..") { return Err(AppError::BadRequest("Invalid filename.".into())); } - if !safe_filename.ends_with(".zip") { + if !std::path::Path::new(&safe_filename) + .extension() + .is_some_and(|e: &std::ffi::OsStr| e.eq_ignore_ascii_case("zip")) + { return Err(AppError::BadRequest( "Only .zip files can be deleted.".into(), )); @@ -2492,8 +2668,8 @@ pub async fn delete_backup( let path = backup_dir.join(&safe_filename); if path.exists() { std::fs::remove_file(&path) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Delete backup: {}", e)))?; - info!("Admin deleted backup file: {}", safe_filename); + .map_err(|e| AppError::Internal(anyhow::anyhow!("Delete backup: {e}")))?; + info!("Admin deleted backup file: {safe_filename}"); } Ok(()) } @@ -2509,17 +2685,19 @@ pub async fn delete_backup( #[derive(Deserialize)] pub struct RestoreSavedForm { filename: String, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } /// Restore a full backup from a saved file in full-backups/. +#[allow(clippy::too_many_lines)] 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let safe_filename: String = form .filename @@ -2528,7 +2706,9 @@ pub async fn restore_saved_full_backup( .collect(); if safe_filename != form.filename || safe_filename.contains("..") - || !safe_filename.ends_with(".zip") + || !std::path::Path::new(&safe_filename) + .extension() + .is_some_and(|e: &std::ffi::OsStr| e.eq_ignore_ascii_case("zip")) { return Err(AppError::BadRequest("Invalid filename.".into())); } @@ -2547,12 +2727,14 @@ pub async fn restore_saved_full_backup( // 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 zip_bytes = std::fs::read(&path) + // MEM-FIX: open the zip as a seekable BufReader instead of + // reading the whole file into a Vec. The FIX[A3] comment above + // correctly deferred the read to after auth, but std::fs::read still + // loaded the entire zip into heap. A 5 GiB backup would exhaust RAM. + let zip_file = std::fs::File::open(&path) .map_err(|_| AppError::NotFound("Backup file not found.".into()))?; - - let cursor = std::io::Cursor::new(zip_bytes); - let mut archive = zip::ZipArchive::new(cursor) - .map_err(|e| AppError::BadRequest(format!("Invalid zip: {}", e)))?; + let mut archive = zip::ZipArchive::new(std::io::BufReader::new(zip_file)) + .map_err(|e| AppError::BadRequest(format!("Invalid zip: {e}")))?; let has_db = archive.file_names().any(|n| n == "chan.db"); if !has_db { @@ -2562,25 +2744,24 @@ pub async fn restore_saved_full_backup( } let temp_dir = std::env::temp_dir(); - let tmp_id = uuid::Uuid::new_v4().to_string().replace('-', ""); - let temp_db = temp_dir.join(format!("chan_restore_{}.db", tmp_id)); + let tmp_id = uuid::Uuid::new_v4().simple().to_string(); + let temp_db = temp_dir.join(format!("chan_restore_{tmp_id}.db")); let mut db_extracted = false; for i in 0..archive.len() { let mut entry = archive .by_index(i) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip[{}]: {}", i, e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip[{i}]: {e}")))?; let name = entry.name().to_string(); if name.contains("..") || name.starts_with('/') || name.starts_with('\\') { - warn!("Restore-saved: skipping suspicious entry '{}'", name); + warn!("Restore-saved: skipping suspicious entry '{name}'"); continue; } if name == "chan.db" { - let mut out = std::fs::File::create(&temp_db).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Create temp DB: {}", e)) - })?; + let mut out = std::fs::File::create(&temp_db) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp DB: {e}")))?; copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write temp DB: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write temp DB: {e}")))?; db_extracted = true; } else if let Some(rel) = name.strip_prefix("uploads/") { if rel.is_empty() { @@ -2589,11 +2770,11 @@ pub async fn restore_saved_full_backup( let target = PathBuf::from(&upload_dir).join(rel); if entry.is_dir() { std::fs::create_dir_all(&target) - .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir: {e}")))?; } else { if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(|e| { - AppError::Internal(anyhow::anyhow!("mkdir parent: {}", e)) + AppError::Internal(anyhow::anyhow!("mkdir parent: {e}")) })?; } let mut out = std::fs::File::create(&target).map_err(|e| { @@ -2617,13 +2798,12 @@ pub async fn restore_saved_full_backup( let backup_result = (|| -> Result<()> { let src = rusqlite::Connection::open(&temp_db) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Open source: {}", e)))?; - use rusqlite::backup::Backup; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open source: {e}")))?; let backup = Backup::new(&src, &mut live_conn) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup init: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup init: {e}")))?; backup .run_to_completion(100, std::time::Duration::from_millis(0), None) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup copy: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Backup copy: {e}")))?; Ok(()) })(); let _ = std::fs::remove_file(&temp_db); @@ -2632,15 +2812,12 @@ pub async fn restore_saved_full_backup( let fresh_sid = new_session_id(); let expires_at = Utc::now().timestamp() + CONFIG.session_duration; match db::create_session(&live_conn, &fresh_sid, admin_id, expires_at) { - Ok(_) => { - info!( - "Admin restore-saved completed; new session for admin_id={}", - admin_id - ); + Ok(()) => { + info!("Admin restore-saved completed; new session for admin_id={admin_id}"); Ok(fresh_sid) } Err(e) => { - warn!("Restore-saved: could not create session: {}", e); + warn!("Restore-saved: could not create session: {e}"); Ok(String::new()) } } @@ -2667,13 +2844,31 @@ pub async fn restore_saved_full_backup( // ─── POST /admin/board/backup/restore-saved ─────────────────────────────────── /// Restore a board backup from a saved file in board-backups/. +#[allow(clippy::too_many_lines)] pub async fn restore_saved_board_backup( State(state): State, jar: CookieJar, Form(form): Form, ) -> Result { + fn encode_q(s: &str) -> String { + const fn nibble(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + _ => (b'A' + n - 10) as char, + } + } + s.bytes() + .flat_map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![b as char] + } + b' ' => vec!['+'], + b => vec!['%', nibble(b >> 4), nibble(b & 0xf)], + }) + .collect() + } let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); - check_csrf_jar(&jar, form._csrf.as_deref())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; let safe_filename: String = form .filename @@ -2682,7 +2877,9 @@ pub async fn restore_saved_board_backup( .collect(); if safe_filename != form.filename || safe_filename.contains("..") - || !safe_filename.ends_with(".zip") + || !std::path::Path::new(&safe_filename) + .extension() + .is_some_and(|e: &std::ffi::OsStr| e.eq_ignore_ascii_case("zip")) { return Err(AppError::BadRequest("Invalid filename.".into())); } @@ -2695,20 +2892,19 @@ pub async fn restore_saved_board_backup( let board_short_result: Result> = tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result { - use board_backup_types::*; - use rusqlite::params; - use std::collections::HashMap; + use board_backup_types::BoardBackupManifest; + use std::collections::HashMap; 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())?; - let zip_bytes = std::fs::read(&path) + // MEM-FIX: open the zip as BufReader instead of loading the + // entire file into a Vec. Board backups can be hundreds of MB. + let zip_file = std::fs::File::open(&path) .map_err(|_| AppError::NotFound("Backup file not found.".into()))?; - - let cursor = std::io::Cursor::new(zip_bytes); - let mut archive = zip::ZipArchive::new(cursor) - .map_err(|e| AppError::BadRequest(format!("Invalid zip: {}", e)))?; + let mut archive = zip::ZipArchive::new(std::io::BufReader::new(zip_file)) + .map_err(|e| AppError::BadRequest(format!("Invalid zip: {e}")))?; if !archive.file_names().any(|n| n == "board.json") { return Err(AppError::BadRequest( @@ -2719,12 +2915,12 @@ pub async fn restore_saved_board_backup( let manifest: BoardBackupManifest = { let mut entry = archive .by_name("board.json") - .map_err(|e| AppError::Internal(anyhow::anyhow!("Read board.json: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Read board.json: {e}")))?; let mut buf = Vec::new(); std::io::Read::read_to_end(&mut entry, &mut buf) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Read bytes: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Read bytes: {e}")))?; serde_json::from_slice(&buf) - .map_err(|e| AppError::BadRequest(format!("Invalid board.json: {}", e)))? + .map_err(|e| AppError::BadRequest(format!("Invalid board.json: {e}")))? }; let board_short = manifest.board.short_name.clone(); @@ -2743,12 +2939,12 @@ pub async fn restore_saved_board_backup( // DELETE and the first INSERT left the board with zero threads and no way // to recover without manual intervention. conn.execute("BEGIN IMMEDIATE", []) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Begin tx: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Begin tx: {e}")))?; let restore_result = (|| -> Result<()> { let live_board_id: i64 = if let Some(eid) = existing_id { conn.execute("DELETE FROM threads WHERE board_id = ?1", params![eid]) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Clear threads: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Clear threads: {e}")))?; conn.execute( "UPDATE boards SET name=?1, description=?2, nsfw=?3, max_threads=?4, bump_limit=?5, @@ -2759,23 +2955,23 @@ pub async fn restore_saved_board_backup( params![ manifest.board.name, manifest.board.description, - manifest.board.nsfw as i64, + i64::from(manifest.board.nsfw), manifest.board.max_threads, manifest.board.bump_limit, - manifest.board.allow_images as i64, - manifest.board.allow_video as i64, - manifest.board.allow_audio as i64, - manifest.board.allow_tripcodes as i64, + i64::from(manifest.board.allow_images), + i64::from(manifest.board.allow_video), + i64::from(manifest.board.allow_audio), + i64::from(manifest.board.allow_tripcodes), manifest.board.edit_window_secs, - manifest.board.allow_editing as i64, - manifest.board.allow_archive as i64, - manifest.board.allow_video_embeds as i64, - manifest.board.allow_captcha as i64, + i64::from(manifest.board.allow_editing), + i64::from(manifest.board.allow_archive), + i64::from(manifest.board.allow_video_embeds), + i64::from(manifest.board.allow_captcha), manifest.board.post_cooldown_secs, eid, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Update board: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Update board: {e}")))?; eid } else { conn.execute( @@ -2788,23 +2984,23 @@ pub async fn restore_saved_board_backup( manifest.board.short_name, manifest.board.name, manifest.board.description, - manifest.board.nsfw as i64, + i64::from(manifest.board.nsfw), manifest.board.max_threads, manifest.board.bump_limit, - manifest.board.allow_images as i64, - manifest.board.allow_video as i64, - manifest.board.allow_audio as i64, - manifest.board.allow_tripcodes as i64, + i64::from(manifest.board.allow_images), + i64::from(manifest.board.allow_video), + i64::from(manifest.board.allow_audio), + i64::from(manifest.board.allow_tripcodes), manifest.board.edit_window_secs, - manifest.board.allow_editing as i64, - manifest.board.allow_archive as i64, - manifest.board.allow_video_embeds as i64, - manifest.board.allow_captcha as i64, + i64::from(manifest.board.allow_editing), + i64::from(manifest.board.allow_archive), + i64::from(manifest.board.allow_video_embeds), + i64::from(manifest.board.allow_captcha), manifest.board.post_cooldown_secs, manifest.board.created_at, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert board: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert board: {e}")))?; conn.last_insert_rowid() }; @@ -2818,12 +3014,12 @@ pub async fn restore_saved_board_backup( t.subject, t.created_at, t.bumped_at, - t.locked as i64, - t.sticky as i64, + i64::from(t.locked), + i64::from(t.sticky), t.reply_count, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert thread: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert thread: {e}")))?; thread_id_map.insert(t.id, conn.last_insert_rowid()); } @@ -2853,10 +3049,10 @@ pub async fn restore_saved_board_backup( p.media_type, p.created_at, p.deletion_token, - p.is_op as i64, + i64::from(p.is_op), ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert post: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert post: {e}")))?; } let mut poll_id_map: HashMap = HashMap::new(); @@ -2869,36 +3065,36 @@ pub async fn restore_saved_board_backup( VALUES (?1,?2,?3,?4)", params![new_tid, p.question, p.expires_at, p.created_at], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert poll: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert poll: {e}")))?; poll_id_map.insert(p.id, conn.last_insert_rowid()); } let mut option_id_map: HashMap = HashMap::new(); for o in &manifest.poll_options { - let new_pid = *poll_id_map.get(&o.poll_id).ok_or_else(|| { + let new_poll_id = *poll_id_map.get(&o.poll_id).ok_or_else(|| { AppError::Internal(anyhow::anyhow!("Unknown poll {}", o.poll_id)) })?; conn.execute( "INSERT INTO poll_options (poll_id, text, position) VALUES (?1,?2,?3)", - params![new_pid, o.text, o.position], + params![new_poll_id, o.text, o.position], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert option: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert option: {e}")))?; option_id_map.insert(o.id, conn.last_insert_rowid()); } for v in &manifest.poll_votes { - let new_pid = *poll_id_map.get(&v.poll_id).ok_or_else(|| { + let new_poll_id = *poll_id_map.get(&v.poll_id).ok_or_else(|| { AppError::Internal(anyhow::anyhow!("Unknown poll {}", v.poll_id)) })?; - let new_oid = *option_id_map.get(&v.option_id).ok_or_else(|| { + let new_option_id = *option_id_map.get(&v.option_id).ok_or_else(|| { AppError::Internal(anyhow::anyhow!("Unknown option {}", v.option_id)) })?; conn.execute( "INSERT OR IGNORE INTO poll_votes (poll_id, option_id, ip_hash) VALUES (?1,?2,?3)", - params![new_pid, new_oid, v.ip_hash], + params![new_poll_id, new_option_id, v.ip_hash], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert vote: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert vote: {e}")))?; } for fh in &manifest.file_hashes { @@ -2914,7 +3110,7 @@ pub async fn restore_saved_board_backup( fh.created_at, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert file_hash: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert file_hash: {e}")))?; } Ok(()) })(); @@ -2922,7 +3118,7 @@ pub async fn restore_saved_board_backup( match restore_result { Ok(()) => { conn.execute("COMMIT", []) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Commit: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Commit: {e}")))?; } Err(e) => { let _ = conn.execute("ROLLBACK", []); @@ -2934,10 +3130,10 @@ pub async fn restore_saved_board_backup( for i in 0..archive.len() { let mut entry = archive .by_index(i) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip[{}]: {}", i, e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip[{i}]: {e}")))?; let name = entry.name().to_string(); if name.contains("..") || name.starts_with('/') || name.starts_with('\\') { - warn!("Board restore-saved: skipping suspicious entry '{}'", name); + warn!("Board restore-saved: skipping suspicious entry '{name}'"); continue; } if let Some(rel) = name.strip_prefix("uploads/") { @@ -2947,25 +3143,25 @@ pub async fn restore_saved_board_backup( let target = PathBuf::from(&upload_dir).join(rel); if entry.is_dir() { std::fs::create_dir_all(&target) - .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir: {e}")))?; } else { if let Some(p) = target.parent() { std::fs::create_dir_all(p).map_err(|e| { - AppError::Internal(anyhow::anyhow!("mkdir parent: {}", e)) + AppError::Internal(anyhow::anyhow!("mkdir parent: {e}")) })?; } let mut out = std::fs::File::create(&target) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Create: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create: {e}")))?; copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write: {e}")))?; } } } - info!("Admin board restore-saved completed for /{}/", board_short); + info!("Admin board restore-saved completed for /{board_short}/"); let safe_short: String = board_short .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect(); Ok(safe_short) @@ -2974,36 +3170,17 @@ pub async fn restore_saved_board_backup( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e))); - fn encode_q(s: &str) -> String { - fn nibble(n: u8) -> char { - match n { - 0..=9 => (b'0' + n) as char, - _ => (b'A' + n - 10) as char, - } - } - s.bytes() - .flat_map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - vec![b as char] - } - b' ' => vec!['+'], - b => vec!['%', nibble(b >> 4), nibble(b & 0xf)], - }) - .collect() - } match board_short_result { - Ok(Ok(board_short)) => Ok(Redirect::to(&format!( - "/admin/panel?board_restored={}", - board_short - )) - .into_response()), + Ok(Ok(board_short)) => { + Ok(Redirect::to(&format!("/admin/panel?board_restored={board_short}")).into_response()) + } Ok(Err(app_err)) => { let msg = encode_q(&app_err.to_string()); - Ok(Redirect::to(&format!("/admin/panel?restore_error={}", msg)).into_response()) + Ok(Redirect::to(&format!("/admin/panel?restore_error={msg}")).into_response()) } Err(join_err) => { let msg = encode_q(&join_err.to_string()); - Ok(Redirect::to(&format!("/admin/panel?restore_error={}", msg)).into_response()) + Ok(Redirect::to(&format!("/admin/panel?restore_error={msg}")).into_response()) } } } @@ -3015,6 +3192,7 @@ mod board_backup_types { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] + #[allow(clippy::struct_excessive_bools)] pub struct BoardRow { pub id: i64, pub short_name: String, @@ -3056,11 +3234,11 @@ mod board_backup_types { pub created_at: i64, } - fn default_true() -> bool { + const fn default_true() -> bool { true } - fn default_edit_window_secs() -> i64 { + const fn default_edit_window_secs() -> i64 { 300 } #[derive(Serialize, Deserialize)] @@ -3140,8 +3318,9 @@ mod board_backup_types { /// Stream a board-level backup zip: manifest JSON + that board's upload files. /// -/// MEM-FIX: Same approach as admin_backup — build zip into a NamedTempFile on +/// 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)] pub async fn board_backup( State(state): State, jar: CookieJar, @@ -3154,8 +3333,7 @@ pub async fn board_backup( let (tmp_path, filename, file_size) = tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result<(PathBuf, String, u64)> { - use board_backup_types::*; - use rusqlite::params; + 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())?; @@ -3188,7 +3366,7 @@ pub async fn board_backup( post_cooldown_secs: r.get(16)?, created_at: r.get(17)?, }), - ).map_err(|_| AppError::NotFound(format!("Board '{}' not found", board_short)))?; + ).map_err(|_| AppError::NotFound(format!("Board '{board_short}' not found")))?; let board_id = board.id; @@ -3301,7 +3479,7 @@ pub async fn board_backup( // ── Build zip to NamedTempFile (MEM-FIX) ───────────────────── let manifest_json = serde_json::to_vec_pretty(&manifest) - .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON serialise: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON serialise: {e}")))?; let uploads_base = std::path::Path::new(&upload_dir); let board_upload_path = uploads_base.join(&board_short); @@ -3310,11 +3488,11 @@ pub async fn board_backup( progress.files_total.store(file_count + 1, Ordering::Relaxed); let zip_tmp = tempfile::NamedTempFile::new() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp zip: {e}")))?; { let out_file = std::io::BufWriter::new( zip_tmp.as_file().try_clone().map_err(|e| { - AppError::Internal(anyhow::anyhow!("Clone temp file handle: {}", e)) + AppError::Internal(anyhow::anyhow!("Clone temp file handle: {e}")) })?, ); let mut zip = zip::ZipWriter::new(out_file); @@ -3322,9 +3500,9 @@ pub async fn board_backup( .compression_method(zip::CompressionMethod::Deflated); zip.start_file("board.json", opts) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip manifest: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip manifest: {e}")))?; zip.write_all(&manifest_json) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write manifest: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write manifest: {e}")))?; progress.files_done.fetch_add(1, Ordering::Relaxed); progress.bytes_done.fetch_add(manifest_json.len() as u64, Ordering::Relaxed); @@ -3332,19 +3510,27 @@ pub async fn board_backup( add_dir_to_zip(&mut zip, uploads_base, &board_upload_path, opts, &progress)?; } - zip.finish() - .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {}", e)))?; + // Flush the BufWriter explicitly so I/O errors are not + // silently swallowed by the implicit Drop-flush. + let writer = zip + .finish() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Finalise zip: {e}")))?; + writer + .into_inner() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush zip writer: {e}")))? + .sync_all() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Sync zip file: {e}")))?; } let file_size = zip_tmp.as_file().metadata().map(|m| m.len()).unwrap_or(0); let (_, tmp_path_obj) = zip_tmp.into_parts(); let final_path = tmp_path_obj.keep().map_err(|e| { - AppError::Internal(anyhow::anyhow!("Persist temp zip: {}", e)) + AppError::Internal(anyhow::anyhow!("Persist temp zip: {e}")) })?; let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S"); - let fname = format!("rustchan-board-{}-{}.zip", board_short, ts); + let fname = format!("rustchan-board-{board_short}-{ts}.zip"); info!("Admin downloaded board backup for /{}/ ({} bytes on disk)", board_short, file_size); progress.phase.store(crate::middleware::backup_phase::DONE, Ordering::Relaxed); Ok((final_path, fname, file_size)) @@ -3353,9 +3539,9 @@ pub async fn board_backup( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - let file = tokio::fs::File::open(&tmp_path).await.map_err(|e| { - AppError::Internal(anyhow::anyhow!("Open board backup for streaming: {}", e)) - })?; + let file = tokio::fs::File::open(&tmp_path) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("Open board backup for streaming: {e}")))?; let stream = ReaderStream::new(file); let body = axum::body::Body::from_stream(stream); @@ -3365,8 +3551,7 @@ pub async fn board_backup( let _ = tokio::fs::remove_file(cleanup_path).await; }); - use axum::http::header; - let disposition = format!("attachment; filename=\"{}\"", filename); + let disposition = format!("attachment; filename=\"{filename}\""); Ok(( [ (header::CONTENT_TYPE, "application/zip".to_string()), @@ -3383,12 +3568,13 @@ pub async fn board_backup( /// Returns `Response` (not `Result`) so ALL errors — including /// 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)] pub async fn board_restore( State(state): State, jar: CookieJar, mut multipart: Multipart, ) -> Response { - fn nibble(n: u8) -> char { + const fn nibble(n: u8) -> char { match n { 0..=9 => (b'0' + n) as char, _ => (b'A' + n - 10) as char, @@ -3399,7 +3585,7 @@ pub async fn board_restore( for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) + out.push(b as char); } b' ' => out.push('+'), b => { @@ -3418,13 +3604,16 @@ pub async fn board_restore( let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()); let upload_dir = CONFIG.upload_dir.clone(); - let mut zip_data: Option> = None; + // MEM-FIX: stream the uploaded file to a NamedTempFile on disk instead + // of buffering the entire zip into a Vec. Board backups can be + // hundreds of MB for active boards with many uploads. + let mut zip_tmp: Option = None; let mut form_csrf: Option = None; while let Some(field) = multipart .next_field() .await - .map_err(|e| AppError::BadRequest(format!("Multipart error: {}", e)))? + .map_err(|e| AppError::BadRequest(format!("Multipart error: {e}")))? { match field.name() { Some("_csrf") => { @@ -3436,11 +3625,32 @@ pub async fn board_restore( ); } Some("backup_file") => { - let bytes = field - .bytes() + let tmp = tempfile::NamedTempFile::new() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Tempfile: {e}")))?; + // Clone the underlying fd for async writing; the original + // NamedTempFile retains ownership and the delete-on-drop guard. + let std_clone = tmp + .as_file() + .try_clone() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Clone fd: {e}")))?; + let async_file = tokio::fs::File::from_std(std_clone); + let mut writer = tokio::io::BufWriter::new(async_file); + let mut field = field; + while let Some(chunk) = field + .chunk() + .await + .map_err(|e| AppError::BadRequest(e.to_string()))? + { + writer + .write_all(&chunk) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("Write chunk: {e}")))?; + } + writer + .flush() .await - .map_err(|e| AppError::BadRequest(e.to_string()))?; - zip_data = Some(bytes.to_vec()); + .map_err(|e| AppError::Internal(anyhow::anyhow!("Flush: {e}")))?; + zip_tmp = Some(tmp); } _ => {} } @@ -3454,67 +3664,105 @@ pub async fn board_restore( return Err(AppError::Forbidden("CSRF token mismatch.".into())); } - let file_bytes = match zip_data { - None => return Err(AppError::BadRequest("No backup file uploaded.".into())), - Some(b) if b.is_empty() => { - return Err(AppError::BadRequest("Uploaded backup file is empty.".into())) - } - Some(b) => b, - }; + let zip_tmp = + zip_tmp.ok_or_else(|| AppError::BadRequest("No backup file uploaded.".into()))?; + // Determine size without reading into RAM. + let file_size = zip_tmp + .as_file() + .seek(std::io::SeekFrom::End(0)) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Seek check: {e}")))?; + if file_size == 0 { + return Err(AppError::BadRequest( + "Uploaded backup file is empty.".into(), + )); + } tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result { - use board_backup_types::*; - use rusqlite::params; + use board_backup_types::BoardBackupManifest; use std::collections::HashMap; + use std::io::Read; let conn = pool.get()?; require_admin_session_sid(&conn, session_id.as_deref())?; - // Detect format: ZIP magic bytes or raw JSON (skipping optional BOM) - let is_zip = file_bytes.starts_with(b"PK\x03\x04"); - let json_lead = if file_bytes.starts_with(b"\xef\xbb\xbf") { - file_bytes.get(3).copied() + // Detect format from the first four bytes (ZIP magic or JSON '{'). + let mut magic = [0u8; 4]; + let mut probe = zip_tmp + .reopen() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen: {e}")))?; + let n = probe.read(&mut magic).unwrap_or(0); + drop(probe); + + let is_zip = n >= 4 + && magic[0] == b'P' + && magic[1] == b'K' + && magic[2] == 0x03 + && magic[3] == 0x04; + // Skip optional UTF-8 BOM (EF BB BF) before the JSON '{'. + let is_json = if n >= 3 && magic[0] == 0xef && magic[1] == 0xbb && magic[2] == 0xbf + { + n >= 4 && magic[3] == b'{' } else { - file_bytes.first().copied() + n >= 1 && magic[0] == b'{' }; - let is_json = json_lead == Some(b'{'); if !is_zip && !is_json { return Err(AppError::BadRequest( - "Unrecognized format. Upload a .zip board backup or a raw board.json file.".into(), + "Unrecognized format. Upload a .zip board backup or a raw board.json file." + .into(), )); } + // We need two re-openings: one for the manifest (BufReader) + // and one for the archive used during file extraction. Both derive + // independent file descriptors from the same NamedTempFile so the + // underlying bytes are always available. let (manifest, mut archive_opt): ( BoardBackupManifest, - Option>>>, + Option>>, ) = if is_zip { - let cursor = std::io::Cursor::new(file_bytes); - let mut archive = zip::ZipArchive::new(cursor) - .map_err(|e| AppError::BadRequest(format!("Invalid zip: {}", e)))?; + let f = zip_tmp + .reopen() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen zip: {e}")))?; + let mut archive = zip::ZipArchive::new(std::io::BufReader::new(f)) + .map_err(|e| AppError::BadRequest(format!("Invalid zip: {e}")))?; if !archive.file_names().any(|n| n == "board.json") { return Err(AppError::BadRequest( - "Invalid board backup: zip must contain 'board.json'. (Did you upload a full-site backup instead?)".into(), + "Invalid board backup: zip must contain 'board.json'. \ + (Did you upload a full-site backup instead?)" + .into(), )); } let manifest: BoardBackupManifest = { let mut entry = archive.by_name("board.json").map_err(|e| { - AppError::Internal(anyhow::anyhow!("Read board.json: {}", e)) + AppError::Internal(anyhow::anyhow!("Read board.json: {e}")) })?; let mut buf = Vec::new(); - std::io::Read::read_to_end(&mut entry, &mut buf).map_err(|e| { - AppError::Internal(anyhow::anyhow!("Read bytes: {}", e)) - })?; - serde_json::from_slice(&buf).map_err(|e| { - AppError::BadRequest(format!("Invalid board.json: {}", e)) - })? + entry + .read_to_end(&mut buf) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Read bytes: {e}")))?; + serde_json::from_slice(&buf) + .map_err(|e| AppError::BadRequest(format!("Invalid board.json: {e}")))? }; - (manifest, Some(archive)) + // Re-open a fresh archive for file extraction in the second pass. + let f2 = zip_tmp + .reopen() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen zip (2): {e}")))?; + let archive2 = zip::ZipArchive::new(std::io::BufReader::new(f2)) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen archive: {e}")))?; + (manifest, Some(archive2)) } else { - let manifest: BoardBackupManifest = serde_json::from_slice(&file_bytes) - .map_err(|e| AppError::BadRequest(format!("Invalid board.json: {}", e)))?; + // Raw board.json — read fully (manifests are small). + let mut f = zip_tmp + .reopen() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Reopen json: {e}")))?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Read json: {e}")))?; + let manifest: BoardBackupManifest = serde_json::from_slice(&buf) + .map_err(|e| AppError::BadRequest(format!("Invalid board.json: {e}")))?; (manifest, None) }; @@ -3528,16 +3776,17 @@ pub async fn board_restore( ) .ok(); - // FIX[A6]: BEGIN IMMEDIATE must cover the DELETE + UPDATE/INSERT of the // board row. Previously those statements ran outside any transaction. conn.execute("BEGIN IMMEDIATE", []) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Begin tx: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Begin tx: {e}")))?; let restore_result = (|| -> Result<()> { let live_board_id: i64 = if let Some(eid) = existing_id { conn.execute("DELETE FROM threads WHERE board_id = ?1", params![eid]) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Clear threads: {}", e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Clear threads: {e}")) + })?; conn.execute( "UPDATE boards SET name=?1, description=?2, nsfw=?3, max_threads=?4, bump_limit=?5, @@ -3548,23 +3797,23 @@ pub async fn board_restore( params![ manifest.board.name, manifest.board.description, - manifest.board.nsfw as i64, + i64::from(manifest.board.nsfw), manifest.board.max_threads, manifest.board.bump_limit, - manifest.board.allow_images as i64, - manifest.board.allow_video as i64, - manifest.board.allow_audio as i64, - manifest.board.allow_tripcodes as i64, + i64::from(manifest.board.allow_images), + i64::from(manifest.board.allow_video), + i64::from(manifest.board.allow_audio), + i64::from(manifest.board.allow_tripcodes), manifest.board.edit_window_secs, - manifest.board.allow_editing as i64, - manifest.board.allow_archive as i64, - manifest.board.allow_video_embeds as i64, - manifest.board.allow_captcha as i64, + i64::from(manifest.board.allow_editing), + i64::from(manifest.board.allow_archive), + i64::from(manifest.board.allow_video_embeds), + i64::from(manifest.board.allow_captcha), manifest.board.post_cooldown_secs, eid, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Update board: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Update board: {e}")))?; eid } else { conn.execute( @@ -3577,23 +3826,23 @@ pub async fn board_restore( manifest.board.short_name, manifest.board.name, manifest.board.description, - manifest.board.nsfw as i64, + i64::from(manifest.board.nsfw), manifest.board.max_threads, manifest.board.bump_limit, - manifest.board.allow_images as i64, - manifest.board.allow_video as i64, - manifest.board.allow_audio as i64, - manifest.board.allow_tripcodes as i64, + i64::from(manifest.board.allow_images), + i64::from(manifest.board.allow_video), + i64::from(manifest.board.allow_audio), + i64::from(manifest.board.allow_tripcodes), manifest.board.edit_window_secs, - manifest.board.allow_editing as i64, - manifest.board.allow_archive as i64, - manifest.board.allow_video_embeds as i64, - manifest.board.allow_captcha as i64, + i64::from(manifest.board.allow_editing), + i64::from(manifest.board.allow_archive), + i64::from(manifest.board.allow_video_embeds), + i64::from(manifest.board.allow_captcha), manifest.board.post_cooldown_secs, manifest.board.created_at, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert board: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert board: {e}")))?; conn.last_insert_rowid() }; @@ -3604,17 +3853,42 @@ pub async fn board_restore( locked, sticky, reply_count) VALUES (?1,?2,?3,?4,?5,?6,?7)", params![ - live_board_id, t.subject, t.created_at, t.bumped_at, - t.locked as i64, t.sticky as i64, t.reply_count, + live_board_id, + t.subject, + t.created_at, + t.bumped_at, + i64::from(t.locked), + i64::from(t.sticky), + t.reply_count, ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert thread {}: {}", t.id, e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Insert thread {}: {e}", t.id)) + })?; thread_id_map.insert(t.id, conn.last_insert_rowid()); } + // ── Insert posts, recording old → new ID mapping ────── + // + // Board restore cannot reuse original post IDs because + // other boards' posts may already occupy those rows in the + // global `posts` table (SQLite AUTOINCREMENT is site-wide, + // not per-board). The posts therefore land at new IDs. + // + // `post_id_map` captures every (old_id → new_id) pair so + // that we can fix up in-board quotelink references in + // `body` and `body_html` in the second pass below. + // Without this, `>>500` in a restored post still points to + // old ID 500 which no longer exists — clicks produce 404s + // and hover previews show "Post not found". + let mut post_id_map: HashMap = HashMap::new(); for p in &manifest.posts { let new_tid = *thread_id_map.get(&p.thread_id).ok_or_else(|| { - AppError::Internal(anyhow::anyhow!("Post {} refs unknown thread {}", p.id, p.thread_id)) + AppError::Internal(anyhow::anyhow!( + "Post {} refs unknown thread {}", + p.id, + p.thread_id + )) })?; conn.execute( "INSERT INTO posts (thread_id, board_id, name, tripcode, subject, @@ -3622,55 +3896,140 @@ pub async fn board_restore( thumb_path, mime_type, media_type, created_at, deletion_token, is_op) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)", params![ - new_tid, live_board_id, p.name, p.tripcode, p.subject, - p.body, p.body_html, p.ip_hash, p.file_path, p.file_name, - p.file_size, p.thumb_path, p.mime_type, p.media_type, - p.created_at, p.deletion_token, p.is_op as i64, + new_tid, + live_board_id, + p.name, + p.tripcode, + p.subject, + p.body, + p.body_html, + p.ip_hash, + p.file_path, + p.file_name, + p.file_size, + p.thumb_path, + p.mime_type, + p.media_type, + p.created_at, + p.deletion_token, + i64::from(p.is_op), ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert post {}: {}", p.id, e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Insert post {}: {e}", p.id)) + })?; + post_id_map.insert(p.id, conn.last_insert_rowid()); + } + + // ── Quotelink fixup pass ────────────────────────────── + // + // If any post IDs changed (which they almost always do + // when restoring into a live DB), rewrite `body` and + // `body_html` for every restored post so that in-board + // quotelinks point at the new IDs. + // + // Only same-board references are remapped. Cross-board + // links (`>>>/board/N`) point to other boards whose IDs + // are unchanged; they are deliberately left untouched. + let any_changed = post_id_map.iter().any(|(old, new)| old != new); + if any_changed { + // Sort by old-ID string length descending so that + // longer IDs are replaced before any prefix of theirs. + // e.g. replace >>1000 before >>100 before >>10 before >>1. + let mut pairs: Vec<(String, String)> = post_id_map + .iter() + .filter(|(old, new)| old != new) + .map(|(old, new)| (old.to_string(), new.to_string())) + .collect(); + pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(b.0.cmp(&a.0))); + + for p in &manifest.posts { + let Some(&new_post_id) = post_id_map.get(&p.id) else { + continue; + }; + + let new_body = remap_body_quotelinks(&p.body, &pairs); + let new_body_html = remap_body_html_quotelinks(&p.body_html, &pairs); + + // Only issue the UPDATE when the text actually + // changed — avoids unnecessary I/O when none of + // the post IDs appear in this post's body. + if new_body != p.body || new_body_html != p.body_html { + conn.execute( + "UPDATE posts SET body = ?1, body_html = ?2 WHERE id = ?3", + params![new_body, new_body_html, new_post_id], + ) + .map_err(|e| { + AppError::Internal(anyhow::anyhow!( + "Fixup quotelinks for post {new_post_id}: {e}" + )) + })?; + } + } } let mut poll_id_map: HashMap = HashMap::new(); for p in &manifest.polls { let new_tid = *thread_id_map.get(&p.thread_id).ok_or_else(|| { - AppError::Internal(anyhow::anyhow!("Poll {} refs unknown thread {}", p.id, p.thread_id)) + AppError::Internal(anyhow::anyhow!( + "Poll {} refs unknown thread {}", + p.id, + p.thread_id + )) })?; conn.execute( "INSERT INTO polls (thread_id, question, expires_at, created_at) VALUES (?1,?2,?3,?4)", params![new_tid, p.question, p.expires_at, p.created_at], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert poll {}: {}", p.id, e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Insert poll {}: {e}", p.id)) + })?; poll_id_map.insert(p.id, conn.last_insert_rowid()); } let mut option_id_map: HashMap = HashMap::new(); for o in &manifest.poll_options { - let new_pid = *poll_id_map.get(&o.poll_id).ok_or_else(|| { - AppError::Internal(anyhow::anyhow!("Option {} refs unknown poll {}", o.id, o.poll_id)) + let new_poll_id = *poll_id_map.get(&o.poll_id).ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "Option {} refs unknown poll {}", + o.id, + o.poll_id + )) })?; conn.execute( "INSERT INTO poll_options (poll_id, text, position) VALUES (?1,?2,?3)", - params![new_pid, o.text, o.position], + params![new_poll_id, o.text, o.position], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert option {}: {}", o.id, e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Insert option {}: {e}", o.id)) + })?; option_id_map.insert(o.id, conn.last_insert_rowid()); } for v in &manifest.poll_votes { - let new_pid = *poll_id_map.get(&v.poll_id).ok_or_else(|| { - AppError::Internal(anyhow::anyhow!("Vote {} refs unknown poll {}", v.id, v.poll_id)) + let new_poll_id = *poll_id_map.get(&v.poll_id).ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "Vote {} refs unknown poll {}", + v.id, + v.poll_id + )) })?; - let new_oid = *option_id_map.get(&v.option_id).ok_or_else(|| { - AppError::Internal(anyhow::anyhow!("Vote {} refs unknown option {}", v.id, v.option_id)) + let new_option_id = *option_id_map.get(&v.option_id).ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "Vote {} refs unknown option {}", + v.id, + v.option_id + )) })?; conn.execute( "INSERT OR IGNORE INTO poll_votes (poll_id, option_id, ip_hash) VALUES (?1,?2,?3)", - params![new_pid, new_oid, v.ip_hash], + params![new_poll_id, new_option_id, v.ip_hash], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert vote {}: {}", v.id, e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Insert vote {}: {e}", v.id)) + })?; } for fh in &manifest.file_hashes { @@ -3678,9 +4037,17 @@ pub async fn board_restore( "INSERT OR IGNORE INTO file_hashes (sha256, file_path, thumb_path, mime_type, created_at) VALUES (?1,?2,?3,?4,?5)", - params![fh.sha256, fh.file_path, fh.thumb_path, fh.mime_type, fh.created_at], + params![ + fh.sha256, + fh.file_path, + fh.thumb_path, + fh.mime_type, + fh.created_at + ], ) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Insert file_hash: {}", e)))?; + .map_err(|e| { + AppError::Internal(anyhow::anyhow!("Insert file_hash: {e}")) + })?; } Ok(()) })(); @@ -3688,7 +4055,7 @@ pub async fn board_restore( match restore_result { Ok(()) => { conn.execute("COMMIT", []) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Commit tx: {}", e)))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Commit tx: {e}")))?; } Err(e) => { let _ = conn.execute("ROLLBACK", []); @@ -3698,49 +4065,62 @@ pub async fn board_restore( if let Some(ref mut archive) = archive_opt { for i in 0..archive.len() { - let mut entry = archive.by_index(i) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip[{}]: {}", i, e)))?; + let mut entry = archive + .by_index(i) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Zip[{i}]: {e}")))?; let name = entry.name().to_string(); - if name.contains("..") || name.starts_with('/') || name.starts_with('\\'){ - warn!("Board restore: skipping suspicious entry '{}'", name); + if name.contains("..") || name.starts_with('/') || name.starts_with('\\') { + warn!("Board restore: skipping suspicious entry '{name}'"); continue; } if let Some(rel) = name.strip_prefix("uploads/") { - if rel.is_empty() { continue; } + if rel.is_empty() { + continue; + } let target = PathBuf::from(&upload_dir).join(rel); if entry.is_dir() { - std::fs::create_dir_all(&target) - .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir: {}", e)))?; + std::fs::create_dir_all(&target).map_err(|e| { + AppError::Internal(anyhow::anyhow!("mkdir: {e}")) + })?; } else { if let Some(p) = target.parent() { - std::fs::create_dir_all(p) - .map_err(|e| AppError::Internal(anyhow::anyhow!("mkdir parent: {}", e)))?; + std::fs::create_dir_all(p).map_err(|e| { + AppError::Internal(anyhow::anyhow!("mkdir parent: {e}")) + })?; } - let mut out = std::fs::File::create(&target) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Create file: {}", e)))?; - copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES) - .map_err(|e| AppError::Internal(anyhow::anyhow!("Write file: {}", e)))?; + let mut out = std::fs::File::create(&target).map_err(|e| { + AppError::Internal(anyhow::anyhow!("Create file: {e}")) + })?; + copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES).map_err( + |e| AppError::Internal(anyhow::anyhow!("Write file: {e}")), + )?; } } } } - info!("Admin board restore completed for /{}/", board_short); + info!("Admin board restore completed for /{board_short}/"); + // Refresh live board list — board_restore may have created a + // board that didn't exist before, so the top bar must update. + if let Ok(boards) = db::get_all_boards(&conn) { + crate::templates::set_live_boards(boards); + } let safe_short: String = board_short .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect(); Ok(safe_short) } }) .await - .unwrap_or_else(|e| Err(AppError::Internal(anyhow::anyhow!("Task panicked: {}", e)))) - }.await; + .unwrap_or_else(|e| Err(AppError::Internal(anyhow::anyhow!("Task panicked: {e}")))) + } + .await; match result { Ok(board_short) => { - Redirect::to(&format!("/admin/panel?board_restored={}", board_short)).into_response() + Redirect::to(&format!("/admin/panel?board_restored={board_short}")).into_response() } Err(e) => Redirect::to(&format!( "/admin/panel?restore_error={}", @@ -3754,10 +4134,11 @@ pub async fn board_restore( #[derive(Deserialize)] pub struct SiteSettingsForm { - pub _csrf: Option, + #[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). + /// 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, @@ -3773,16 +4154,22 @@ pub async fn update_site_settings( ) -> 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(""), - ) { + 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") { @@ -3791,7 +4178,7 @@ pub async fn update_site_settings( "0" }; db::set_site_setting(&conn, "collapse_greentext", val)?; - info!("Admin updated site setting: collapse_greentext={}", val); + info!("Admin updated site setting: collapse_greentext={val}"); // Save the custom site name (trimmed, max 64 chars). let new_name = form @@ -3820,15 +4207,12 @@ pub async fn update_site_settings( 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). - const VALID_THEMES: &[&str] = &[ - "terminal", - "aero", - "dorfic", - "fluorogrid", - "neoncubicle", - "chanclassic", - ]; let new_theme = form .default_theme .as_deref() @@ -3860,7 +4244,8 @@ pub async fn update_site_settings( #[derive(Deserialize)] pub struct VacuumForm { - pub _csrf: Option, + #[serde(rename = "_csrf")] + pub csrf: Option, } pub async fn admin_vacuum( @@ -3870,10 +4255,8 @@ pub async fn admin_vacuum( ) -> 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(""), - ) { + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -3923,7 +4306,7 @@ pub struct IpHistoryQuery { pub page: i64, } -fn default_page() -> i64 { +const fn default_page() -> i64 { 1 } @@ -3949,10 +4332,10 @@ pub async fn admin_ip_history( 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())?; - const PER_PAGE: i64 = 25; 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 = @@ -3983,7 +4366,8 @@ pub struct ResolveReportForm { /// Optional: also ban the reported post's author ban_ip_hash: Option, ban_reason: Option, - _csrf: Option, + #[serde(rename = "_csrf")] + csrf: Option, } pub async fn resolve_report( @@ -3992,7 +4376,7 @@ pub async fn resolve_report( 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())?; + check_csrf_jar(&jar, form.csrf.as_deref())?; tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -4005,7 +4389,13 @@ pub async fn resolve_report( // Optionally ban the reporter's target while resolving. if let Some(ref ip) = form.ban_ip_hash { - if !ip.trim().is_empty() { + 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( @@ -4016,7 +4406,7 @@ pub async fn resolve_report( "ban", None, "", - &format!("via report {} — {}", form.report_id, reason), + &format!("via report {} — {reason}", form.report_id), ); } } @@ -4049,7 +4439,7 @@ pub struct ModLogQuery { page: i64, } -fn default_mod_log_page() -> i64 { +const fn default_mod_log_page() -> i64 { 1 } @@ -4066,10 +4456,10 @@ pub async fn mod_log_page( 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())?; - const PER_PAGE: i64 = 50; 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())?; @@ -4087,3 +4477,84 @@ pub async fn mod_log_page( 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/board.rs b/src/handlers/board.rs index b159141..499fe62 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -14,27 +14,20 @@ use crate::{ error::{AppError, Result}, handlers::parse_post_multipart, middleware::{validate_csrf, AppState}, - models::*, + models::{Pagination, SearchQuery, ThreadSummary}, templates, utils::{ - // FIX[LOW-8]: sha256_hex now lives in utils::crypto (deduplicated) - crypto::{hash_ip, new_csrf_token, new_deletion_token, sha256_hex, verify_pow}, - files::save_upload, + crypto::{hash_ip, new_csrf_token, new_deletion_token, verify_pow}, sanitize::{ - // FIX[MEDIUM-8]: apply_word_filters now runs before escape_html - apply_word_filters, - escape_html, - render_post_body, - validate_body, - validate_body_with_file, - validate_name, - validate_subject, + apply_word_filters, escape_html, render_post_body, validate_body, + validate_body_with_file, validate_name, validate_subject, }, tripcode::parse_name_tripcode, }, }; use axum::{ extract::{Form, Multipart, Path, Query, State}, + http::{header, HeaderMap}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; @@ -52,13 +45,13 @@ pub async fn index( ) -> Result<(CookieJar, Html)> { let (jar, csrf) = ensure_csrf(jar); - let (board_stats, site_stats) = tokio::task::spawn_blocking({ + let (board_stats, site_data) = tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result<(Vec, crate::models::SiteStats)> { let conn = pool.get()?; let boards = db::get_all_boards_with_stats(&conn)?; - let stats = db::get_site_stats(&conn).unwrap_or_default(); - Ok((boards, stats)) + let site_data = db::get_site_stats(&conn).unwrap_or_default(); + Ok((boards, site_data)) } }) .await @@ -68,8 +61,10 @@ pub async fn index( let onion_address: Option = if crate::config::CONFIG.enable_tor_support { let data_dir = std::path::PathBuf::from(&crate::config::CONFIG.database_path) .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| std::path::PathBuf::from(".")); + .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() @@ -83,7 +78,7 @@ pub async fn index( jar, Html(templates::index_page( &board_stats, - &site_stats, + &site_data, &csrf, onion_address.as_deref(), )), @@ -97,7 +92,8 @@ pub async fn board_index( Path(board_short): Path, Query(params): Query>, jar: CookieJar, -) -> Result<(CookieJar, Html)> { + req_headers: HeaderMap, +) -> Result { let (jar, csrf) = ensure_csrf(jar); let page: i64 = params @@ -106,20 +102,16 @@ pub async fn board_index( .unwrap_or(1) .max(1); - let html = tokio::task::spawn_blocking({ + let result = tokio::task::spawn_blocking({ let pool = state.db.clone(); let csrf_clone = csrf.clone(); - // FIX[HIGH-2]: is_admin_session check moved inside spawn_blocking so - // the blocking DB call does not stall the Tokio worker thread. let jar_session = jar.get("chan_admin_session").map(|c| c.value().to_string()); - move || -> Result { + move || -> Result<(String, String)> { let conn = pool.get()?; - // Resolve admin status inside the blocking task let is_admin = jar_session .as_deref() - .map(|sid| db::get_session(&conn, sid).ok().flatten().is_some()) - .unwrap_or(false); + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); let board = db::get_board_by_short(&conn, &board_short)? .ok_or_else(|| AppError::NotFound(format!("Board /{board_short}/ not found")))?; @@ -129,11 +121,17 @@ pub async fn board_index( let threads = db::get_threads_for_board(&conn, board.id, THREADS_PER_PAGE, pagination.offset())?; + // 3.2: Derive ETag from the most-recently-bumped thread on this page + // 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}\""); + 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)?; - let omitted = (total_replies - preview.len() as i64).max(0); + let omitted = (total_replies - i64::try_from(preview.len()).unwrap_or(0)).max(0); summaries.push(ThreadSummary { thread, preview_posts: preview, @@ -143,7 +141,7 @@ pub async fn board_index( let all_boards = db::get_all_boards(&conn)?; let collapse_greentext = db::get_collapse_greentext(&conn); - Ok(templates::board_page( + let html = templates::board_page( &board, &summaries, &pagination, @@ -152,17 +150,43 @@ pub async fn board_index( is_admin, None, collapse_greentext, - )) + ); + Ok((etag, html)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - Ok((jar, Html(html))) + let (etag, html) = result; + + // 3.2: Return 304 Not Modified when the client's cached version is current. + let client_etag = req_headers + .get("if-none-match") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if client_etag == etag { + let mut resp = axum::http::Response::builder() + .status(axum::http::StatusCode::NOT_MODIFIED) + .body(axum::body::Body::empty()) + .unwrap_or_default(); + resp.headers_mut().insert( + "etag", + axum::http::HeaderValue::from_str(&etag) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("\"0\"")), + ); + return Ok((jar, resp).into_response()); + } + + let mut resp = Html(html).into_response(); + if let Ok(v) = axum::http::HeaderValue::from_str(&etag) { + resp.headers_mut().insert("etag", v); + } + Ok((jar, resp).into_response()) } // ─── POST /:board/ — create new thread ─────────────────────────────────────── +#[allow(clippy::too_many_lines)] pub async fn create_thread( State(state): State, Path(board_short): Path, @@ -201,7 +225,7 @@ 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(); + 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(); @@ -224,8 +248,7 @@ pub async fn create_thread( // Verify admin session — admins bypass the per-board cooldown entirely. let is_admin = admin_session_id .as_deref() - .map(|sid| db::get_session(&conn, sid).ok().flatten().is_some()) - .unwrap_or(false); + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); // Per-board post cooldown — the SOLE post rate control. // post_cooldown_secs = 0 means no cooldown at all; admins always bypass it. @@ -234,11 +257,7 @@ pub async fn create_thread( if let Some(secs) = elapsed { let remaining = board.post_cooldown_secs.saturating_sub(secs); if remaining > 0 { - return Err(AppError::BadRequest(format!( - "Please wait {} more second{} before posting again.", - remaining, - if remaining == 1 { "" } else { "s" } - ))); + return Err(AppError::BadRequest(format!("Please wait {remaining} more second{} before posting again.", if remaining == 1 { "" } else { "s" }))); } } } @@ -282,117 +301,26 @@ pub async fn create_thread( let escaped_body = escape_html(&filtered_body); let body_html = render_post_body(&escaped_body); - let uploaded = if let Some((data, fname)) = file_data { - // Detect media type from magic bytes to enforce per-board toggles. - // We call detect_mime_type here for a quick classification without - // doing a full save; the real detection runs again in save_upload. - let detected_mime = crate::utils::files::detect_mime_type(&data) - .map_err(|e| AppError::BadRequest(e.to_string()))?; - let detected_media = crate::models::MediaType::from_mime(detected_mime) - .ok_or_else(|| AppError::BadRequest("Unsupported file type.".into()))?; - - match detected_media { - crate::models::MediaType::Image if !board.allow_images => { - return Err(AppError::BadRequest( - "Image uploads are disabled on this board.".into(), - )) - } - crate::models::MediaType::Video if !board.allow_video => { - return Err(AppError::BadRequest( - "Video uploads are disabled on this board.".into(), - )) - } - crate::models::MediaType::Audio if !board.allow_audio => { - return Err(AppError::BadRequest( - "Audio uploads are disabled on this board.".into(), - )) - } - _ => {} - } - - // SHA-256 deduplication — FIX[LOW-8]: use sha256_hex from crypto module - let hash = sha256_hex(&data); - if let Some(cached) = db::find_file_by_hash(&conn, &hash)? { - let cached_media = crate::models::MediaType::from_mime(&cached.mime_type) - .unwrap_or(crate::models::MediaType::Image); - 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: data.len() as i64, - media_type: cached_media, - processing_pending: false, // cached = already fully processed - }) - } else { - let f = save_upload( - &data, - &fname, - &upload_dir, - &board.short_name, - thumb_size, - max_image_size, - max_video_size, - max_audio_size, - ffmpeg_available, - ) - .map_err(crate::handlers::classify_upload_error)?; - db::record_file_hash(&conn, &hash, &f.file_path, &f.thumb_path, &f.mime_type)?; - Some(f) - } - } else { - None - }; + let uploaded = crate::handlers::process_primary_upload( + file_data, + &board, + &conn, + &upload_dir, + thumb_size, + max_image_size, + max_video_size, + max_audio_size, + ffmpeg_available, + )?; // ── Image+audio combo ───────────────────────────────────────────── - // If an audio file was also submitted alongside an image, and the - // board permits both, save the audio file using the image's thumb. - let audio_uploaded: Option = - if let Some((aud_data, aud_fname)) = audio_file_data { - // Validate that the board allows audio - if !board.allow_audio { - return Err(AppError::BadRequest( - "Audio uploads are disabled on this board.".into(), - )); - } - // The primary file must be an image for the combo to be valid - let primary_is_image = uploaded - .as_ref() - .map(|u| matches!(u.media_type, crate::models::MediaType::Image)) - .unwrap_or(false); - if !primary_is_image { - return Err(AppError::BadRequest( - "Audio can only be combined with an image upload.".into(), - )); - } - // Confirm it's actually audio via magic bytes - let aud_mime = crate::utils::files::detect_mime_type(&aud_data) - .map_err(|e| AppError::BadRequest(e.to_string()))?; - let aud_media = crate::models::MediaType::from_mime(aud_mime) - .ok_or_else(|| AppError::BadRequest("Unsupported audio type.".into()))?; - if !matches!(aud_media, crate::models::MediaType::Audio) { - return Err(AppError::BadRequest( - "The audio slot only accepts audio files.".into(), - )); - } - - let mut aud_file = crate::utils::files::save_audio_with_image_thumb( - &aud_data, - &aud_fname, - &upload_dir, - &board.short_name, - max_audio_size, - ) - .map_err(crate::handlers::classify_upload_error)?; - - // Use the image thumbnail as the audio's thumbnail - if let Some(ref img) = uploaded { - aud_file.thumb_path = img.thumb_path.clone(); - } - Some(aud_file) - } else { - None - }; + let audio_uploaded = crate::handlers::process_audio_combo( + audio_file_data, + uploaded.as_ref(), + &board, + &upload_dir, + max_audio_size, + )?; let deletion_token = if del_token_val.trim().is_empty() { new_deletion_token() @@ -449,40 +377,15 @@ pub async fn create_thread( } // ── Background jobs ─────────────────────────────────────────────── - // 1. Media post-processing (video transcode / audio waveform) - if let Some(ref up) = uploaded { - if up.processing_pending { - let job = match up.media_type { - crate::models::MediaType::Video => { - Some(crate::workers::Job::VideoTranscode { - post_id, - file_path: up.file_path.clone(), - board_short: board.short_name.clone(), - }) - } - crate::models::MediaType::Audio => { - Some(crate::workers::Job::AudioWaveform { - post_id, - file_path: up.file_path.clone(), - board_short: board.short_name.clone(), - }) - } - _ => None, - }; - if let Some(j) = job { - if let Err(e) = job_queue.enqueue(&j) { - tracing::warn!("Failed to enqueue media job: {}", e); - } - } - } - } - - // 2. Spam analysis - let _ = job_queue.enqueue(&crate::workers::Job::SpamCheck { + // 1 & 2. Media post-processing + spam check (shared helper) + crate::handlers::enqueue_post_jobs( + &job_queue, post_id, - ip_hash: ip_hash.clone(), - body_len: body_text.len(), - }); + &ip_hash, + body_text.len(), + uploaded.as_ref(), + &board.short_name, + ); // 3. Thread pruning — now async so HTTP response returns immediately. let max_threads = board.max_threads; @@ -493,67 +396,21 @@ pub async fn create_thread( allow_archive: board.allow_archive, }); - info!("New thread {} created in /{}/", thread_id, board.short_name); - Ok(format!("/{}/thread/{}", board.short_name, thread_id)) + info!("New thread {thread_id} created in /{}/", board.short_name); + Ok(format!("/{}/thread/{thread_id}", board.short_name)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - // BadRequest → re-render the board index with an inline error banner. + // BadRequest → return a lightweight 422 page instead of re-querying the + // entire board index (which wastes significant DB and CPU under spam load). let redirect_url = match result { Ok(url) => url, Err(AppError::BadRequest(msg)) => { - let html = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - let csrf_err = csrf_cookie.clone().unwrap_or_default(); - let board_short = board_short_err.clone(); - let msg = msg.clone(); - move || -> String { - let conn = match pool.get() { - Ok(c) => c, - Err(_) => return String::new(), - }; - let board = match db::get_board_by_short(&conn, &board_short) { - Ok(Some(b)) => b, - _ => return String::new(), - }; - let all_boards = db::get_all_boards(&conn).unwrap_or_default(); - let total = db::count_threads_for_board(&conn, board.id).unwrap_or(0); - let pagination = crate::models::Pagination::new(1, 10, total); - let threads = - db::get_threads_for_board(&conn, board.id, 10, 0).unwrap_or_default(); - let summaries: Vec = threads - .into_iter() - .map(|t| { - let preview = db::get_preview_posts(&conn, t.id, 3).unwrap_or_default(); - let omitted = (t.reply_count - preview.len() as i64).max(0); - crate::models::ThreadSummary { - thread: t, - preview_posts: preview, - omitted, - } - }) - .collect(); - templates::board_page( - &board, - &summaries, - &pagination, - &csrf_err, - &all_boards, - false, - Some(&msg), - db::get_collapse_greentext(&conn), - ) - } - }) - .await - .unwrap_or_default(); - - if !html.is_empty() { - return Ok((jar, Html(html)).into_response()); - } - return Err(AppError::BadRequest(msg)); + let mut resp = Html(templates::error_page(422, &msg)).into_response(); + *resp.status_mut() = axum::http::StatusCode::UNPROCESSABLE_ENTITY; + return Ok(resp); } Err(e) => return Err(e), }; @@ -578,8 +435,7 @@ pub async fn catalog( let conn = pool.get()?; let is_admin = jar_session .as_deref() - .map(|sid| db::get_session(&conn, sid).ok().flatten().is_some()) - .unwrap_or(false); + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); let board = db::get_board_by_short(&conn, &board_short)? .ok_or_else(|| AppError::NotFound(format!("Board /{board_short}/ not found")))?; let threads = db::get_threads_for_board(&conn, board.id, 200, 0)?; @@ -609,8 +465,8 @@ pub async fn board_archive( Query(params): Query>, jar: CookieJar, ) -> Result<(CookieJar, Html)> { - let (jar, csrf) = ensure_csrf(jar); const ARCHIVE_PER_PAGE: i64 = 20; + let (jar, csrf) = ensure_csrf(jar); let page: i64 = params .get("page") @@ -667,8 +523,8 @@ pub async fn search( Query(q): Query, jar: CookieJar, ) -> Result<(CookieJar, Html)> { - let (jar, csrf) = ensure_csrf(jar); const SEARCH_PER_PAGE: i64 = 20; + let (jar, csrf) = ensure_csrf(jar); // Cap query length to prevent excessively large LIKE pattern scans. let query_str: String = q.q.trim().chars().take(256).collect(); @@ -713,7 +569,7 @@ pub async fn search( // ─── CSRF cookie helper ─────────────────────────────────────────────────────── -/// Ensure the CSRF token cookie is set. Returns (updated_jar, token_string). +/// Ensure the CSRF token cookie is set. Returns (`updated_jar`, `token_string`). pub fn ensure_csrf(jar: CookieJar) -> (CookieJar, String) { if let Some(cookie) = jar.get("csrf_token") { let token = cookie.value().to_string(); @@ -739,10 +595,11 @@ pub fn ensure_csrf(jar: CookieJar) -> (CookieJar, String) { #[derive(serde::Deserialize)] pub struct ReportForm { pub post_id: i64, + #[allow(dead_code)] pub thread_id: i64, pub board: String, pub reason: Option, - pub _csrf: Option, + pub csrf: Option, } pub async fn file_report( @@ -752,7 +609,7 @@ pub async fn file_report( Form(form): Form, ) -> Result { let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !validate_csrf(csrf_cookie.as_deref(), form._csrf.as_deref().unwrap_or("")) { + if !validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -767,19 +624,19 @@ pub async fn file_report( .collect::(); let post_id = form.post_id; - let _thread_id = form.thread_id; let board_raw = form .board .chars() - .filter(|c| c.is_ascii_alphanumeric()) + .filter(char::is_ascii_alphanumeric) .take(8) .collect::(); + let board_raw_closure = board_raw.clone(); let db_thread_id = tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> Result { let conn = pool.get()?; - let board = db::get_board_by_short(&conn, &board_raw)? + let board = db::get_board_by_short(&conn, &board_raw_closure)? .ok_or_else(|| AppError::NotFound("Board not found.".into()))?; // Verify post exists and belongs to this board to prevent spoofed reports. let post = db::get_post(&conn, post_id)? @@ -805,16 +662,11 @@ pub async fn file_report( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - // Redirect back to the thread using the DB-resolved IDs, not the form values. - let safe_board = form - .board - .chars() - .filter(|c| c.is_ascii_alphanumeric()) - .take(8) - .collect::(); + // Redirect back to the thread using the DB-resolved IDs. + // `board_raw` is already sanitised to alphanumeric earlier in this handler. Ok(Redirect::to(&format!( - "/{}/thread/{}#p{}", - safe_board, db_thread_id, form.post_id + "/{board_raw}/thread/{db_thread_id}#p{}", + form.post_id )) .into_response()) } @@ -834,14 +686,21 @@ pub async fn serve_board_media( use tower::ServiceExt; use tower_http::services::ServeFile; - // Reject path-traversal attempts. - if media_path.contains("..") { + // Reject path-traversal attempts and absolute-path escapes. + if media_path.contains("..") || media_path.starts_with('/') { return StatusCode::BAD_REQUEST.into_response(); } let base = PathBuf::from(&CONFIG.upload_dir); let target = base.join(&media_path); + // Verify the resolved path is still inside the upload directory. + // This catches any edge cases that slip past the string checks above + // (e.g. symlinks, exotic percent-encoding handled by the OS). + if !target.starts_with(&base) { + return StatusCode::BAD_REQUEST.into_response(); + } + if target.exists() { // File present — forward the real request (with Range, ETag, etc.) to // ServeFile so it can respond with 206 Partial Content when needed. @@ -849,16 +708,19 @@ pub async fn serve_board_media( // the request headers caused it to receive 200 instead of 206 and // refuse playback on videos it tried to stream in chunks. let req = req.map(|_| axum::body::Body::empty()); - match ServeFile::new(&target).oneshot(req).await { - Ok(resp) => resp.map(axum::body::Body::new).into_response(), - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } - } else if media_path.ends_with(".mp4") { + ServeFile::new(&target).oneshot(req).await.map_or_else( + |_| StatusCode::INTERNAL_SERVER_ERROR.into_response(), + |resp| resp.map(axum::body::Body::new).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_abs = base.join(&webm_path_str); if webm_abs.exists() { - Redirect::permanent(&format!("/boards/{}", webm_path_str)).into_response() + Redirect::permanent(&format!("/boards/{webm_path_str}")).into_response() } else { StatusCode::NOT_FOUND.into_response() } @@ -887,8 +749,6 @@ pub async fn api_post_preview( State(state): State, Path((board_short, post_id)): Path<(String, i64)>, ) -> impl axum::response::IntoResponse { - use axum::http::header; - let result = tokio::task::spawn_blocking({ let pool = state.db.clone(); move || -> crate::error::Result> { @@ -903,11 +763,14 @@ pub async fn api_post_preview( let html = crate::templates::render_post( &p, &board_short, - "", // no CSRF needed — preview is read-only - false, // no delete controls - false, // not admin - true, // show media thumbnail - 0, // no edit window + "", + crate::templates::thread::RenderPostOpts { + show_delete: false, + is_admin: false, + show_media: true, + allow_editing: false, // no edit link in read-only preview + }, + 0, // no edit window ); Ok(Some((html, thread_id))) } @@ -962,15 +825,24 @@ pub async fn redirect_to_post( }) .await; - match result { - Ok(Ok(Some(thread_id))) => { - let url = format!("/{}/thread/{}#p{}", board_short_for_url, thread_id, post_id); - Redirect::to(&url).into_response() - } - _ => { - // Post not found or wrong board — return a plain 404. - axum::http::StatusCode::NOT_FOUND.into_response() - } + if let Ok(Ok(Some(thread_id))) = result { + let url = format!("/{board_short_for_url}/thread/{thread_id}#p{post_id}"); + Redirect::to(&url).into_response() + } else { + // Post not found or wrong board — render the error page template + // so the user gets a readable message instead of a blank HTTP 404. + // This is the fallback path when JavaScript is disabled or when + // a user manually navigates to a quotelink URL after a board + // restore that assigned new IDs to the restored posts. + let html = crate::templates::error_page( + 404, + &format!("Post #{post_id} not found. It may have been deleted or the board was restored from a backup."), + ); + ( + axum::http::StatusCode::NOT_FOUND, + axum::response::Html(html), + ) + .into_response() } } @@ -981,7 +853,7 @@ pub async fn redirect_to_post( #[derive(serde::Deserialize)] pub struct AppealForm { pub reason: String, - pub _csrf: Option, + pub csrf: Option, } pub async fn submit_appeal( @@ -993,10 +865,8 @@ pub async fn submit_appeal( use axum::response::Html; 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(""), - ) { + if !crate::middleware::validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) + { return Html(crate::templates::error_page(403, "CSRF token mismatch.")).into_response(); } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 5ed113a..796d09c 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -7,10 +7,47 @@ pub mod thread; // Both create_thread and post_reply parse the same multipart fields. // This helper consolidates that duplicated logic into one place. +use crate::config::CONFIG; use crate::error::{AppError, Result}; use crate::middleware::validate_csrf; +use crate::workers::JobQueue; use axum::extract::Multipart; +// ─── Streaming multipart size limit ────────────────────────────────────────── +// +// 3.1: The previous implementation called `field.bytes().await` which buffers +// the entire file in memory before any size check, allowing a malicious client +// to exhaust server RAM with a multi-GB upload. +// +// `read_field_bytes` replaces it with a streaming read that accumulates chunks +// and aborts — returning HTTP 413 — the moment the running total exceeds the +// configured limit. The limit used is the largest allowed media size so that +// any single field is capped. +// +// 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. + +async fn read_field_bytes( + mut field: axum::extract::multipart::Field<'_>, + max_bytes: usize, +) -> Result> { + let mut buf: Vec = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|e| AppError::BadRequest(e.to_string()))? + { + if buf.len() + chunk.len() > max_bytes { + return Err(AppError::UploadTooLarge(format!( + "File too large. Maximum upload size is {} MiB.", + max_bytes / 1024 / 1024 + ))); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + /// Parsed fields from a post/thread creation multipart form. pub struct PostFormData { pub csrf_verified: bool, @@ -29,12 +66,13 @@ pub struct PostFormData { pub poll_duration_secs: Option, /// Sage — when true the reply must not bump the thread. pub sage: bool, - /// PoW CAPTCHA nonce — submitted by the thread-creation form when enabled. + /// `PoW` CAPTCHA nonce — submitted by the thread-creation form when enabled. pub pow_nonce: String, } /// Drain all fields from a multipart form into [`PostFormData`]. /// `csrf_cookie` is the value from the browser cookie for CSRF verification. +#[allow(clippy::too_many_lines)] pub async fn parse_post_multipart( mut multipart: Multipart, csrf_cookie: Option<&str>, @@ -111,22 +149,17 @@ pub async fn parse_post_multipart( } Some("file") => { let fname = field.file_name().unwrap_or("upload").to_string(); - let bytes = field - .bytes() - .await - .map_err(|e| AppError::BadRequest(format!("File read error: {e}")))?; - if !bytes.is_empty() { - file = Some((bytes.to_vec(), fname)); + let max = CONFIG.max_video_size.max(CONFIG.max_audio_size); + let data = read_field_bytes(field, max).await?; + if !data.is_empty() { + file = Some((data, fname)); } } Some("audio_file") => { let fname = field.file_name().unwrap_or("audio").to_string(); - let bytes = field - .bytes() - .await - .map_err(|e| AppError::BadRequest(format!("Audio file read error: {e}")))?; - if !bytes.is_empty() { - audio_file = Some((bytes.to_vec(), fname)); + let data = read_field_bytes(field, CONFIG.max_audio_size).await?; + if !data.is_empty() { + audio_file = Some((data, fname)); } } _ => { @@ -138,7 +171,9 @@ pub async fn parse_post_multipart( // Convert duration value + unit → seconds (saturating to prevent overflow). // The unit is validated against an explicit allow-list (case-insensitive) so // that a tampered form field does not silently multiply by an arbitrary factor. - let poll_duration_secs = if !poll_question.trim().is_empty() { + let poll_duration_secs = if poll_question.trim().is_empty() { + None + } else { match poll_duration_value { None => None, Some(v) => { @@ -148,17 +183,12 @@ pub async fn parse_post_multipart( "hours" => v.saturating_mul(3600), "days" => v.saturating_mul(86_400), other => { - return Err(AppError::BadRequest(format!( - "Invalid poll duration unit '{}'. Use 'minutes', 'hours', or 'days'.", - other - ))); + return Err(AppError::BadRequest(format!("Invalid poll duration unit '{other}'. Use 'minutes', 'hours', or 'days'."))); } }; Some(secs) } } - } else { - None }; Ok(PostFormData { @@ -181,12 +211,12 @@ pub async fn parse_post_multipart( /// Convert an anyhow error from `save_upload` into the most appropriate /// `AppError` variant, giving clients accurate HTTP status codes: -/// • "File too large" → 413 UploadTooLarge -/// • "Insufficient disk space" → 413 UploadTooLarge -/// • "File type not allowed" → 415 InvalidMediaType -/// • "Not an audio file" → 415 InvalidMediaType -/// • anything else → 400 BadRequest -pub fn classify_upload_error(e: anyhow::Error) -> AppError { +/// • "File too large" → 413 `UploadTooLarge` +/// • "Insufficient disk space" → 413 `UploadTooLarge` +/// • "File type not allowed" → 415 `InvalidMediaType` +/// • "Not an audio file" → 415 `InvalidMediaType` +/// • anything else → 400 `BadRequest` +pub fn classify_upload_error(e: &anyhow::Error) -> AppError { let msg = e.to_string(); // Compare lower-cased so minor wording changes in save_upload don't silently // fall through to a generic 400 instead of the correct 413 / 415. @@ -199,3 +229,194 @@ pub fn classify_upload_error(e: anyhow::Error) -> AppError { AppError::BadRequest(msg) } } + +// ─── Shared media upload processing (R2-2) ─────────────────────────────────── +// +// create_thread (board.rs) and post_reply (thread.rs) had identical blocks for: +// 1. Magic-byte mime detection + per-board toggle enforcement +// 2. SHA-256 deduplication lookup +// 3. save_upload / save_audio_with_image_thumb +// 4. record_file_hash +// 5. Image+audio combo validation +// 6. Background job enqueueing +// +// Both handlers now call these shared functions instead of duplicating the code. + +use crate::models::Board; + +/// Process the primary file upload for a new post: detect mime type, enforce +/// per-board media toggles, SHA-256 dedup, save to disk and record hash. +/// +/// Returns `Ok(None)` when `file_data` is `None` (no file attached). +/// Must be called from inside a `spawn_blocking` closure. +pub fn process_primary_upload( + file_data: Option<(Vec, String)>, + board: &Board, + conn: &rusqlite::Connection, + upload_dir: &str, + thumb_size: u32, + max_image_size: usize, + max_video_size: usize, + max_audio_size: usize, + ffmpeg_available: bool, +) -> Result> { + let Some((data, fname)) = file_data else { + return Ok(None); + }; + let detected_mime = crate::utils::files::detect_mime_type(&data) + .map_err(|e| AppError::BadRequest(e.to_string()))?; + let detected_media = crate::models::MediaType::from_mime(detected_mime) + .ok_or_else(|| AppError::BadRequest("Unsupported file type.".into()))?; + + match detected_media { + crate::models::MediaType::Image if !board.allow_images => { + return Err(AppError::BadRequest( + "Image uploads are disabled on this board.".into(), + )) + } + crate::models::MediaType::Video if !board.allow_video => { + return Err(AppError::BadRequest( + "Video uploads are disabled on this board.".into(), + )) + } + crate::models::MediaType::Audio if !board.allow_audio => { + return Err(AppError::BadRequest( + "Audio uploads are disabled on this board.".into(), + )) + } + crate::models::MediaType::Image + | crate::models::MediaType::Video + | crate::models::MediaType::Audio => {} + } + + // SHA-256 deduplication — serve the cached entry without re-saving. + 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 f = crate::utils::files::save_upload( + &data, + &fname, + upload_dir, + &board.short_name, + thumb_size, + max_image_size, + max_video_size, + max_audio_size, + ffmpeg_available, + ) + .map_err(|e| classify_upload_error(&e))?; + crate::db::record_file_hash(conn, &hash, &f.file_path, &f.thumb_path, &f.mime_type)?; + Ok(Some(f)) +} + +/// Process the secondary audio file for an image+audio combo upload. +/// `primary_upload` must already be the processed primary image. +/// +/// Returns `Ok(None)` when `audio_file_data` is `None`. +/// Must be called from inside a `spawn_blocking` closure. +pub fn process_audio_combo( + audio_file_data: Option<(Vec, String)>, + primary_upload: Option<&crate::utils::files::UploadedFile>, + board: &Board, + upload_dir: &str, + max_audio_size: usize, +) -> Result> { + let Some((aud_data, aud_fname)) = audio_file_data else { + return Ok(None); + }; + + if !board.allow_audio { + return Err(AppError::BadRequest( + "Audio uploads are disabled on this board.".into(), + )); + } + + // Audio combo requires the primary file to be an image. + let primary_is_image = + primary_upload.is_some_and(|u| matches!(u.media_type, crate::models::MediaType::Image)); + if !primary_is_image { + return Err(AppError::BadRequest( + "Audio can only be combined with an image upload.".into(), + )); + } + + // Confirm the secondary file is actually audio. + let aud_mime = crate::utils::files::detect_mime_type(&aud_data) + .map_err(|e| AppError::BadRequest(e.to_string()))?; + let aud_media = crate::models::MediaType::from_mime(aud_mime) + .ok_or_else(|| AppError::BadRequest("Unsupported audio type.".into()))?; + if !matches!(aud_media, crate::models::MediaType::Audio) { + return Err(AppError::BadRequest( + "The audio slot only accepts audio files.".into(), + )); + } + + let mut aud_file = crate::utils::files::save_audio_with_image_thumb( + &aud_data, + &aud_fname, + upload_dir, + &board.short_name, + max_audio_size, + ) + .map_err(|e| classify_upload_error(&e))?; + + // Use the image thumbnail as the audio's visual. + if let Some(img) = primary_upload { + aud_file.thumb_path.clone_from(&img.thumb_path); + } + Ok(Some(aud_file)) +} + +/// Enqueue background media-processing and spam-check jobs for a newly created +/// post. Shared by `create_thread` and `post_reply`. +pub fn enqueue_post_jobs( + job_queue: &JobQueue, + post_id: i64, + ip_hash: &str, + body_len: usize, + uploaded: Option<&crate::utils::files::UploadedFile>, + board_short: &str, +) { + // 1. Media post-processing (video transcode / audio waveform) + if let Some(up) = uploaded { + if up.processing_pending { + let job = match up.media_type { + crate::models::MediaType::Video => Some(crate::workers::Job::VideoTranscode { + post_id, + file_path: up.file_path.clone(), + board_short: board_short.to_string(), + }), + crate::models::MediaType::Audio => Some(crate::workers::Job::AudioWaveform { + post_id, + file_path: up.file_path.clone(), + board_short: board_short.to_string(), + }), + crate::models::MediaType::Image => None, + }; + if let Some(j) = job { + if let Err(e) = job_queue.enqueue(&j) { + tracing::warn!("Failed to enqueue media job: {e}"); + } + } + } + } + + // 2. Spam analysis + let _ = job_queue.enqueue(&crate::workers::Job::SpamCheck { + post_id, + ip_hash: ip_hash.to_string(), + body_len, + }); +} diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index aacaadf..6f44dd8 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -12,8 +12,7 @@ use crate::{ handlers::{board::ensure_csrf, parse_post_multipart}, middleware::{validate_csrf, AppState}, utils::{ - crypto::{hash_ip, new_deletion_token, sha256_hex, verify_pow}, - files::save_upload, + crypto::{hash_ip, new_deletion_token, verify_pow}, sanitize::{ apply_word_filters, escape_html, render_post_body, validate_body, validate_body_with_file, validate_name, @@ -23,6 +22,7 @@ use crate::{ }; use axum::{ extract::{Form, Multipart, Path, Query, State}, + http::HeaderMap, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::cookie::CookieJar; @@ -31,66 +31,100 @@ use tracing::info; // ─── GET /:board/thread/:id ─────────────────────────────────────────────────── +#[allow(clippy::too_many_lines)] pub async fn view_thread( State(state): State, Path((board_short, thread_id)): Path<(String, i64)>, crate::middleware::ClientIp(client_ip): crate::middleware::ClientIp, jar: CookieJar, -) -> Result<(CookieJar, Html)> { + req_headers: HeaderMap, +) -> Result { let (jar, csrf) = ensure_csrf(jar); - let html = tokio::task::spawn_blocking({ + let result = tokio::task::spawn_blocking({ 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 { + move || -> Result<(String, String)> { let conn = pool.get()?; let is_admin = jar_session .as_deref() - .map(|sid| db::get_session(&conn, sid).ok().flatten().is_some()) - .unwrap_or(false); + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); let board = db::get_board_by_short(&conn, &board_short)? .ok_or_else(|| AppError::NotFound(format!("Board /{board_short}/ not found")))?; let thread = db::get_thread(&conn, thread_id)? - .ok_or_else(|| AppError::NotFound(format!("Thread {} not found", thread_id)))?; + .ok_or_else(|| AppError::NotFound(format!("Thread {thread_id} not found")))?; if thread.board_id != board.id { 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. + let boards_ver = crate::templates::live_boards_version(); + let etag = format!("\"{}-b{boards_ver}\"", thread.bumped_at); + let posts = db::get_posts_for_thread(&conn, thread_id)?; let all_boards = db::get_all_boards(&conn)?; - // Compute ip_hash for poll vote status let ip_hash = crate::utils::crypto::hash_ip(&client_ip, &crate::config::CONFIG.cookie_secret); - let poll = db::get_poll_for_thread(&conn, thread_id, &ip_hash)?; + let thread_poll = db::get_poll_for_thread(&conn, thread_id, &ip_hash)?; let collapse_greentext = db::get_collapse_greentext(&conn); - Ok(crate::templates::thread_page( + let html = crate::templates::thread_page( &board, &thread, &posts, &csrf_clone, &all_boards, is_admin, - poll.as_ref(), + thread_poll.as_ref(), None, collapse_greentext, - )) + ); + Ok((etag, html)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - Ok((jar, Html(html))) + let (etag, html) = result; + + // 3.2: Return 304 Not Modified when client's cached copy is still current. + let client_etag = req_headers + .get("if-none-match") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if client_etag == etag { + let mut resp = axum::http::Response::builder() + .status(axum::http::StatusCode::NOT_MODIFIED) + .body(axum::body::Body::empty()) + .unwrap_or_default(); + resp.headers_mut().insert( + "etag", + axum::http::HeaderValue::from_str(&etag) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("\"0\"")), + ); + return Ok((jar, resp).into_response()); + } + + let mut resp = Html(html).into_response(); + if let Ok(v) = axum::http::HeaderValue::from_str(&etag) { + resp.headers_mut().insert("etag", v); + } + Ok((jar, resp).into_response()) } // ─── POST /:board/thread/:id — post reply ──────────────────────────────────── +#[allow(clippy::too_many_lines)] pub async fn post_reply( State(state): State, Path((board_short, thread_id)): Path<(String, i64)>, @@ -126,8 +160,7 @@ 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(); - let board_short_err = board_short.clone(); - let client_ip_err = client_ip.clone(); // CRIT-2: keep for the error re-render path below + 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(); @@ -163,8 +196,7 @@ pub async fn post_reply( // Verify admin session first; admins bypass the cooldown entirely. let is_admin = admin_session_id .as_deref() - .map(|sid| db::get_session(&conn, sid).ok().flatten().is_some()) - .unwrap_or(false); + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); // post_cooldown_secs = 0 means no cooldown at all on this board. if board.post_cooldown_secs > 0 && !is_admin { @@ -172,11 +204,7 @@ pub async fn post_reply( if let Some(secs) = elapsed { let remaining = board.post_cooldown_secs.saturating_sub(secs); if remaining > 0 { - return Err(AppError::BadRequest(format!( - "Please wait {} more second{} before posting again.", - remaining, - if remaining == 1 { "" } else { "s" } - ))); + return Err(AppError::BadRequest(format!("Please wait {remaining} more second{} before posting again.", if remaining == 1 { "" } else { "s" }))); } } } @@ -220,107 +248,26 @@ pub async fn post_reply( let escaped_body = escape_html(&filtered_body); let body_html = render_post_body(&escaped_body); - let uploaded = if let Some((data, fname)) = file_data { - // Enforce per-board media type toggles using magic-byte detection. - let detected_mime = crate::utils::files::detect_mime_type(&data) - .map_err(|e| AppError::BadRequest(e.to_string()))?; - let detected_media = crate::models::MediaType::from_mime(detected_mime) - .ok_or_else(|| AppError::BadRequest("Unsupported file type.".into()))?; - - match detected_media { - crate::models::MediaType::Image if !board.allow_images => { - return Err(AppError::BadRequest( - "Image uploads are disabled on this board.".into(), - )) - } - crate::models::MediaType::Video if !board.allow_video => { - return Err(AppError::BadRequest( - "Video uploads are disabled on this board.".into(), - )) - } - crate::models::MediaType::Audio if !board.allow_audio => { - return Err(AppError::BadRequest( - "Audio uploads are disabled on this board.".into(), - )) - } - _ => {} - } - - // SHA-256 deduplication — FIX[LOW-8]: use sha256_hex from crypto module - let hash = sha256_hex(&data); - if let Some(cached) = db::find_file_by_hash(&conn, &hash)? { - let cached_media = crate::models::MediaType::from_mime(&cached.mime_type) - .unwrap_or(crate::models::MediaType::Image); - 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: data.len() as i64, - media_type: cached_media, - processing_pending: false, - }) - } else { - let f = save_upload( - &data, - &fname, - &upload_dir, - &board.short_name, - thumb_size, - max_image_size, - max_video_size, - max_audio_size, - ffmpeg_available, - ) - .map_err(crate::handlers::classify_upload_error)?; - db::record_file_hash(&conn, &hash, &f.file_path, &f.thumb_path, &f.mime_type)?; - Some(f) - } - } else { - None - }; + let uploaded = crate::handlers::process_primary_upload( + file_data, + &board, + &conn, + &upload_dir, + thumb_size, + max_image_size, + max_video_size, + max_audio_size, + ffmpeg_available, + )?; // ── Image+audio combo ───────────────────────────────────────────── - let audio_uploaded: Option = - if let Some((aud_data, aud_fname)) = audio_file_data { - if !board.allow_audio { - return Err(AppError::BadRequest( - "Audio uploads are disabled on this board.".into(), - )); - } - let primary_is_image = uploaded - .as_ref() - .map(|u| matches!(u.media_type, crate::models::MediaType::Image)) - .unwrap_or(false); - if !primary_is_image { - return Err(AppError::BadRequest( - "Audio can only be combined with an image upload.".into(), - )); - } - let aud_mime = crate::utils::files::detect_mime_type(&aud_data) - .map_err(|e| AppError::BadRequest(e.to_string()))?; - let aud_media = crate::models::MediaType::from_mime(aud_mime) - .ok_or_else(|| AppError::BadRequest("Unsupported audio type.".into()))?; - if !matches!(aud_media, crate::models::MediaType::Audio) { - return Err(AppError::BadRequest( - "The audio slot only accepts audio files.".into(), - )); - } - let mut aud_file = crate::utils::files::save_audio_with_image_thumb( - &aud_data, - &aud_fname, - &upload_dir, - &board.short_name, - max_audio_size, - ) - .map_err(crate::handlers::classify_upload_error)?; - if let Some(ref img) = uploaded { - aud_file.thumb_path = img.thumb_path.clone(); - } - Some(aud_file) - } else { - None - }; + let audio_uploaded = crate::handlers::process_audio_combo( + audio_file_data, + uploaded.as_ref(), + &board, + &upload_dir, + max_audio_size, + )?; let deletion_token = if del_token_val.trim().is_empty() { new_deletion_token() @@ -364,103 +311,31 @@ pub async fn post_reply( )?; } - // ── Background jobs ─────────────────────────────────────────────── - if let Some(ref up) = uploaded { - if up.processing_pending { - let job = match up.media_type { - crate::models::MediaType::Video => { - Some(crate::workers::Job::VideoTranscode { - post_id, - file_path: up.file_path.clone(), - board_short: board.short_name.clone(), - }) - } - crate::models::MediaType::Audio => { - Some(crate::workers::Job::AudioWaveform { - post_id, - file_path: up.file_path.clone(), - board_short: board.short_name.clone(), - }) - } - _ => None, - }; - if let Some(j) = job { - if let Err(e) = job_queue.enqueue(&j) { - tracing::warn!("Failed to enqueue media job for reply: {}", e); - } - } - } - } - let _ = job_queue.enqueue(&crate::workers::Job::SpamCheck { + crate::handlers::enqueue_post_jobs( + &job_queue, post_id, - ip_hash: ip_hash.clone(), - body_len: body_text.len(), - }); - - info!( - "Reply {} posted in thread {} on /{}/", - post_id, thread_id, board.short_name + &ip_hash, + body_text.len(), + uploaded.as_ref(), + &board.short_name, ); - Ok(format!( - "/{}/thread/{}#p{}", - board.short_name, thread_id, post_id - )) + + info!("Reply {post_id} posted in thread {thread_id} on /{}/", board.short_name); + Ok(format!("/{}/thread/{thread_id}#p{post_id}", board.short_name)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - // BadRequest → re-render the thread page with an inline error banner. + // BadRequest → return a lightweight 422 page instead of re-querying the + // entire thread (which wastes significant DB and CPU under spam load). let redirect_url = match result { Ok(url) => url, Err(AppError::BadRequest(msg)) => { - let html = tokio::task::spawn_blocking({ - let pool = state.db.clone(); - let csrf_err = csrf_cookie.clone().unwrap_or_default(); - let board_short = board_short_err.clone(); - let msg = msg.clone(); - move || -> String { - let conn = match pool.get() { - Ok(c) => c, - Err(_) => return String::new(), - }; - let board = match db::get_board_by_short(&conn, &board_short) { - Ok(Some(b)) => b, - _ => return String::new(), - }; - let thread = match db::get_thread(&conn, thread_id) { - Ok(Some(t)) => t, - _ => return String::new(), - }; - let posts = db::get_posts_for_thread(&conn, thread_id).unwrap_or_default(); - let all_boards = db::get_all_boards(&conn).unwrap_or_default(); - let ip_hash = crate::utils::crypto::hash_ip( - &client_ip_err, - &crate::config::CONFIG.cookie_secret, - ); - let poll = db::get_poll_for_thread(&conn, thread_id, &ip_hash) - .ok() - .flatten(); - crate::templates::thread_page( - &board, - &thread, - &posts, - &csrf_err, - &all_boards, - false, - poll.as_ref(), - Some(&msg), - db::get_collapse_greentext(&conn), - ) - } - }) - .await - .unwrap_or_default(); - - if !html.is_empty() { - return Ok((jar, Html(html)).into_response()); - } - return Err(AppError::BadRequest(msg)); + let mut resp = + axum::response::Html(crate::templates::error_page(422, &msg)).into_response(); + *resp.status_mut() = axum::http::StatusCode::UNPROCESSABLE_ENTITY; + return Ok(resp); } Err(e) => return Err(e), }; @@ -540,7 +415,7 @@ pub async fn edit_post_get( #[derive(Deserialize)] pub struct EditForm { - pub _csrf: Option, + pub csrf: Option, pub deletion_token: String, pub body: String, } @@ -560,7 +435,7 @@ pub async fn edit_post_post( Form(form): Form, ) -> Result { let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !validate_csrf(csrf_cookie.as_deref(), form._csrf.as_deref().unwrap_or("")) { + if !validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -640,10 +515,10 @@ pub async fn edit_post_post( return Ok(EditOutcome::ErrorPage(html)); } - info!("Post {} edited on /{}/", post_id, board.short_name); + info!("Post {post_id} edited on /{}/", board.short_name); Ok(EditOutcome::Redirect(format!( - "/{}/thread/{}#p{}", - board.short_name, post.thread_id, post_id + "/{}/thread/{}#p{post_id}", + board.short_name, post.thread_id ))) } }) @@ -660,7 +535,7 @@ pub async fn edit_post_post( #[derive(Deserialize)] pub struct VoteForm { - pub _csrf: Option, + pub csrf: Option, pub option_id: i64, } @@ -671,7 +546,7 @@ pub async fn vote_handler( Form(form): Form, ) -> Result { let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_string()); - if !validate_csrf(csrf_cookie.as_deref(), form._csrf.as_deref().unwrap_or("")) { + if !validate_csrf(csrf_cookie.as_deref(), form.csrf.as_deref().unwrap_or("")) { return Err(AppError::Forbidden("CSRF token mismatch.".into())); } @@ -715,12 +590,10 @@ pub async fn vote_handler( db::cast_vote(&conn, poll_id, option_id, &ip_hash)?; info!( - "Vote cast on poll {} option {} by {}", - poll_id, - option_id, + "Vote cast on poll {poll_id} option {option_id} by {}", &ip_hash[..8] ); - Ok(format!("/{}/thread/{}#poll", board_short, thread_id)) + Ok(format!("/{board_short}/thread/{thread_id}#poll")) } }) .await @@ -774,7 +647,7 @@ pub async fn thread_updates( // Fetch posts newer than `since`, ordered oldest-first so they // render in the correct chronological order when appended. - let posts = db::get_new_posts_since(&conn, thread_id, since)?; + let posts = db::get_new_posts_since(&conn, thread_id, since, 100)?; let last_id = posts.iter().map(|p| p.id).max().unwrap_or(since); let count = posts.len(); @@ -786,10 +659,13 @@ pub async fn thread_updates( post, &board_short, "", - false, - false, - true, - 0, // no edit link in auto-appended HTML; reload restores it + crate::templates::thread::RenderPostOpts { + show_delete: false, + is_admin: false, + show_media: true, + allow_editing: false, // no edit link in auto-appended HTML; reload restores it + }, + 0, )); } @@ -807,10 +683,32 @@ pub async fn thread_updates( .await .map_err(|e| crate::error::AppError::Internal(anyhow::anyhow!(e)))??; + // Current board-list version + rendered nav links — lets the JS refresh + // the nav bar when boards are added or deleted while a thread is open, + // without requiring a full page reload. + let boards_version = crate::templates::live_boards_version(); + let boards = crate::templates::live_boards_snapshot(); + let nav_inner: String = boards + .iter() + .map(|b| { + format!( + r#"{s}"#, + s = crate::utils::sanitize::escape_html(&b.short_name) + ) + }) + .collect::>() + .join(" / "); + let nav_html = if nav_inner.is_empty() { + String::new() + } else { + format!("[ {nav_inner} ]") + }; + // Build a JSON envelope with new-post HTML plus current thread state. - // The client consumes the state fields to keep the nav bar in sync. + // boards_version / nav_html let the client keep the nav bar in sync when + // boards are added or deleted while the user has a thread open. let json = format!( - r#"{{"html":{html_json},"last_id":{last_id},"count":{count},"reply_count":{reply_count},"bump_time":{bump_time},"locked":{locked},"sticky":{sticky}}}"#, + r#"{{"html":{html_json},"last_id":{last_id},"count":{count},"reply_count":{reply_count},"bump_time":{bump_time},"locked":{locked},"sticky":{sticky},"boards_version":{boards_version},"nav_html":{nav_html_json}}}"#, html_json = serde_json::to_string(&html).unwrap_or_else(|_| "\"\"".to_string()), last_id = last_id, count = count, @@ -818,6 +716,8 @@ pub async fn thread_updates( bump_time = bump_time, locked = locked, sticky = sticky, + boards_version = boards_version, + nav_html_json = serde_json::to_string(&nav_html).unwrap_or_else(|_| "\"\"".to_string()), ); Ok(([(header::CONTENT_TYPE, "application/json")], json).into_response()) diff --git a/src/main.rs b/src/main.rs index 3ca1b30..0d446d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,11 +25,13 @@ use axum::{ }; use clap::{Parser, Subcommand}; use dashmap::DashMap; -use once_cell::sync::Lazy; +use std::io::{BufRead, BufReader, Write}; use std::net::SocketAddr; -use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +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}; mod config; @@ -55,15 +57,46 @@ static THEME_INIT_JS: &str = include_str!("../static/theme-init.js"); /// Total HTTP requests handled since startup. pub static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0); /// Requests currently being processed (in-flight). -static IN_FLIGHT: AtomicI64 = AtomicI64::new(0); +/// +/// 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. -static ACTIVE_UPLOADS: AtomicI64 = AtomicI64::new(0); +/// +/// 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: Lazy> = Lazy::new(DashMap::new); +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 ─────────────────────────────────────────────────────────── @@ -136,9 +169,14 @@ enum AdminAction { } // ─── 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] -async fn main() -> anyhow::Result<()> { +fn main() -> anyhow::Result<()> { fmt::fmt() .with_env_filter( EnvFilter::try_from_default_env() @@ -148,22 +186,39 @@ async fn main() -> anyhow::Result<()> { .compact() .init(); + // CONFIG must be initialised before building the runtime so that + // blocking_threads is available. This is safe because CONFIG is a + // LazyLock that initialises itself on first access. + let blocking_threads = CONFIG.blocking_threads; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .max_blocking_threads(blocking_threads) + .build() + .expect("Failed to build Tokio runtime"); + let cli = Cli::parse(); - 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), - } + 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| p.to_path_buf())) + .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") }; @@ -192,20 +247,20 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { print_banner(); - let bind_addr: String = if let Some(p) = port_override { - // 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(|(h, _)| h) - .unwrap_or("0.0.0.0"); - format!("{}:{}", host, p) - } else { - CONFIG.bind_addr.clone() - }; + 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)?; @@ -219,17 +274,39 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { // Initialise the live site name and subtitle from DB so they're available before any request. { if let Ok(conn) = pool.get() { - let name = db::get_site_name(&conn); + // 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. - let subtitle = db::get_site_subtitle(&conn); - let subtitle = if subtitle.is_empty() && !CONFIG.initial_site_subtitle.is_empty() { - let _ = db::set_site_setting(&conn, "site_subtitle", &CONFIG.initial_site_subtitle); - CONFIG.initial_site_subtitle.clone() - } else { - subtitle - }; + // 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. @@ -244,6 +321,11 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { 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 ──────────────────────────────────────────────── @@ -269,7 +351,7 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { ffmpeg_available, job_queue: { let q = std::sync::Arc::new(workers::JobQueue::new(pool.clone())); - workers::start_worker_pool(q.clone(), ffmpeg_available); + workers::start_worker_pool(&q, ffmpeg_available); q }, backup_progress: std::sync::Arc::new(middleware::BackupProgress::new()), @@ -287,9 +369,9 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { iv.tick().await; if let Ok(conn) = bg.get() { match db::purge_expired_sessions(&conn) { - Ok(n) if n > 0 => info!("Purged {} expired sessions", n), - Err(e) => tracing::error!("Session purge error: {}", e), - _ => {} + Ok(n) if n > 0 => info!("Purged {n} expired sessions"), + Err(e) => tracing::error!("Session purge error: {e}"), + Ok(_) => {} } } } @@ -312,14 +394,9 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { if let Ok(conn) = bg.get() { match db::run_wal_checkpoint(&conn) { Ok((pages, moved, backfill)) => { - tracing::debug!( - "WAL checkpoint: {} pages total, {} moved, {} backfilled", - pages, - moved, - backfill - ); + tracing::debug!("WAL checkpoint: {pages} pages total, {moved} moved, {backfill} backfilled"); } - Err(e) => tracing::warn!("WAL checkpoint failed: {}", e), + 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 @@ -335,15 +412,117 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { let mut iv = tokio::time::interval(Duration::from_secs(300)); loop { iv.tick().await; - let cutoff = Instant::now() - Duration::from_secs(300); + 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://{}/admin", bind_addr); + info!("Listening on http://{bind_addr}"); + info!("Admin panel http://{bind_addr}/admin"); info!("Data dir {}", data_dir.display()); println!(); @@ -366,6 +545,7 @@ async fn run_server(port_override: Option) -> anyhow::Result<()> { Ok(()) } +#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router { Router::new() .route("/static/style.css", get(serve_css)) @@ -373,12 +553,22 @@ fn build_router(state: AppState) -> Router { .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)) + .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)) + .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), @@ -513,6 +703,12 @@ fn build_router(state: AppState) -> Router { .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( @@ -578,8 +774,7 @@ async fn hsts_middleware( .headers() .get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) - .map(|v| v.eq_ignore_ascii_case("https")) - .unwrap_or(false); + .is_some_and(|v| v.eq_ignore_ascii_case("https")); let mut resp = next.run(req).await; if is_https { @@ -639,6 +834,10 @@ async fn track_requests( 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(); @@ -675,22 +874,16 @@ async fn track_requests( .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) - .map(|ct| ct.contains("multipart/form-data")) - .unwrap_or(false); + .is_some_and(|ct| ct.contains("multipart/form-data")); - if is_upload { + // 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); - } - - use tracing::Instrument as _; - let resp = next.run(req).instrument(span).await; - - IN_FLIGHT.fetch_sub(1, Ordering::Relaxed); - if is_upload { - ACTIVE_UPLOADS.fetch_sub(1, Ordering::Relaxed); - } + ScopedDecrement(&ACTIVE_UPLOADS) + }); - resp + next.run(req).instrument(span).await } // ─── First-run check ───────────────────────────────────────────────────────── @@ -732,6 +925,7 @@ struct TermStats { last_tick: Instant, } +#[allow(clippy::too_many_lines)] fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { // Uptime let uptime = start.elapsed(); @@ -744,34 +938,36 @@ fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { 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) = if let Ok(conn) = pool.get() { - 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)) + 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 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) - } else { - (0, 0, 0, 0, vec![]) - }; + 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); @@ -779,28 +975,35 @@ fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { 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 {} (+{})\x1b[0m", threads, new_threads) + format!("\x1b[1;33mthreads {threads} (+{new_threads})\x1b[0m") } else { - format!("threads {}", threads) + format!("threads {threads}") }; let post_str = if new_posts > 0 { - format!("\x1b[1;33mposts {} (+{})\x1b[0m", posts, new_posts) + format!("\x1b[1;33mposts {posts} (+{new_posts})\x1b[0m") } else { - format!("posts {}", posts) + format!("posts {posts}") }; ts.prev_thread_count = threads; ts.prev_post_count = posts; // Active connections / users online - let in_flight = IN_FLIGHT.load(Ordering::Relaxed).max(0) as u64; + // 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| e.key()[..8].to_string()) + .map(|e| { + let key = e.key(); + key.get(..8).unwrap_or(key.as_str()).to_string() + }) .collect(); - hashes.sort(); + hashes.sort_unstable(); hashes.truncate(5); if hashes.is_empty() { "none".into() @@ -810,7 +1013,8 @@ fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { }; // Upload progress bar — shown only while uploads are active - let active_uploads = ACTIVE_UPLOADS.load(Ordering::Relaxed).max(0) as u64; + // 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, @@ -818,42 +1022,36 @@ fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { let tick = SPINNER_TICK.fetch_add(1, Ordering::Relaxed); let spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let spin = spinners - .get((tick as usize) % spinners.len()) + .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{} UPLOAD [{}] {} file(s) uploading\x1b[0m", - spin, bar, active_uploads - ); + println!(" \x1b[36m{spin} UPLOAD [{bar}] {active_uploads} file(s) uploading\x1b[0m"); } // Main stats line - println!( - "── STATS uptime {h}h{m:02}m │ requests {} │ \x1b[32m{:.1} req/s\x1b[0m │ in-flight {} │ boards {} {} {} │ db {} KiB uploads {:.1} MiB ──", - curr_reqs, rps, in_flight, boards, thread_str, post_str, db_kb, upload_mb + #[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 - println!( - " users online: {} │ IPs: {} │ mem: {} KiB RSS", - online_count, - ip_list, - process_rss_kb() - ); + 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!("/{}/ threads:{} posts:{}", short, t, p)) + .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); + println!("{line}"); line = String::from(" "); line_len = 0; } @@ -865,14 +1063,14 @@ fn print_stats(pool: &db::DbPool, start: Instant, ts: &mut TermStats) { line_len += seg.len(); } if line_len > 0 { - println!("{}", line); + println!("{line}"); } } } /// Read the process RSS (resident set size) in KiB. /// -/// * Linux — parsed from `/proc/self/status` (VmRSS field, already 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. @@ -883,12 +1081,11 @@ fn process_rss_kb() -> u64 { 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:") { - let kb: u64 = val + return val .split_whitespace() .next() .and_then(|n| n.parse().ok()) .unwrap_or(0); - return kb; } } } @@ -911,16 +1108,15 @@ fn process_rss_kb() -> u64 { } fn get_per_board_stats(conn: &rusqlite::Connection) -> Vec<(String, i64, i64)> { - let mut stmt = match conn.prepare( + 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", - ) { - Ok(s) => s, - Err(_) => return vec![], + ) else { + return vec![]; }; stmt.query_map([], |row| { Ok(( @@ -934,7 +1130,9 @@ fn get_per_board_stats(conn: &rusqlite::Connection) -> Vec<(String, i64, i64)> { } fn dir_size_mb(path: &str) -> f64 { - walkdir_size(std::path::Path::new(path)) as f64 / (1024.0 * 1024.0) + #[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 { @@ -947,7 +1145,7 @@ fn walkdir_size(path: &std::path::Path) -> u64 { // 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().map(|ft| ft.is_dir()).unwrap_or(false); + let is_real_dir = e.file_type().is_ok_and(|ft| ft.is_dir()); if is_real_dir { walkdir_size(&e.path()) } else { @@ -965,21 +1163,22 @@ fn print_banner() { // │ character is always aligned regardless of the actual value length. const INNER: usize = 53; - // Truncate `s` to `width` chars, then right-pad with spaces to `width`. + // 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 chars: Vec = s.chars().collect(); - if chars.len() >= width { - chars - .get(..width) - .map(|s| s.iter().collect()) - .unwrap_or_else(|| s.clone()) + let char_count = s.chars().count(); + if char_count >= width { + s.chars().take(width).collect() } else { - format!("{}{}", s, " ".repeat(width - chars.len())) + format!("{s}{}", " ".repeat(width - char_count)) } }; let title = cell( - format!("{} v{}", CONFIG.forum_name, env!("CARGO_PKG_VERSION")), + 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>│" @@ -988,33 +1187,32 @@ fn print_banner() { let img_mib = CONFIG.max_image_size / 1024 / 1024; let vid_mib = CONFIG.max_video_size / 1024 / 1024; let limits = cell( - format!("Images {} MiB max │ Videos {} MiB max", img_mib, vid_mib), + format!("Images {img_mib} MiB max │ Videos {vid_mib} MiB max"), INNER - 4, // "│ <val> │" ); println!("┌─────────────────────────────────────────────────────┐"); - println!("│ {}│", title); + println!("│ {title}│"); println!("├─────────────────────────────────────────────────────┤"); - println!("│ Bind {}│", bind); - println!("│ DB {}│", db); - println!("│ Uploads {}│", upl); - println!("│ {} │", limits); + 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 || { - use std::io::{BufRead, BufReader, Write}; - // Small delay so startup messages settle first std::thread::sleep(Duration::from_millis(600)); print_keyboard_help(); let stdin = std::io::stdin(); - let handle = stdin.lock(); - let mut reader = BufReader::new(handle); // Fix #4: TermStats must persist across keypresses so that // prev_req_count/prev_post_count/prev_thread_count reflect the values @@ -1028,12 +1226,17 @@ fn spawn_keyboard_handler(pool: db::DbPool, start_time: Instant) { 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) => break, // EOF — stdin closed (daemon mode) + Ok(0) | Err(_) => break, // EOF — stdin closed (daemon mode) Ok(_) => {} - Err(_) => break, } let cmd = line.trim().to_lowercase(); match cmd.as_str() { @@ -1046,7 +1249,7 @@ fn spawn_keyboard_handler(pool: db::DbPool, start_time: Instant) { "h" => print_keyboard_help(), "q" => println!(" \x1b[33m[!]\x1b[0m Use Ctrl+C or SIGTERM to stop the server."), "" => {} - other => println!(" Unknown command '{}'. Press [h] for help.", other), + other => println!(" Unknown command '{other}'. Press [h] for help."), } let _ = std::io::stdout().flush(); } @@ -1057,12 +1260,8 @@ 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 [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!(); } @@ -1075,7 +1274,7 @@ fn kb_list_boards(pool: &db::DbPool) { let boards = match db::get_all_boards(&conn) { Ok(b) => b, Err(e) => { - println!(" \x1b[31m[err]\x1b[0m {}", e); + println!(" \x1b[31m[err]\x1b[0m {e}"); return; } }; @@ -1098,9 +1297,8 @@ fn kb_list_boards(pool: &db::DbPool) { } fn kb_create_board(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { - use std::io::Write; let mut prompt = |msg: &str| -> String { - print!(" \x1b[36m{}\x1b[0m ", msg); + print!(" \x1b[36m{msg}\x1b[0m "); let _ = std::io::stdout().flush(); let mut s = String::new(); let _ = reader.read_line(&mut s); @@ -1112,6 +1310,20 @@ fn kb_create_board(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { 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."); @@ -1131,15 +1343,6 @@ fn kb_create_board(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { 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 short_lc = short.to_lowercase(); - if !short_lc.chars().all(|c| c.is_ascii_alphanumeric()) - || short_lc.is_empty() - || short_lc.len() > 8 - { - println!(" \x1b[31m[err]\x1b[0m Short name must be 1-8 alphanumeric characters."); - return; - } - let Ok(conn) = pool.get() else { println!(" \x1b[31m[err]\x1b[0m Could not get DB connection."); return; @@ -1164,27 +1367,24 @@ fn kb_create_board(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { if allow_video { "yes" } else { "no" }, if allow_audio { "yes" } else { "no" }, ), - Err(e) => println!(" \x1b[31m[err]\x1b[0m {}", e), + Err(e) => println!(" \x1b[31m[err]\x1b[0m {e}"), } println!(); } fn kb_delete_thread(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { - use std::io::Write; - 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 = match s.trim().parse() { - Ok(n) => n, - Err(_) => { - println!( - " \x1b[31m[err]\x1b[0m '{}' is not a valid thread ID.", - s.trim() - ); - return; - } + 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 { @@ -1199,14 +1399,11 @@ fn kb_delete_thread(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { ) .unwrap_or(0); if exists == 0 { - println!(" \x1b[31m[err]\x1b[0m Thread {} not found.", thread_id); + println!(" \x1b[31m[err]\x1b[0m Thread {thread_id} not found."); return; } - print!( - " \x1b[33mDelete thread {} and all its posts? [y/N]:\x1b[0m ", - thread_id - ); + 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); @@ -1226,7 +1423,7 @@ fn kb_delete_thread(pool: &db::DbPool, reader: &mut dyn std::io::BufRead) { paths.len() ); } - Err(e) => println!(" \x1b[31m[err]\x1b[0m {}", e), + Err(e) => println!(" \x1b[31m[err]\x1b[0m {e}"), } println!(); } @@ -1237,7 +1434,7 @@ 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); + tracing::error!("Failed to listen for Ctrl+C: {e}"); } }; #[cfg(unix)] @@ -1247,7 +1444,7 @@ async fn shutdown_signal() { sig.recv().await; } Err(e) => { - tracing::error!("Failed to register SIGTERM handler: {}", e); + tracing::error!("Failed to register SIGTERM handler: {e}"); std::future::pending::<()>().await; } } @@ -1255,21 +1452,32 @@ async fn shutdown_signal() { #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { - _ = ctrl_c => info!("Received Ctrl+C"), - _ = terminate => info!("Received SIGTERM"), + () = 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); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent)?; - } + + // 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()?; @@ -1279,7 +1487,7 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { validate_password(&password)?; let hash = crypto::hash_password(&password)?; let id = db::create_admin(&conn, &username, &hash)?; - println!("✓ Admin '{}' created (id={}).", username, id); + println!("✓ Admin '{username}' created (id={id})."); } AdminAction::ResetPassword { username, @@ -1287,10 +1495,10 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { } => { validate_password(&new_password)?; db::get_admin_by_username(&conn, &username)? - .ok_or_else(|| anyhow::anyhow!("Admin '{}' not found.", 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); + println!("✓ Password updated for '{username}'."); } AdminAction::ListAdmins => { let rows = db::list_admins(&conn)?; @@ -1303,9 +1511,8 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { let date = chrono::Utc .timestamp_opt(*ts, 0) .single() - .map(|d| d.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| "?".to_string()); - println!("{:<6} {:<24} {}", id, user, date); + .map_or_else(|| "?".to_string(), |d| d.format("%Y-%m-%d").to_string()); + println!("{id:<6} {user:<24} {date}"); } } } @@ -1319,9 +1526,9 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { no_audio, } => { let short = short.to_lowercase(); - if !short.chars().all(|c| c.is_ascii_alphanumeric()) - || short.is_empty() + 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')."); } @@ -1351,7 +1558,6 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { 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: "); - use std::io::Write; std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; @@ -1391,8 +1597,10 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { 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(|d| d.format("%Y-%m-%d %H:%M UTC").to_string()) - .unwrap_or_else(|| "permanent".to_string()); + .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 } => { @@ -1410,19 +1618,19 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { ); println!("{}", "-".repeat(75)); for b in &bans { - let partial = &b.ip_hash[..b.ip_hash.len().min(16)]; + // 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(|d| d.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "Permanent".to_string()); - println!( - "{:<5} {:<18} {:<28} {}", - b.id, - partial, - b.reason.as_deref().unwrap_or(""), - expires - ); + .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}"); } } } @@ -1430,6 +1638,100 @@ fn run_admin(action: AdminAction) -> anyhow::Result<()> { 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."); diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index d9e5672..b87d064 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -42,12 +42,12 @@ use axum::{ response::{IntoResponse, Response}, }; use dashmap::DashMap; -use once_cell::sync::Lazy; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; -/// Global rate limit table: ip_key → (request_count, window_start_secs) -static RATE_TABLE: Lazy<DashMap<String, (u32, u64)>> = Lazy::new(DashMap::new); +/// Global rate limit table: `ip_key` → (`request_count`, `window_start_secs`) +static RATE_TABLE: LazyLock<DashMap<String, (u32, u64)>> = LazyLock::new(DashMap::new); /// FIX[MEDIUM-4]: Track the last time we ran a full cleanup so we can also /// clean on a time basis, not just when the table exceeds a size threshold. @@ -63,7 +63,7 @@ static LAST_CLEANUP_SECS: AtomicU64 = AtomicU64::new(0); // `reset()` are guaranteed visible to any reader that subsequently loads the // new phase value. -/// Phase codes stored in BackupProgress::phase. +/// Phase codes stored in `BackupProgress::phase`. pub mod backup_phase { pub const IDLE: u64 = 0; pub const SNAPSHOT_DB: u64 = 1; @@ -75,7 +75,7 @@ pub mod backup_phase { } /// Shared atomic progress state for backup operations. -/// Stored as Arc<BackupProgress> in AppState so admin handlers and the +/// Stored as Arc<BackupProgress> in `AppState` so admin handlers and the /// progress endpoint can both access it without locking. pub struct BackupProgress { pub phase: std::sync::atomic::AtomicU64, @@ -86,7 +86,7 @@ pub struct BackupProgress { } impl BackupProgress { - pub fn new() -> Self { + pub const fn new() -> Self { use std::sync::atomic::AtomicU64; Self { phase: AtomicU64::new(backup_phase::IDLE), @@ -119,7 +119,7 @@ impl BackupProgress { #[derive(Clone)] pub struct AppState { pub db: crate::db::DbPool, - /// True when ffmpeg was detected at startup (set by detect::detect_ffmpeg). + /// 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, /// Background job queue — enqueue CPU-heavy work here instead of blocking @@ -195,11 +195,10 @@ pub async fn rate_limit_middleware(req: Request, next: Next) -> Response { .headers() .get(axum::http::header::COOKIE) .and_then(|v| v.to_str().ok()) - .map(|s| { + .is_some_and(|s| { s.split(';') .any(|pair| pair.trim().starts_with("chan_admin_session=")) - }) - .unwrap_or(false); + }); if has_admin_cookie { return next.run(req).await; } @@ -219,10 +218,9 @@ pub async fn rate_limit_middleware(req: Request, next: Next) -> Response { // Check and update rate limit counter let blocked = { - let mut entry = RATE_TABLE.entry(ip_key.clone()).or_insert((0, now)); - let (count, window_start) = entry.value_mut(); - - if now.saturating_sub(*window_start) > window { + let mut binding = RATE_TABLE.entry(ip_key.clone()).or_insert((0, now)); + let (count, window_start) = binding.value_mut(); + let result = if now.saturating_sub(*window_start) > window { // Window has expired, reset *count = 1; *window_start = now; @@ -230,7 +228,9 @@ pub async fn rate_limit_middleware(req: Request, next: Next) -> Response { } else { *count += 1; *count > limit - } + }; + drop(binding); + result }; if blocked { @@ -300,7 +300,7 @@ fn rate_limited_toast_page() -> String { <html lang="en"> <head> <meta charset="utf-8"> -<title>Slow down — {forum_name} +Slow down — {forum_name_escaped}