diff --git a/Cargo.lock b/Cargo.lock index 0f37ffc..18f6bd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -455,6 +461,7 @@ name = "guestbook" version = "0.2.1" dependencies = [ "axum", + "base64 0.22.1", "chrono", "dotenvy", "http-body-util", @@ -466,6 +473,7 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "urlencoding", "uuid", ] @@ -1178,7 +1186,7 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", @@ -1243,7 +1251,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -1892,6 +1900,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 69374cf..3f0415b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ toml = "0.8" dotenvy = "0.15" uuid = { version = "1", features = ["v4"] } chrono = "0.4" +base64 = "0.22" tracing = "0.1" tracing-subscriber = "0.3" @@ -22,3 +23,4 @@ tracing-subscriber = "0.3" tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" tempfile = "3" +urlencoding = "2" diff --git a/src/config.rs b/src/config.rs index 4fc48f2..6806929 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,10 @@ pub struct Config { pub captcha_answer: String, pub captcha_exact: bool, pub captcha_casesensitive: bool, + pub enable_drawings: bool, + pub label_drawing: String, + pub canvas_width: u32, + pub canvas_height: u32, pub template: Option, pub separator: String, pub style: String, @@ -38,6 +42,12 @@ impl Config { format!("127.0.0.1:{}", self.port) } + /// Maximum drawing file size: width * height * 4 (raw RGBA). + /// Any valid PNG from the configured canvas will be smaller than this. + pub fn max_drawing_bytes(&self) -> usize { + self.canvas_width as usize * self.canvas_height as usize * 4 + } + pub fn from_env() -> Result { Ok(Config { port: env::var("BOOK_PORT") @@ -91,6 +101,19 @@ impl Config { captcha_casesensitive: env::var("BOOK_CAPTCHA_CASESENSITIVE") .map(|v| v != "false") .unwrap_or(false), + enable_drawings: env::var("BOOK_ENABLE_DRAWINGS") + .map(|v| v != "false") + .unwrap_or(false), + label_drawing: env::var("BOOK_LABEL_DRAWING") + .unwrap_or_else(|_| "Draw (optional):".into()), + canvas_width: env::var("BOOK_CANVAS_WIDTH") + .unwrap_or_else(|_| "400".into()) + .parse() + .map_err(|_| "BOOK_CANVAS_WIDTH must be a number")?, + canvas_height: env::var("BOOK_CANVAS_HEIGHT") + .unwrap_or_else(|_| "200".into()) + .parse() + .map_err(|_| "BOOK_CANVAS_HEIGHT must be a number")?, separator: env::var("BOOK_SEPARATOR") .unwrap_or_else(|_| "------------------------------------------------------------".into()), template: env::var("BOOK_TEMPLATE").ok().map(|path| { @@ -242,4 +265,19 @@ mod tests { env::remove_var("BOOK_TELEGRAM_CHAT_ID"); env::remove_var("BOOK_ENABLE_HTML_INJECTION"); } + + #[test] + fn test_enable_drawings_default() { + let _lock = ENV_LOCK.lock().unwrap(); + env::remove_var("BOOK_ENABLE_DRAWINGS"); + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + + let config = Config::from_env().unwrap(); + assert!(!config.enable_drawings); + assert_eq!(config.canvas_width, 400); + assert_eq!(config.canvas_height, 200); + assert_eq!(config.max_drawing_bytes(), 400 * 200 * 4); + assert_eq!(config.label_drawing, "Draw (optional):"); + } } diff --git a/src/entries.rs b/src/entries.rs index dda8753..e9af7a8 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -15,6 +15,8 @@ pub struct EntryMeta { pub date: String, #[serde(default)] pub website: String, + #[serde(default)] + pub drawing: String, pub status: Status, } @@ -183,4 +185,44 @@ Hello world!"#; let result = Entry::parse("x", "no frontmatter here"); assert!(result.is_err()); } + + #[test] + fn test_parse_entry_with_drawing() { + let contents = r#"+++ +name = "alice" +date = "2026-04-09" +status = "approved" +drawing = "abc123.png" ++++ +Hello!"#; + let entry = Entry::parse("test", contents).unwrap(); + assert_eq!(entry.meta.drawing, "abc123.png"); + } + + #[test] + fn test_parse_entry_without_drawing() { + let contents = r#"+++ +name = "bob" +date = "2026-04-09" +status = "pending" ++++ +Hi!"#; + let entry = Entry::parse("test", contents).unwrap(); + assert_eq!(entry.meta.drawing, ""); + } + + #[test] + fn test_roundtrip_with_drawing() { + let contents = r#"+++ +name = "alice" +date = "2026-04-09" +status = "approved" +drawing = "abc123.png" ++++ +Hello!"#; + let entry = Entry::parse("test", contents).unwrap(); + let serialized = entry.to_file_contents(); + let reparsed = Entry::parse("test", &serialized).unwrap(); + assert_eq!(reparsed.meta.drawing, "abc123.png"); + } } diff --git a/src/render.rs b/src/render.rs index 318f698..ed72b41 100644 --- a/src/render.rs +++ b/src/render.rs @@ -132,6 +132,10 @@ mod tests { captcha_answer: String::new(), captcha_exact: false, captcha_casesensitive: false, + enable_drawings: false, + label_drawing: "Draw (optional):".into(), + canvas_width: 400, + canvas_height: 200, template: None, separator: "---".into(), style: String::new(), @@ -152,6 +156,7 @@ mod tests { name: name.into(), date: date.into(), website: String::new(), + drawing: String::new(), status: Status::Approved, }, body: body.into(), diff --git a/src/web.rs b/src/web.rs index 5f1cee7..471f7ca 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,9 +1,13 @@ use axum::{ + extract::DefaultBodyLimit, + extract::Path as AxumPath, extract::State, - response::Html, + http::{header, StatusCode}, + response::{Html, IntoResponse, Response}, routing::{get, post}, Form, Router, }; +use base64::Engine; use serde::Deserialize; use std::sync::Arc; use uuid::Uuid; @@ -27,12 +31,16 @@ pub struct SubmitForm { url: String, // honeypot #[serde(default)] captcha: String, + #[serde(default)] + drawing: String, } pub fn router(state: Arc) -> Router { Router::new() .route("/", get(index)) .route("/submit", post(submit)) + .route("/drawings/{filename}", get(serve_drawing)) + .layer(DefaultBodyLimit::max(2 * 1024 * 1024)) .with_state(state) } @@ -54,6 +62,34 @@ async fn index(State(state): State>) -> Html { Html(html) } +async fn serve_drawing( + State(state): State>, + AxumPath(filename): AxumPath, +) -> Response { + // Validate filename: only safe chars + .png + if !filename.ends_with(".png") + || !filename[..filename.len() - 4] + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return StatusCode::NOT_FOUND.into_response(); + } + + let path = state.config.data_dir.join("drawings").join(&filename); + match std::fs::read(&path) { + Ok(bytes) => ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "image/png"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + bytes, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + async fn submit( State(state): State>, Form(form): Form, @@ -116,6 +152,49 @@ async fn submit( return Html(format!("Message is too long (max {max_msg} chars).")); } + // Process drawing if enabled and provided + let (drawing_filename, drawing_bytes) = if state.config.enable_drawings && !form.drawing.is_empty() { + let b64 = form.drawing + .strip_prefix("data:image/png;base64,") + .unwrap_or(""); + if b64.is_empty() { + (String::new(), None) + } else { + let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) { + Ok(b) => b, + Err(_) => return Html("Invalid drawing data.".to_string()), + }; + let max = state.config.max_drawing_bytes(); + if max > 0 && bytes.len() > max { + return Html(format!("Drawing is too large (max {} bytes).", max)); + } + + // Validate PNG: magic bytes + IHDR dimensions match configured canvas + if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" { + return Html("Invalid drawing format.".to_string()); + } + let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + if width != state.config.canvas_width || height != state.config.canvas_height { + return Html("Invalid drawing dimensions.".to_string()); + } + + let drawing_id = &Uuid::new_v4().to_string()[..8]; + let date_now = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let drawing_name = format!("{date_now}-{drawing_id}.png"); + let drawings_dir = state.config.data_dir.join("drawings"); + std::fs::create_dir_all(&drawings_dir).ok(); + if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), &bytes) { + tracing::error!("failed to write drawing: {e}"); + return Html("Something went wrong. Please try again.".to_string()); + } + (drawing_name, Some(bytes)) + } + } else { + (String::new(), None) + }; + let _ = drawing_bytes; + let short_id = &Uuid::new_v4().to_string()[..8]; let date = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); let date_short = &date[..10]; @@ -127,6 +206,7 @@ async fn submit( name, date, website, + drawing: drawing_filename, status: Status::Pending, }, body: message, @@ -152,6 +232,7 @@ mod tests { use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; + use base64::Engine; use http_body_util::BodyExt; use tower::ServiceExt; @@ -175,6 +256,10 @@ mod tests { captcha_answer: String::new(), captcha_exact: false, captcha_casesensitive: false, + enable_drawings: false, + label_drawing: "Draw (optional):".into(), + canvas_width: 400, + canvas_height: 200, template: None, separator: "---".into(), style: String::new(), @@ -463,4 +548,193 @@ mod tests { assert!(html.contains("What is 2+2?")); assert!(html.contains("name=\"captcha\"")); } + + async fn get_path(app: &Router, path: &str) -> (StatusCode, Vec) { + let req = Request::builder() + .uri(path) + .body(Body::empty()) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes().to_vec(); + (status, bytes) + } + + #[tokio::test] + async fn test_serve_drawing() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + + let drawings_dir = dir.path().join("drawings"); + std::fs::create_dir_all(&drawings_dir).unwrap(); + let png_bytes = b"\x89PNG\r\n\x1a\nfake"; + std::fs::write(drawings_dir.join("test123.png"), png_bytes).unwrap(); + + let (status, body) = get_path(&app, "/drawings/test123.png").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, png_bytes); + } + + #[tokio::test] + async fn test_serve_drawing_not_found() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + + let (status, _) = get_path(&app, "/drawings/nonexistent.png").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_serve_drawing_rejects_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + + let (status, _) = get_path(&app, "/drawings/../entries/secret.txt").await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// Build a fake but valid PNG with the given dimensions. + fn fake_png(width: u32, height: u32) -> Vec { + let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + png.extend_from_slice(&13u32.to_be_bytes()); + png.extend_from_slice(b"IHDR"); + png.extend_from_slice(&width.to_be_bytes()); + png.extend_from_slice(&height.to_be_bytes()); + png.extend_from_slice(&[8, 6, 0, 0, 0]); + png.extend_from_slice(&[0, 0, 0, 0]); + png + } + + #[tokio::test] + async fn test_submit_with_drawing() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.enable_drawings = true; + config.canvas_width = 400; + config.canvas_height = 200; + let (app, _rx) = test_app(config); + + let png = fake_png(400, 200); + let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png); + let data_url = format!("data:image/png;base64,{drawing_data}"); + let body = format!( + "name=alice&message=hello&drawing={}", + urlencoding::encode(&data_url) + ); + let (_, resp) = post_form(&app, &body).await; + assert!(resp.contains("pending approval")); + + let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries")) + .unwrap() + .collect(); + assert_eq!(entries.len(), 1); + let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap(); + assert!(content.contains("drawing = ")); + + let drawings: Vec<_> = std::fs::read_dir(dir.path().join("drawings")) + .unwrap() + .collect(); + assert_eq!(drawings.len(), 1); + } + + #[tokio::test] + async fn test_submit_without_drawing() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.enable_drawings = true; + let (app, _rx) = test_app(config); + let (_, resp) = post_form(&app, "name=alice&message=hello").await; + assert!(resp.contains("pending approval")); + + let drawings_dir = dir.path().join("drawings"); + if drawings_dir.exists() { + let count = std::fs::read_dir(&drawings_dir).unwrap().count(); + assert_eq!(count, 0); + } + } + + #[tokio::test] + async fn test_submit_drawing_too_large() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.enable_drawings = true; + config.canvas_width = 1; + config.canvas_height = 1; + let (app, _rx) = test_app(config); + + // PNG with dimensions 1x1 — max_drawing_bytes() is 4, but the fake_png itself is 33 bytes + let png = fake_png(1, 1); + let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png); + let data_url = format!("data:image/png;base64,{drawing_data}"); + let body = format!( + "name=alice&message=hello&drawing={}", + urlencoding::encode(&data_url) + ); + let (_, resp) = post_form(&app, &body).await; + assert!(resp.contains("too large")); + } + + #[tokio::test] + async fn test_submit_drawing_rejects_non_png() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.enable_drawings = true; + let (app, _rx) = test_app(config); + + let drawing_data = base64::engine::general_purpose::STANDARD.encode(b"not a png file at all"); + let data_url = format!("data:image/png;base64,{drawing_data}"); + let body = format!( + "name=alice&message=hello&drawing={}", + urlencoding::encode(&data_url) + ); + let (_, resp) = post_form(&app, &body).await; + assert!(resp.contains("Invalid drawing")); + } + + #[tokio::test] + async fn test_submit_drawing_rejects_wrong_dimensions() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.enable_drawings = true; + config.canvas_width = 400; + config.canvas_height = 200; + let (app, _rx) = test_app(config); + + let png = fake_png(1920, 1080); + let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png); + let data_url = format!("data:image/png;base64,{drawing_data}"); + let body = format!( + "name=alice&message=hello&drawing={}", + urlencoding::encode(&data_url) + ); + let (_, resp) = post_form(&app, &body).await; + assert!(resp.contains("Invalid drawing dimensions")); + } + + #[tokio::test] + async fn test_submit_drawing_ignored_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.enable_drawings = false; + let (app, _rx) = test_app(config); + + let png = fake_png(400, 200); + let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png); + let data_url = format!("data:image/png;base64,{drawing_data}"); + let body = format!( + "name=alice&message=hello&drawing={}", + urlencoding::encode(&data_url) + ); + let (_, resp) = post_form(&app, &body).await; + assert!(resp.contains("pending approval")); + + let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries")) + .unwrap() + .collect(); + let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap(); + assert!(!content.contains("drawing = \"2026")); + } }