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 {