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>)>(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) { - while let Some(entry) = rx.recv().await { +pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option>)>) { + 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, + pub tx: tokio::sync::mpsc::Sender<(Entry, Option>)>, } #[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) { + fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<(Entry, Option>)>) { let (tx, rx) = tokio::sync::mpsc::channel(32); let state = Arc::new(AppState { config, tx }); (router(state), rx)