From b1a198c88406a7d96a9e1bc9bdcee3e3fd960b4a Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 15:46:30 -0700 Subject: [PATCH 1/4] shouldt be there --- clippy_reports/clippy_raw.txt | 0 clippy_reports/summary.txt | 5 ----- 2 files changed, 5 deletions(-) delete mode 100644 clippy_reports/clippy_raw.txt delete mode 100644 clippy_reports/summary.txt diff --git a/clippy_reports/clippy_raw.txt b/clippy_reports/clippy_raw.txt deleted file mode 100644 index e69de29..0000000 diff --git a/clippy_reports/summary.txt b/clippy_reports/summary.txt deleted file mode 100644 index 3db62a6..0000000 --- a/clippy_reports/summary.txt +++ /dev/null @@ -1,5 +0,0 @@ -dev-check summary — Tue 17 Mar 2026 12:34:13 PDT -Duration: 163s -Passed: 11 -Failed: 0 -Skipped: 0 From 817455affd98d150aa17d412ec17e85e76bd52d1 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 16:43:16 -0700 Subject: [PATCH 2/4] Upgraded timezone system to reflect local machine time --- clippy_reports/clippy_raw.txt | 0 src/templates/board.rs | 3 ++- src/templates/thread.rs | 6 +++-- static/main.js | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 clippy_reports/clippy_raw.txt diff --git a/clippy_reports/clippy_raw.txt b/clippy_reports/clippy_raw.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/templates/board.rs b/src/templates/board.rs index feb1e4f..2bc4ed3 100644 --- a/src/templates/board.rs +++ b/src/templates/board.rs @@ -297,13 +297,14 @@ fn render_thread_summary( r#""#, sticky = sticky_label, locked = locked_label, name = escape_html(t.op_name.as_deref().unwrap_or("Anonymous")), + ts = t.created_at, time = fmt_ts_short(t.created_at), board = escape_html(board_short), tid = t.id, diff --git a/src/templates/thread.rs b/src/templates/thread.rs index 98e6798..5fc904e 100644 --- a/src/templates/thread.rs +++ b/src/templates/thread.rs @@ -389,7 +389,8 @@ pub fn render_post( .edited_at .map(|ts| { format!( - r#" (edited {short})"#, + r#" (edited {short})"#, + utc = ts, full = fmt_ts(ts), short = fmt_ts_short(ts), ) @@ -402,7 +403,7 @@ pub fn render_post( r##"
"##, @@ -410,6 +411,7 @@ pub fn render_post( id = post.id, name = escape_html(&post.name), tripcode = tripcode_html, + ts = post.created_at, time = fmt_ts_short(post.created_at), edited = edited_html, ); diff --git a/static/main.js b/static/main.js index 126104f..d917332 100644 --- a/static/main.js +++ b/static/main.js @@ -6,6 +6,48 @@ 'use strict'; +// ─── Localize post timestamps to device timezone ────────────────────────────── + +function localizePostTimes(root) { + var els = (root || document).querySelectorAll( + 'span.post-time[data-utc], span.post-edited[data-utc]' + ); + var days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + els.forEach(function (el) { + var ts = parseInt(el.getAttribute('data-utc'), 10); + if (isNaN(ts)) return; + var d = new Date(ts * 1000); + var mm = String(d.getMonth() + 1).padStart(2, '0'); + var dd = String(d.getDate()).padStart(2, '0'); + var yy = String(d.getFullYear()).slice(-2); + var day = days[d.getDay()]; + var hh = String(d.getHours()).padStart(2, '0'); + var min = String(d.getMinutes()).padStart(2, '0'); + var ss = String(d.getSeconds()).padStart(2, '0'); + var local = mm + '/' + dd + '/' + yy + '(' + day + ')' + hh + ':' + min + ':' + ss; + if (el.classList.contains('post-edited')) { + el.title = 'last edited ' + local; + el.textContent = '(edited ' + local + ')'; + } else { + el.textContent = local; + } + el.removeAttribute('data-utc'); // prevent double-processing + }); +} + +document.addEventListener('DOMContentLoaded', function () { + localizePostTimes(document); +}); + +// Hook into new-post insertions (thread auto-update, quote popups, etc.) +(function () { + var _origLocalize = window._onNewPostsInserted; + window._onNewPostsInserted = function (container) { + localizePostTimes(container); + if (_origLocalize) _origLocalize(container); + }; +}()); + // ─── Post form toggle & mobile drawer ──────────────────────────────────────── function togglePostForm() { From 8892eb92ab6c0e6d41d3c2f0a474356c37a77ab8 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 17:45:30 -0700 Subject: [PATCH 3/4] multiple big fixes --- Cargo.lock | 1 + Cargo.toml | 5 +- clippy_reports/summary.txt | 5 + src/chan_net/import.rs | 25 +-- src/chan_net/mod.rs | 44 +++- src/config.rs | 11 + src/db/boards.rs | 15 +- src/db/chan_net.rs | 11 +- src/db/posts.rs | 83 ++++--- src/handlers/admin/moderation.rs | 2 +- src/handlers/board.rs | 47 +++- src/handlers/thread.rs | 4 +- src/media/ffmpeg.rs | 2 + src/server/server.rs | 29 ++- src/utils/files.rs | 21 +- src/workers/mod.rs | 360 ++++++++++++++++++++++--------- 16 files changed, 469 insertions(+), 196 deletions(-) create mode 100644 clippy_reports/summary.txt diff --git a/Cargo.lock b/Cargo.lock index 3990ff7..b91e553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1756,6 +1756,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "tempfile", "thiserror", "time", diff --git a/Cargo.toml b/Cargo.toml index b39eb0c..23fa9b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,8 +48,9 @@ serde_json = "1" # toml 1.0: serde parsing is built-in, no feature flag needed. toml = "1.0" -argon2 = "0.5" -sha2 = "0.10" +argon2 = "0.5" +sha2 = "0.10" +subtle = "2" hex = "0.4" # rand_core 0.6 replaces rand = "0.8" entirely. # We only need OsRng + RngCore, both of which live in rand_core. diff --git a/clippy_reports/summary.txt b/clippy_reports/summary.txt new file mode 100644 index 0000000..62610ea --- /dev/null +++ b/clippy_reports/summary.txt @@ -0,0 +1,5 @@ +dev-check summary — Tue 17 Mar 2026 17:44:42 PDT +Duration: 177s +Passed: 11 +Failed: 0 +Skipped: 0 diff --git a/src/chan_net/import.rs b/src/chan_net/import.rs index 26f76be..c9c11bc 100644 --- a/src/chan_net/import.rs +++ b/src/chan_net/import.rs @@ -47,22 +47,17 @@ pub async fn do_import(state: &AppState, bytes: bytes::Bytes) -> Result, next: response } +// ─── ChanNet API key middleware ─────────────────────────────────────────────── + +/// Middleware that enforces the pre-shared `X-ChanNet-Key` header on sensitive +/// `ChanNet` endpoints (/chan/refresh and /chan/poll). +/// +/// FIX[High-9]: These endpoints were previously unauthenticated. Any process +/// that could reach the `ChanNet` bind address could trigger a full DB snapshot +/// push (refresh) or pull-and-import from a remote node (poll) with no +/// credentials. The API key is configured via `CHAN_NET_API_KEY` / settings.toml. +/// +/// If `chan_net_api_key` is empty the request is rejected with 403 Forbidden +/// (the feature is intentionally disabled rather than wide open). +async fn verify_chan_api_key(req: axum::extract::Request, next: Next) -> Response { + use subtle::ConstantTimeEq as _; + let expected = &crate::config::CONFIG.chan_net_api_key; + if expected.is_empty() { + // API key not configured — refuse the request to prevent accidental + // exposure when an operator forgets to set the key. + return StatusCode::FORBIDDEN.into_response(); + } + let provided = req + .headers() + .get("X-ChanNet-Key") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + // Constant-time comparison to prevent timing side-channels. + if provided.as_bytes().ct_eq(expected.as_bytes()).into() { + next.run(req).await + } else { + StatusCode::UNAUTHORIZED.into_response() + } +} + /// Build the `ChanNet` router. /// /// All `/chan/*` routes are wired here. `DefaultBodyLimit` is applied @@ -170,7 +203,14 @@ pub fn chan_router(state: AppState) -> Router { "/chan/import", post(import::chan_import).layer(DefaultBodyLimit::max(CONFIG.chan_net_max_body)), ) - .route("/chan/refresh", post(refresh::chan_refresh)) - .route("/chan/poll", post(poll::chan_poll)) + // FIX[High-9]: /chan/refresh and /chan/poll now require X-ChanNet-Key. + .route( + "/chan/refresh", + post(refresh::chan_refresh).layer(middleware::from_fn(verify_chan_api_key)), + ) + .route( + "/chan/poll", + post(poll::chan_poll).layer(middleware::from_fn(verify_chan_api_key)), + ) .with_state(state) } diff --git a/src/config.rs b/src/config.rs index 8851902..94ad46b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -94,6 +94,10 @@ struct SettingsFile { /// Address to bind the second `ChanNet` TCP listener. /// Default: 127.0.0.1:7070 (loopback-only; not exposed to the internet). chan_net_bind: Option, + /// Pre-shared API key required for /chan/refresh and /chan/poll endpoints. + /// Must be at least 32 characters. Leave empty to disable the endpoints. + /// Set via `CHAN_NET_API_KEY` environment variable or `settings.toml`. + chan_net_api_key: Option, } fn load_settings_file() -> SettingsFile { @@ -307,6 +311,9 @@ pub struct Config { pub chan_net_max_body: usize, /// Maximum request body size for `/chan/command` (raw JSON). Default: 8 KiB. pub chan_net_command_max_body: usize, + /// Pre-shared key required on X-ChanNet-Key header for /chan/refresh and + /// /chan/poll. An empty string means those endpoints are disabled entirely. + pub chan_net_api_key: String, } impl Config { @@ -474,6 +481,10 @@ impl Config { chan_net_bind, chan_net_max_body, chan_net_command_max_body, + chan_net_api_key: std::env::var("CHAN_NET_API_KEY") + .ok() + .or(s.chan_net_api_key) + .unwrap_or_default(), } } diff --git a/src/db/boards.rs b/src/db/boards.rs index cb10e17..9aa4d48 100644 --- a/src/db/boards.rs +++ b/src/db/boards.rs @@ -414,13 +414,18 @@ pub fn delete_board(conn: &rusqlite::Connection, id: i64) -> Result> /// Per-board thread and post counts for the terminal stats display. pub fn get_per_board_stats(conn: &rusqlite::Connection) -> Vec<(String, i64, i64)> { + // FIX[High-11]: Replace N+1 correlated subqueries (2 subqueries × boards) + // with a single LEFT JOIN … GROUP BY pass. For a forum with 20 boards the + // old query executed 41 SQL statements; this executes 1. 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", + COUNT(DISTINCT t.id) AS tc, \ + COUNT(DISTINCT p.id) AS pc \ + FROM boards b \ + LEFT JOIN threads t ON t.board_id = b.id \ + LEFT JOIN posts p ON p.thread_id = t.id \ + GROUP BY b.id \ + ORDER BY b.short_name", ) else { return vec![]; }; diff --git a/src/db/chan_net.rs b/src/db/chan_net.rs index 3af2c64..ccbd95b 100644 --- a/src/db/chan_net.rs +++ b/src/db/chan_net.rs @@ -64,12 +64,17 @@ pub fn insert_board_if_absent(conn: &Connection, short_name: &str, title: &str) return Ok(id); } - conn.execute( + // FIX[High-7]: Use INSERT … RETURNING id instead of last_insert_rowid(). + // last_insert_rowid() is connection-local; in a multi-connection pool another + // write on the same connection between the INSERT and this call would return + // the wrong row ID. + let id: i64 = conn.query_row( "INSERT INTO boards (short_name, title, description, nsfw, max_threads, bump_limit) - VALUES (?1, ?2, '', 0, 100, 300)", + VALUES (?1, ?2, '', 0, 100, 300) RETURNING id", rusqlite::params![short_name, title], + |r| r.get(0), )?; - Ok(conn.last_insert_rowid()) + Ok(id) } // ── insert_post_if_absent ───────────────────────────────────────────────────── diff --git a/src/db/posts.rs b/src/db/posts.rs index 5d33204..1523692 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -369,7 +369,7 @@ pub fn verify_deletion_token( /// # Errors /// Returns an error if the database operation fails. pub fn edit_post( - conn: &rusqlite::Connection, + conn: &mut rusqlite::Connection, post_id: i64, token: &str, new_body: &str, @@ -382,54 +382,51 @@ pub fn edit_post( edit_window_secs }; - // 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") + // FIX[High-6]: Use rusqlite's typed transaction API instead of raw + // execute_batch("BEGIN IMMEDIATE"). Raw execute_batch does not update + // rusqlite's internal transaction-tracking state; a subsequent call to + // unchecked_transaction() on the same pooled connection would attempt to + // start a nested transaction on top of an already-committed one, causing + // SQLITE_ERROR: cannot start a transaction within a transaction. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::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.saturating_sub(created_at) > window { - return Ok(false); - } + // FIX[HIGH-2]: Fetch token and created_at in a single round-trip inside + // the IMMEDIATE transaction so no concurrent writer can modify the post + // between our SELECT and UPDATE. + let row: Option<(String, i64)> = tx + .query_row( + "SELECT deletion_token, created_at FROM posts WHERE id = ?1", + params![post_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .context("Failed to query post for edit")?; - 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], - )?; + let Some((stored_token, created_at)) = row else { + return Ok(false); // post does not exist + }; - // Belt-and-suspenders: confirm the row was actually written. - Ok(conn.changes() > 0) - })(); + if !constant_time_eq(stored_token.as_bytes(), token.as_bytes()) { + return Ok(false); + } - 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) - } + let now = chrono::Utc::now().timestamp(); + if now.saturating_sub(created_at) > window { + 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], + ) + .context("Failed to update post body")?; + + let updated = tx.changes() > 0; + tx.commit() + .context("Failed to commit edit_post transaction")?; + Ok(updated) } /// Constant-time byte slice comparison to prevent timing side-channel attacks. diff --git a/src/handlers/admin/moderation.rs b/src/handlers/admin/moderation.rs index 4b37424..694a6b9 100644 --- a/src/handlers/admin/moderation.rs +++ b/src/handlers/admin/moderation.rs @@ -180,7 +180,7 @@ pub async fn admin_ban_and_delete( "ban", None, &board_short, - &format!("inline ban — ip_hash={reason}… reason={}", &ip_hash_log), + &format!("inline ban — ip_hash={}… reason={reason}", &ip_hash_log), ); // Delete post (or whole thread if OP) diff --git a/src/handlers/board.rs b/src/handlers/board.rs index 75f008f..ef768f5 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -483,14 +483,19 @@ pub async fn catalog( State(state): State, Path(board_short): Path, jar: CookieJar, -) -> Result<(CookieJar, Html)> { + req_headers: HeaderMap, +) -> Result { let (jar, csrf) = ensure_csrf(jar); - let html = tokio::task::spawn_blocking({ + // FIX[High-8]: Add ETag caching to the catalog. Previously every request + // fetched up to 200 full thread rows and re-rendered the entire page + // regardless of whether anything changed. The ETag is derived from the + // most-recently-bumped thread, mirroring the board index handler. + let (etag, html) = 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() @@ -498,22 +503,47 @@ pub async fn catalog( 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)?; + let max_bump = threads.iter().map(|t| t.bumped_at).max().unwrap_or(0); + let admin_tag = if is_admin { "-a" } else { "" }; + let etag = format!("\"{max_bump}-catalog{admin_tag}\""); let all_boards = db::get_all_boards(&conn)?; let collapse_greentext = db::get_collapse_greentext(&conn); - Ok(templates::catalog_page( + let html = templates::catalog_page( &board, &threads, &csrf_clone, &all_boards, is_admin, collapse_greentext, - )) + ); + Ok((etag, html)) } }) .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; - Ok((jar, Html(html))) + 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()) } // ─── GET /:board/archive ────────────────────────────────────────────────────── @@ -750,7 +780,10 @@ fn media_content_type(path: &std::path::Path) -> Option<&'static str> { Some("gif") => Some("image/gif"), Some("bmp") => Some("image/bmp"), Some("tiff" | "tif") => Some("image/tiff"), - Some("svg") => Some("image/svg+xml"), + // SVG is intentionally omitted: serving SVG inline allows stored XSS via + // embedded