diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dba5fe..ff70d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to RustChan will be documented in this file. --- ## [1.1.0] +## ๐ŸŒ New: ChanNet API (Port 7070) + +RustChan can now talk to other RustChans. Introducing the **ChanNet API** โ€” a two-layer federation and gateway system living entirely on port 7070. + +**Layer 1 โ€” Federation** (`/chan/export`, `/chan/import`, `/chan/refresh`, `/chan/poll`): nodes sync with each other via ZIP snapshots. Push your posts out, pull theirs in, keep your mirror fresh. + +**Layer 2 โ€” RustWave Gateway** (`/chan/command`): the [RustWave](https://github.com/a2kiti/rustwave) audio transport client gets its own command interface. Send a typed JSON command, get a ZIP back. Supported commands: `full_export`, `board_export`, `thread_export`, `archive_export`, `force_refresh`, and `reply_push` (the only one that actually writes anything). + +Text only โ€” no images, no media, no binary data cross this interface by design. Full schema docs in `channet_api_reference.docx`. + +--- + ## Architecture Refactor This release restructures the codebase for maintainability. No user-facing 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 index 3db62a6..62610ea 100644 --- a/clippy_reports/summary.txt +++ b/clippy_reports/summary.txt @@ -1,5 +1,5 @@ -dev-check summary โ€” Tue 17 Mar 2026 12:34:13 PDT -Duration: 163s +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