From 5be082a6a03436d84c7c07a390674fcbba3564bb Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 21:46:27 +0100 Subject: [PATCH 1/8] docs: points at the upstream on forgejo instead of gh --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f6ce03c..25f440b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ This will run the site on localhost on the port you've configured, or `8123` by ```nix # flake.nix { - inputs.guestbook.url = "github:llywelwyn/guestbook"; + inputs.guestbook.url = "git+https://git.ily.rs/lew/guestbook"; outputs = { self, nixpkgs, guestbook, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { From 7663237f571c5479a44141c891e9c87eb3bb7de2 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 22:17:01 +0100 Subject: [PATCH 2/8] feat: support for drawing --- Cargo.lock | 18 +++- Cargo.toml | 2 + src/config.rs | 38 +++++++ src/entries.rs | 42 ++++++++ src/render.rs | 5 + src/web.rs | 276 ++++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 378 insertions(+), 3 deletions(-) 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")); + } } From d34768b63dcc5f971d549a29f2d64fbc88ca4cbd Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 22:27:22 +0100 Subject: [PATCH 3/8] feat: renders drawpad, and saves files as epoch_uuid for better sorting on disk --- .env.example | 12 ++++++ src/render.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++++++- src/web.rs | 43 +++++++++++--------- 3 files changed, 142 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 451521b..48f9687 100644 --- a/.env.example +++ b/.env.example @@ -87,3 +87,15 @@ # Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. # Uses built-in default if unset. # BOOK_TEMPLATE=./templates/default.html + +# Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/. +# BOOK_ENABLE_DRAWINGS=false + +# Label for the drawing canvas. +# BOOK_LABEL_DRAWING=Draw (optional): + +# Drawing canvas width in pixels. +# BOOK_CANVAS_WIDTH=400 + +# Drawing canvas height in pixels. +# BOOK_CANVAS_HEIGHT=200 diff --git a/src/render.rs b/src/render.rs index ed72b41..338f756 100644 --- a/src/render.rs +++ b/src/render.rs @@ -38,6 +38,48 @@ pub fn render_form(config: &Config) -> String { String::new() }; + let drawing_section = if config.enable_drawings { + format!( + r##" + + +Reset + +"##, + label = config.label_drawing, + w = config.canvas_width, + h = config.canvas_height, + ) + } else { + String::new() + }; + format!( r#"{prompt}
@@ -47,6 +89,7 @@ pub fn render_form(config: &Config) -> String { {captcha_section} +{drawing_section}
"#, @@ -57,6 +100,7 @@ pub fn render_form(config: &Config) -> String { rows = config.textarea_rows, cols = config.textarea_cols, captcha_section = captcha_section, + drawing_section = drawing_section, button = config.button_text, ) } @@ -100,8 +144,16 @@ fn render_entry(entry: &Entry, config: &Config) -> String { } else { escape_html(&entry.body) }; + let drawing_html = if !entry.meta.drawing.is_empty() { + format!( + "\n", + escape_html(&entry.meta.drawing) + ) + } else { + String::new() + }; format!( - "\n{header}\n\n{body}\n\n{}\n", + "\n{header}\n\n{body}{drawing_html}\n\n{}\n", config.separator ) } @@ -322,4 +374,56 @@ mod tests { "<b>test</b> & "quotes" 'apos'" ); } + + #[test] + fn test_render_form_shows_canvas_when_drawings_enabled() { + let mut config = test_config(); + config.enable_drawings = true; + let form = render_form(&config); + assert!(form.contains(""#)); + } + + #[test] + fn test_render_entry_drawing_works_without_html_injection() { + let mut config = test_config(); + config.enable_html_injection = false; + let mut entry = make_entry("alice", "2026-04-09", ""); + entry.meta.drawing = "2026-04-09-abc123.png".into(); + let form = render_form(&config); + let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); + // Drawing renders regardless + assert!(html.contains(r#""#)); + // But body HTML is escaped + assert!(html.contains("<script>")); + } + + #[test] + fn test_render_entry_without_drawing() { + let config = test_config(); + let entry = make_entry("alice", "2026-04-09", "Hello!"); + let form = render_form(&config); + let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); + assert!(!html.contains("entry-drawing")); + } } diff --git a/src/web.rs b/src/web.rs index 471f7ca..685274c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -153,12 +153,12 @@ async fn submit( } // Process drawing if enabled and provided - let (drawing_filename, drawing_bytes) = if state.config.enable_drawings && !form.drawing.is_empty() { + let drawing_bytes: Option> = 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) + None } else { let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) { Ok(b) => b, @@ -179,27 +179,34 @@ async fn submit( 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)) + Some(bytes) } } else { - (String::new(), None) + None + }; + + let now = chrono::Utc::now(); + let epoch = now.timestamp(); + let short_id = &Uuid::new_v4().to_string()[..8]; + let prefix = format!("{epoch}_{short_id}"); + let date = now.format("%Y-%m-%dT%H:%M:%S").to_string(); + let filename = format!("{prefix}.txt"); + + // Save drawing with the same prefix as the entry + let drawing_filename = if let Some(ref bytes) = drawing_bytes { + let drawing_name = format!("{prefix}.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 + } else { + String::new() }; 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]; - let filename = format!("{date_short}-{short_id}.txt"); - let entry = Entry { id: filename.trim_end_matches(".txt").to_string(), meta: EntryMeta { From 6322606335f3ceb1eb45a43c1f3c77f1c2dcfc54 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 22:28:47 +0100 Subject: [PATCH 4/8] fix: corrects a test that could never fail post-name change --- src/web.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web.rs b/src/web.rs index 685274c..9c8d908 100644 --- a/src/web.rs +++ b/src/web.rs @@ -742,6 +742,6 @@ mod tests { .unwrap() .collect(); let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap(); - assert!(!content.contains("drawing = \"2026")); + assert!(content.contains("drawing = \"\"")); } } From d26c289f663dbf82890ada41310939720c771545 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 22:39:00 +0100 Subject: [PATCH 5/8] feat: textarea width/rows in px to match drawpad, and drawpad submissions/entry rendering --- .env.example | 8 ++++---- README.md | 26 ++++++++++++++++++-------- module.nix | 16 ++++++++-------- src/config.rs | 16 ++++++++-------- src/render.rs | 23 +++++++++++------------ src/web.rs | 4 ++-- templates/default.css | 14 +++++++++++++- 7 files changed, 64 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index 48f9687..2f47748 100644 --- a/.env.example +++ b/.env.example @@ -78,11 +78,11 @@ # Label for the message field. # BOOK_LABEL_MESSAGE=Your message: -# Number of rows for the message textarea. -# BOOK_TEXTAREA_ROWS=8 +# Message textarea width in pixels. +# BOOK_TEXTAREA_WIDTH=400 -# Number of columns for the message textarea. -# BOOK_TEXTAREA_COLS=60 +# Message textarea height in pixels. +# BOOK_TEXTAREA_HEIGHT=150 # Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. # Uses built-in default if unset. diff --git a/README.md b/README.md index 25f440b..48d1af0 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,11 @@ Running `guestbook` with no env vars will give you a working guestbook on `local # Label for the message field. # BOOK_LABEL_MESSAGE=Your message: -# Number of rows for the message textarea. -# BOOK_TEXTAREA_ROWS=8 +# Message textarea width in pixels. +# BOOK_TEXTAREA_WIDTH=400 -# Number of columns for the message textarea. -# BOOK_TEXTAREA_COLS=60 +# Message textarea height in pixels. +# BOOK_TEXTAREA_HEIGHT=150 # Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. # Uses built-in default if unset. @@ -235,8 +235,8 @@ services.guestbook = { message = "Your message:"; }; message = { - rows = 8; - cols = 60; + width = 400; + height = 150; }; }; }; @@ -286,8 +286,8 @@ The `status` field can be `pending`, `approved`, or `denied`. Only approved entr title - Site title (BOOK_SITE_TITLE). Useful in and headings. form - The submission form (labels, inputs, button). Controlled by BOOK_FORM_PROMPT, BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, - BOOK_LABEL_MESSAGE, BOOK_BUTTON_TEXT, BOOK_TEXTAREA_ROWS, - BOOK_TEXTAREA_COLS. Empty when BOOK_ENABLE_SUBMISSIONS=false. + BOOK_LABEL_MESSAGE, BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH, + BOOK_TEXTAREA_HEIGHT. Empty when BOOK_ENABLE_SUBMISSIONS=false. entries - Approved guestbook entries, newest first. Entry separator controlled by BOOK_SEPARATOR. style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in @@ -340,6 +340,16 @@ entries .guestbook-textarea {} .guestbook-button {} +/* Drawings */ +.guestbook-canvas { + border: 1px solid #000; + cursor: crosshair; +} +.guestbook-canvas-reset {} +.entry-drawing { + max-width: 100%; +} + /* Entries */ .entry-header {} .entry-name {} diff --git a/module.nix b/module.nix index 09647c3..c6200c3 100644 --- a/module.nix +++ b/module.nix @@ -203,16 +203,16 @@ in }; message = { - rows = mkOption { + width = mkOption { type = types.int; - default = 8; - description = "Number of rows for the message textarea."; + default = 400; + description = "Message textarea width in pixels."; }; - cols = mkOption { + height = mkOption { type = types.int; - default = 60; - description = "Number of columns for the message textarea."; + default = 150; + description = "Message textarea height in pixels."; }; }; }; @@ -249,8 +249,8 @@ in BOOK_LABEL_NAME = cfg.styles.labels.name; BOOK_LABEL_WEBSITE = cfg.styles.labels.website; BOOK_LABEL_MESSAGE = cfg.styles.labels.message; - BOOK_TEXTAREA_ROWS = toString cfg.styles.message.rows; - BOOK_TEXTAREA_COLS = toString cfg.styles.message.cols; + BOOK_TEXTAREA_WIDTH = toString cfg.styles.message.width; + BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height; } // lib.optionalAttrs (cfg.styles.cssFile != null) { BOOK_STYLE_FILE = cfg.styles.cssFile; } // lib.optionalAttrs (cfg.styles.templateFile != null) { diff --git a/src/config.rs b/src/config.rs index 6806929..6633961 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,8 +33,8 @@ pub struct Config { pub label_name: String, pub label_website: String, pub label_message: String, - pub textarea_rows: u32, - pub textarea_cols: u32, + pub textarea_width: u32, + pub textarea_height: u32, } impl Config { @@ -138,14 +138,14 @@ impl Config { .unwrap_or_else(|_| "Your website (optional):".into()), label_message: env::var("BOOK_LABEL_MESSAGE") .unwrap_or_else(|_| "Your message:".into()), - textarea_rows: env::var("BOOK_TEXTAREA_ROWS") - .unwrap_or_else(|_| "8".into()) + textarea_width: env::var("BOOK_TEXTAREA_WIDTH") + .unwrap_or_else(|_| "400".into()) .parse() - .map_err(|_| "BOOK_TEXTAREA_ROWS must be a number")?, - textarea_cols: env::var("BOOK_TEXTAREA_COLS") - .unwrap_or_else(|_| "60".into()) + .map_err(|_| "BOOK_TEXTAREA_WIDTH must be a number")?, + textarea_height: env::var("BOOK_TEXTAREA_HEIGHT") + .unwrap_or_else(|_| "150".into()) .parse() - .map_err(|_| "BOOK_TEXTAREA_COLS must be a number")?, + .map_err(|_| "BOOK_TEXTAREA_HEIGHT must be a number")?, }) } } diff --git a/src/render.rs b/src/render.rs index 338f756..07ab1b4 100644 --- a/src/render.rs +++ b/src/render.rs @@ -40,8 +40,7 @@ pub fn render_form(config: &Config) -> String { let drawing_section = if config.enable_drawings { format!( - r##" -<label class="guestbook-label">{label}</label> + r##"<label class="guestbook-label">{label}</label> <canvas class="guestbook-canvas" width="{w}" height="{h}"></canvas> <a href="#" class="guestbook-canvas-reset">Reset</a> <input type="hidden" name="drawing"> @@ -87,7 +86,7 @@ pub fn render_form(config: &Config) -> String { <input class="guestbook-input" name="name" required> {website_section} <label class="guestbook-label">{label_message}</label> -<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea> +<textarea class="guestbook-textarea" name="message" style="width:{tw}px;height:{th}px" required></textarea> {captcha_section} {drawing_section} <input name="url" style="display:none" tabindex="-1" autocomplete="off"> @@ -97,8 +96,8 @@ pub fn render_form(config: &Config) -> String { label_name = config.label_name, website_section = website_section, label_message = config.label_message, - rows = config.textarea_rows, - cols = config.textarea_cols, + tw = config.textarea_width, + th = config.textarea_height, captcha_section = captcha_section, drawing_section = drawing_section, button = config.button_text, @@ -196,8 +195,8 @@ mod tests { label_name: "Your name:".into(), label_website: "Your website (optional):".into(), label_message: "Your message:".into(), - textarea_rows: 8, - textarea_cols: 60, + textarea_width: 400, + textarea_height: 150, } } @@ -298,11 +297,11 @@ mod tests { #[test] fn test_render_form_custom_textarea() { let mut config = test_config(); - config.textarea_rows = 12; - config.textarea_cols = 40; + config.textarea_width = 500; + config.textarea_height = 200; let form = render_form(&config); - assert!(form.contains("rows=\"12\"")); - assert!(form.contains("cols=\"40\"")); + assert!(form.contains("width:500px")); + assert!(form.contains("height:200px")); } #[test] @@ -424,6 +423,6 @@ mod tests { let entry = make_entry("alice", "2026-04-09", "Hello!"); let form = render_form(&config); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); - assert!(!html.contains("entry-drawing")); + assert!(!html.contains("<img class=\"entry-drawing\"")); } } diff --git a/src/web.rs b/src/web.rs index 9c8d908..26392d1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -275,8 +275,8 @@ mod tests { label_name: "Your name:".into(), label_website: "Your website (optional):".into(), label_message: "Your message:".into(), - textarea_rows: 8, - textarea_cols: 60, + textarea_width: 400, + textarea_height: 150, } } diff --git a/templates/default.css b/templates/default.css index f5df6db..91d8ba1 100644 --- a/templates/default.css +++ b/templates/default.css @@ -12,9 +12,21 @@ .guestbook-form {} .guestbook-label {} .guestbook-input {} -.guestbook-textarea {} +.guestbook-textarea { + box-sizing: border-box; +} .guestbook-button {} +/* Drawings */ +.guestbook-canvas { + border: 1px solid #000; + cursor: crosshair; +} +.guestbook-canvas-reset {} +.entry-drawing { + max-width: 100%; +} + /* Entries */ .entry-header {} .entry-name {} From 2044616aeb7a1db6e59a98c2ce74a3e83c90f2f8 Mon Sep 17 00:00:00 2001 From: lew <lew@ily.rs> Date: Thu, 9 Apr 2026 22:51:04 +0100 Subject: [PATCH 6/8] feat: restructures module.nix opts --- README.md | 28 ++++++---- module.nix | 146 ++++++++++++++++++++++++++++++------------------ src/main.rs | 2 +- src/telegram.rs | 12 +++- src/web.rs | 8 +-- 5 files changed, 125 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 48d1af0..9cff78f 100644 --- a/README.md +++ b/README.md @@ -196,17 +196,24 @@ services.guestbook = { forwardAuth = null; # e.g. "localhost:9090" }; - security = { - enableSubmissions = true; - enableHtmlInjection = false; - enableWebsiteLinks = true; - enableHoneypot = true; - captcha = { + features = { + submissions.enable = true; + websites.enable = true; + drawing = { enable = false; - question = ""; - answer = ""; - exact = false; - caseSensitive = false; + canvasWidth = 400; + canvasHeight = 200; + }; + security = { + htmlInjection.enable = false; + honeypot.enable = true; + captcha = { + enable = false; + question = ""; + answer = ""; + exact = false; + caseSensitive = false; + }; }; }; @@ -233,6 +240,7 @@ services.guestbook = { name = "Your name:"; website = "Your website (optional):"; message = "Your message:"; + drawing = "Draw (optional):"; }; message = { width = 400; diff --git a/module.nix b/module.nix index c6200c3..e4f8182 100644 --- a/module.nix +++ b/module.nix @@ -57,56 +57,86 @@ in }; }; - security = { - enableSubmissions = mkOption { - type = types.bool; - default = true; - description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; - }; - - enableHtmlInjection = mkOption { - type = types.bool; - default = false; - description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped."; - }; - - enableWebsiteLinks = mkOption { - type = types.bool; - default = true; - description = "Show website field in form and render website links in entries. When false, the input is hidden, submitted values are ignored, and existing links are not displayed."; - }; - - enableHoneypot = mkOption { - type = types.bool; - default = true; - description = "Enable honeypot field for spam prevention."; - }; - - captcha = { - enable = mkEnableOption "captcha on submission form"; - - question = mkOption { - type = types.str; - default = ""; - description = "Captcha question displayed as a label."; + features = { + submissions = { + enable = mkOption { + type = types.bool; + default = true; + description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; }; + }; - answer = mkOption { - type = types.str; - default = ""; - description = "Captcha answer to validate against."; + websites = { + enable = mkOption { + type = types.bool; + default = true; + description = "Show website field in form and render website links in entries. When false, the input is hidden, submitted values are ignored, and existing links are not displayed."; }; + }; - exact = mkOption { + drawing = { + enable = mkOption { type = types.bool; default = false; - description = "Require exact match. When false, the answer just needs to be contained in the response."; + description = "Enable the drawing canvas in the submission form. Stores PNG files in dataDir/drawings/."; }; - caseSensitive = mkOption { - type = types.bool; - default = false; - description = "Require case-sensitive match."; + canvasWidth = mkOption { + type = types.int; + default = 400; + description = "Drawing canvas width in pixels."; + }; + + canvasHeight = mkOption { + type = types.int; + default = 200; + description = "Drawing canvas height in pixels."; + }; + }; + + security = { + htmlInjection = { + enable = mkOption { + type = types.bool; + default = false; + description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped."; + }; + }; + + honeypot = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable honeypot field for spam prevention."; + }; + }; + + captcha = { + enable = mkEnableOption "captcha on submission form"; + + question = mkOption { + type = types.str; + default = ""; + description = "Captcha question displayed as a label."; + }; + + answer = mkOption { + type = types.str; + default = ""; + description = "Captcha answer to validate against."; + }; + + exact = mkOption { + type = types.bool; + default = false; + description = "Require exact match. When false, the answer just needs to be contained in the response."; + }; + + caseSensitive = mkOption { + type = types.bool; + default = false; + description = "Require case-sensitive match."; + }; }; }; }; @@ -149,7 +179,7 @@ in css = mkOption { type = types.str; default = ""; - description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .entry-header, .entry-name, .entry-website, .entry-body, .entry-separator"; + description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .guestbook-canvas, .entry-header, .entry-name, .entry-website, .entry-body, .entry-drawing, .entry-separator"; }; cssFile = mkOption { @@ -200,6 +230,12 @@ in default = "Your message:"; description = "Label for the message field."; }; + + drawing = mkOption { + type = types.str; + default = "Draw (optional):"; + description = "Label for the drawing canvas."; + }; }; message = { @@ -230,15 +266,16 @@ in BOOK_DATA_DIR = cfg.dataDir; BOOK_SITE_TITLE = cfg.siteTitle; - BOOK_ENABLE_HONEYPOT = if cfg.security.enableHoneypot then "true" else "false"; - BOOK_ENABLE_SUBMISSIONS = if cfg.security.enableSubmissions then "true" else "false"; - BOOK_ENABLE_HTML_INJECTION = if cfg.security.enableHtmlInjection then "true" else "false"; - BOOK_ENABLE_WEBSITE_LINKS = if cfg.security.enableWebsiteLinks then "true" else "false"; - BOOK_ENABLE_CAPTCHA = if cfg.security.captcha.enable then "true" else "false"; - BOOK_CAPTCHA_QUESTION = cfg.security.captcha.question; - BOOK_CAPTCHA_ANSWER = cfg.security.captcha.answer; - BOOK_CAPTCHA_EXACT = if cfg.security.captcha.exact then "true" else "false"; - BOOK_CAPTCHA_CASESENSITIVE = if cfg.security.captcha.caseSensitive then "true" else "false"; + BOOK_ENABLE_SUBMISSIONS = if cfg.features.submissions.enable then "true" else "false"; + BOOK_ENABLE_WEBSITE_LINKS = if cfg.features.websites.enable then "true" else "false"; + BOOK_ENABLE_DRAWINGS = if cfg.features.drawing.enable then "true" else "false"; + BOOK_ENABLE_HTML_INJECTION = if cfg.features.security.htmlInjection.enable then "true" else "false"; + BOOK_ENABLE_HONEYPOT = if cfg.features.security.honeypot.enable then "true" else "false"; + BOOK_ENABLE_CAPTCHA = if cfg.features.security.captcha.enable then "true" else "false"; + BOOK_CAPTCHA_QUESTION = cfg.features.security.captcha.question; + BOOK_CAPTCHA_ANSWER = cfg.features.security.captcha.answer; + BOOK_CAPTCHA_EXACT = if cfg.features.security.captcha.exact then "true" else "false"; + BOOK_CAPTCHA_CASESENSITIVE = if cfg.features.security.captcha.caseSensitive then "true" else "false"; BOOK_MAX_NAME_LENGTH = toString cfg.limits.name; BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message; BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website; @@ -249,6 +286,9 @@ in BOOK_LABEL_NAME = cfg.styles.labels.name; BOOK_LABEL_WEBSITE = cfg.styles.labels.website; BOOK_LABEL_MESSAGE = cfg.styles.labels.message; + BOOK_LABEL_DRAWING = cfg.styles.labels.drawing; + BOOK_CANVAS_WIDTH = toString cfg.features.drawing.canvasWidth; + BOOK_CANVAS_HEIGHT = toString cfg.features.drawing.canvasHeight; BOOK_TEXTAREA_WIDTH = toString cfg.styles.message.width; BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height; } // lib.optionalAttrs (cfg.styles.cssFile != null) { @@ -261,7 +301,7 @@ in serviceConfig = { Type = "simple"; ExecStartPre = "+${pkgs.writeShellScript "guestbook-prepare" '' - mkdir -p ${cfg.dataDir}/entries + mkdir -p ${cfg.dataDir}/entries ${cfg.dataDir}/drawings chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir} ''}"; Restart = "on-failure"; diff --git a/src/main.rs b/src/main.rs index d2875b4..969ffe7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ async fn main() { std::fs::create_dir_all(&entries_dir).ok(); - let (tx, rx) = tokio::sync::mpsc::channel(32); + let (tx, rx) = tokio::sync::mpsc::channel::<(entries::Entry, Option<Vec<u8>>)>(32); // Spawn telegram tasks if configured match (&config.telegram_bot_token, config.telegram_chat_id) { diff --git a/src/telegram.rs b/src/telegram.rs index 0215cc2..5cd7b9c 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -18,9 +18,17 @@ async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) { } /// Listen for new entries on the channel and send Telegram notifications. -pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<Entry>) { - while let Some(entry) = rx.recv().await { +pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option<Vec<u8>>)>) { + while let Some((entry, drawing_bytes)) = rx.recv().await { notify(&bot, chat_id, &entry).await; + if let Some(bytes) = drawing_bytes { + if let Err(e) = bot.send_photo( + chat_id, + teloxide::types::InputFile::memory(bytes).file_name("drawing.png"), + ).await { + tracing::error!("failed to send drawing photo: {e}"); + } + } } } diff --git a/src/web.rs b/src/web.rs index 26392d1..61a15db 100644 --- a/src/web.rs +++ b/src/web.rs @@ -18,7 +18,7 @@ use crate::render::{self, DEFAULT_TEMPLATE}; pub struct AppState { pub config: Config, - pub tx: tokio::sync::mpsc::Sender<Entry>, + pub tx: tokio::sync::mpsc::Sender<(Entry, Option<Vec<u8>>)>, } #[derive(Deserialize)] @@ -205,8 +205,6 @@ async fn submit( } else { String::new() }; - let _ = drawing_bytes; - let entry = Entry { id: filename.trim_end_matches(".txt").to_string(), meta: EntryMeta { @@ -229,7 +227,7 @@ async fn submit( } // Notify telegram task - let _ = state.tx.send(entry).await; + let _ = state.tx.send((entry, drawing_bytes)).await; Html("Thanks! Your message is pending approval.".to_string()) } @@ -280,7 +278,7 @@ mod tests { } } - fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<Entry>) { + fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<(Entry, Option<Vec<u8>>)>) { let (tx, rx) = tokio::sync::mpsc::channel(32); let state = Arc::new(AppState { config, tx }); (router(state), rx) From 9521fc4aef0e3ec0381490cbaedafaf3dd8843bc Mon Sep 17 00:00:00 2001 From: lew <lew@ily.rs> Date: Thu, 9 Apr 2026 23:00:48 +0100 Subject: [PATCH 7/8] feat: initial draft of drawings, still missing some features from my other site --- .env.example | 2 +- README.md | 3 ++- module.nix | 2 +- src/render.rs | 4 ++-- src/telegram.rs | 2 +- src/web.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ templates/default.css | 1 + 7 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 2f47748..be53f25 100644 --- a/.env.example +++ b/.env.example @@ -59,7 +59,7 @@ # Custom CSS injected into a style tag. # Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, -# .guestbook-textarea, .guestbook-button, .entry-header, .entry-name, +# .guestbook-textarea, .guestbook-button, .entry-header, .entry-date, .entry-name, # .entry-website, .entry-body, .entry-separator # BOOK_STYLE= diff --git a/README.md b/README.md index 9cff78f..bfc15c8 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Running `guestbook` with no env vars will give you a working guestbook on `local # Custom CSS injected into a style tag. # Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, -# .guestbook-textarea, .guestbook-button, .entry-header, .entry-name, +# .guestbook-textarea, .guestbook-button, .entry-header, .entry-date, .entry-name, # .entry-website, .entry-body, .entry-separator # BOOK_STYLE= @@ -360,6 +360,7 @@ entries /* Entries */ .entry-header {} +.entry-date {} .entry-name {} .entry-website {} .entry-body {} diff --git a/module.nix b/module.nix index e4f8182..6fe86fe 100644 --- a/module.nix +++ b/module.nix @@ -179,7 +179,7 @@ in css = mkOption { type = types.str; default = ""; - description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .guestbook-canvas, .entry-header, .entry-name, .entry-website, .entry-body, .entry-drawing, .entry-separator"; + description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .guestbook-canvas, .entry-header, .entry-date, .entry-name, .entry-website, .entry-body, .entry-drawing, .entry-separator"; }; cssFile = mkOption { diff --git a/src/render.rs b/src/render.rs index 07ab1b4..be24b68 100644 --- a/src/render.rs +++ b/src/render.rs @@ -127,7 +127,7 @@ fn render_entry(entry: &Entry, config: &Config) -> String { escape_html(&entry.meta.name) }; let mut header = format!( - "<span class=\"entry-header\">{} - <span class=\"entry-name\">{}</span>", + "<span class=\"entry-header\"><span class=\"entry-date\">{}</span> - <span class=\"entry-name\">{}</span>", &entry.meta.date[..10], name ); if config.enable_website_links && !entry.meta.website.is_empty() { @@ -152,7 +152,7 @@ fn render_entry(entry: &Entry, config: &Config) -> String { String::new() }; format!( - "\n{header}\n\n<span class=\"entry-body\">{body}</span>{drawing_html}\n\n<span class=\"entry-separator\">{}</span>\n", + "\n{header}\n{drawing_html}\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n", config.separator ) } diff --git a/src/telegram.rs b/src/telegram.rs index 5cd7b9c..2001824 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -7,7 +7,7 @@ use crate::entries::{self, Entry, Status}; /// Send a notification to Telegram about a new entry. async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) { - let short_id = entry.id.split('-').last().unwrap_or(&entry.id); + let short_id = entry.id.split('_').last().unwrap_or(&entry.id); let text = format!( "New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}", entry.meta.name, entry.meta.website, entry.body, short_id, short_id diff --git a/src/web.rs b/src/web.rs index 61a15db..c32e0b2 100644 --- a/src/web.rs +++ b/src/web.rs @@ -742,4 +742,46 @@ mod tests { let content = std::fs::read_to_string(entries[0].as_ref().unwrap().path()).unwrap(); assert!(content.contains("drawing = \"\"")); } + + #[tokio::test] + async fn test_drawing_full_roundtrip() { + 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); + + // Submit with a drawing + 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) + ); + post_form(&app, &body).await; + + // Approve the entry + let entries_dir = dir.path().join("entries"); + let entry_file = std::fs::read_dir(&entries_dir).unwrap().next().unwrap().unwrap(); + let content = std::fs::read_to_string(entry_file.path()).unwrap(); + let id = entry_file.path().file_stem().unwrap().to_str().unwrap().to_string(); + let mut entry = entries::Entry::parse(&id, &content).unwrap(); + entry.meta.status = entries::Status::Approved; + std::fs::write(entry_file.path(), entry.to_file_contents()).unwrap(); + + let drawing_filename = entry.meta.drawing.clone(); + assert!(!drawing_filename.is_empty(), "entry should have a drawing filename"); + + // Verify index shows the drawing + let html = get_index(&app).await; + assert!(html.contains("entry-drawing")); + assert!(html.contains(&format!("/drawings/{drawing_filename}"))); + + // Verify the drawing file is served + let (status, bytes) = get_path(&app, &format!("/drawings/{drawing_filename}")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(bytes, png); + } } diff --git a/templates/default.css b/templates/default.css index 91d8ba1..c25963e 100644 --- a/templates/default.css +++ b/templates/default.css @@ -29,6 +29,7 @@ /* Entries */ .entry-header {} +.entry-date {} .entry-name {} .entry-website {} .entry-body {} From 459584cb9cca7a801c715a48ed71584fe67b7416 Mon Sep 17 00:00:00 2001 From: lew <lew@ily.rs> Date: Thu, 9 Apr 2026 23:04:52 +0100 Subject: [PATCH 8/8] docs: adds drawings mention to readme and crate desc --- Cargo.toml | 2 +- README.md | 22 +++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3f0415b..cb7578c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "guestbook" version = "0.2.1" edition = "2021" -description = "A configurable web guestbook made to be easy to use, with entries in plain text files, options for honeypots and captchas to deter spam, and moderation via Telegram bot." +description = "A configurable web guestbook made to be easy to use, with entries in plain text files, an optional drawing canvas, honeypots and captchas to deter spam, and moderation via Telegram bot." license = "MIT" repository = "https://git.ily.rs/lew/guestbook" diff --git a/README.md b/README.md index bfc15c8..11a41bc 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,14 @@ `guestbook` is a self-hosted guestbook web service with: - entries stored in plaintext, -- notifications and moderation via [Telegram](#telegram), +- optional [drawing canvas](#drawing) for visitors to sketch alongside their message, +- notifications and moderation via [Telegram](#telegram) (including drawing previews), - spam prevention via honeypot and/or [captcha](#captcha), - completely customisable [styling](#customisation), and more, written in Rust, and inspired by [t0.vc/g](https://t0.vc/g). -`guestbook` is a single binary that serves a single-page guestbook aimed at personal sites. There's a form for visitors to submit a name, message, and optionally a link to their own site. Entries are written to plain text files with TOML frontmatter, and are initially marked as pending. The frontmatter can be manually edited to mark entries as approved or denied, or a Telegram bot can be hooked up for notifications and moderation. Running the Telegram bot just requires handing over a bot token, and it'll run off the same binary. +`guestbook` is a single binary that serves a single-page guestbook aimed at personal sites. There's a form for visitors to submit a name, message, and optionally a link to their own site. Visitors can also draw a picture if the drawing feature is enabled. Entries are written to plain text files with TOML frontmatter, and are initially marked as pending. The frontmatter can be manually edited to mark entries as approved or denied, or a Telegram bot can be hooked up for notifications and moderation (drawings are sent as photos so you can see them before approving). Running the Telegram bot just requires handing over a bot token, and it'll run off the same binary. Everything is configured through environment variables (see [`.env.example`](#default-config) for the defaults). If you're hosting with Nix, there's a flake that can set up the `guestbook` service end-to-end, running on a systemd service with a Caddy reverse proxy. Optionally, just ignore the flake and set up all the extra stuff yourself. @@ -252,6 +253,16 @@ services.guestbook = { --- +### Drawing + +Set `BOOK_ENABLE_DRAWINGS=true` to add a drawing canvas to the form. Visitors draw with mouse or touch; on submit, the canvas is converted to a base64 PNG data URL in a hidden field. Drawings are stored as PNGs in `{data_dir}/drawings/` and rendered above the message body, independent of the HTML injection setting. + +Server-side validation checks the PNG magic bytes (`\x89PNG\r\n\x1a\n`), then reads width/height from the IHDR chunk and rejects anything that doesn't match `BOOK_CANVAS_WIDTH` x `BOOK_CANVAS_HEIGHT`. Max file size is derived from canvas dimensions (`w * h * 4`, the raw RGBA ceiling). A 2MB request body limit is enforced on all form submissions. + +When Telegram moderation is enabled, drawings are sent as photos in the notification so you can see them before approving. + +--- + ### Telegram To enable Telegram moderation, create a bot via [@BotFather](https://t.me/BotFather) and set `BOOK_TELEGRAM_BOT_TOKEN` to the token it gives you. Set `BOOK_TELEGRAM_CHAT_ID` to the chat ID where you want notifications sent: the easiest way to find this is to message the bot and check the [getUpdates](https://api.telegram.org/bot<token>/getUpdates) endpoint. @@ -262,19 +273,20 @@ When a visitor submits an entry, the bot sends a message with the entry details ### Entry Format -Each entry is a plain text file in `{data_dir}/entries/`. The filename is `{date}-{short_id}.txt`. +Each entry is a plain text file in `{data_dir}/entries/`. The filename is `{epoch}_{uuid}.txt`. If the entry has a drawing, the drawing is stored as `{epoch}_{uuid}.png` in `{data_dir}/drawings/` with the same prefix. ``` +++ name = "someone" -date = "2026-04-09" +date = "2026-04-09T12:00:00" website = "https://example.com" +drawing = "1744185600_abcd1234.png" status = "pending" +++ Message body here. ``` -The `status` field can be `pending`, `approved`, or `denied`. Only approved entries are displayed. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`. +The `status` field can be `pending`, `approved`, or `denied`. Only approved entries are displayed. The `drawing` field is empty when there's no drawing. To moderate without Telegram, just edit the file and change `status` to `approved` or `denied`. ---