From 726fe55eb8394516e98b90ca853cd3eaf983e616 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 18:57:58 +0100 Subject: [PATCH] refactor: renames some options to be a little clearer, and fixes up the module --- .env.example | 23 ++-- module.nix | 292 ++++++++++++++++++++++++++------------------------ src/config.rs | 44 ++++---- src/render.rs | 24 ++--- src/web.rs | 28 ++--- 5 files changed, 212 insertions(+), 199 deletions(-) diff --git a/.env.example b/.env.example index 14041b2..b7065d8 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,18 @@ BOOK_TELEGRAM_BOT_TOKEN=your-bot-token-here BOOK_TELEGRAM_CHAT_ID=0 # Enable honeypot field for spam prevention. -BOOK_HONEYPOT=true +BOOK_ENABLE_HONEYPOT=true + +# Allow new guestbook submissions. When false, the form is hidden and submissions are rejected. +BOOK_ENABLE_SUBMISSIONS=true + +# 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. +BOOK_ENABLE_WEBSITE_LINKS=true + +# Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. +# Website URLs are always escaped regardless of this setting. +BOOK_ENABLE_HTML_INJECTION=true # Maximum length for names. 0 for unlimited. BOOK_MAX_NAME_LENGTH=50 @@ -25,16 +36,6 @@ BOOK_MAX_MESSAGE_LENGTH=1000 # Maximum length for website URLs. 0 for unlimited. BOOK_MAX_WEBSITE_LENGTH=100 -# Allow new guestbook submissions. When false, the form is hidden and submissions are rejected. -BOOK_OPEN_REGISTRATION=true - -# Show website field in submission form. When false, the input is hidden and submitted values are ignored. -BOOK_ENABLE_WEBSITE_FIELD=true - -# Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. -# Website URLs are always escaped regardless of this setting. -BOOK_ALLOW_HTML_INJECTION=true - # Separator between guestbook entries. BOOK_SEPARATOR=------------------------------------------------------------ diff --git a/module.nix b/module.nix index 41af677..498c6f3 100644 --- a/module.nix +++ b/module.nix @@ -30,124 +30,6 @@ in description = "Site title shown in nav and page title."; }; - telegramChatId = mkOption { - type = types.int; - description = "Telegram chat ID for moderation messages."; - }; - - telegramBotTokenFile = mkOption { - type = types.path; - description = "Path to a file containing the Telegram bot token."; - }; - - honeypot = mkOption { - type = types.bool; - default = true; - description = "Enable honeypot field for spam prevention."; - }; - - maxNameLength = mkOption { - type = types.int; - default = 50; - description = "Maximum length for names. 0 for unlimited."; - }; - - maxMessageLength = mkOption { - type = types.int; - default = 1000; - description = "Maximum length for messages. 0 for unlimited."; - }; - - maxWebsiteLength = mkOption { - type = types.int; - default = 100; - description = "Maximum length for website URLs. 0 for unlimited."; - }; - - openRegistration = mkOption { - type = types.bool; - default = true; - description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; - }; - - enableWebsiteField = mkOption { - type = types.bool; - default = true; - description = "Show website field in submission form. When false, the input is hidden and submitted values are ignored."; - }; - - allowHtmlInjection = mkOption { - type = types.bool; - default = true; - description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped."; - }; - - separator = mkOption { - type = types.str; - default = "------------------------------------------------------------"; - description = "Separator between guestbook entries."; - }; - - style = 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"; - }; - - styleFile = mkOption { - type = types.nullOr types.path; - default = null; - description = "Path to a CSS file. Takes precedence over style."; - }; - - formPrompt = mkOption { - type = types.str; - default = "If you visited my site, please sign my guestbook!"; - description = "Text shown above the form."; - }; - - buttonText = mkOption { - type = types.str; - default = "sign"; - description = "Submit button text."; - }; - - labelName = mkOption { - type = types.str; - default = "Your name:"; - description = "Label for the name field."; - }; - - labelWebsite = mkOption { - type = types.str; - default = "Your website (optional):"; - description = "Label for the website field."; - }; - - labelMessage = mkOption { - type = types.str; - default = "Your message:"; - description = "Label for the message field."; - }; - - textareaRows = mkOption { - type = types.int; - default = 8; - description = "Number of rows for the message textarea."; - }; - - textareaCols = mkOption { - type = types.int; - default = 60; - description = "Number of columns for the message textarea."; - }; - - templateFile = mkOption { - type = types.nullOr types.path; - default = null; - description = "Custom HTML template file with {{title}}, {{form}}, and {{entries}} placeholders. Uses built-in default if null."; - }; - user = mkOption { type = types.str; default = "guestbook"; @@ -168,6 +50,136 @@ in description = "Domain for the Caddy virtual host."; }; }; + + 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 = true; + 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."; + }; + }; + + telegram = { + botTokenFile = mkOption { + type = types.path; + description = "Path to a file containing the Telegram bot token."; + }; + + chatId = mkOption { + type = types.int; + description = "Telegram chat ID for moderation messages."; + }; + }; + + limits = { + name = mkOption { + type = types.int; + default = 50; + description = "Maximum length for names. 0 for unlimited."; + }; + + message = mkOption { + type = types.int; + default = 1000; + description = "Maximum length for messages. 0 for unlimited."; + }; + + website = mkOption { + type = types.int; + default = 100; + description = "Maximum length for website URLs. 0 for unlimited."; + }; + }; + + styles = { + 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"; + }; + + cssFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to a CSS file. Takes precedence over css."; + }; + + templateFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. Uses built-in default if null."; + }; + + separator = mkOption { + type = types.str; + default = "------------------------------------------------------------"; + description = "Separator between guestbook entries."; + }; + + greeting = mkOption { + type = types.str; + default = "If you visited my site, please sign my guestbook!"; + description = "Text shown above the form."; + }; + + labels = { + submit = mkOption { + type = types.str; + default = "sign"; + description = "Submit button text."; + }; + + name = mkOption { + type = types.str; + default = "Your name:"; + description = "Label for the name field."; + }; + + website = mkOption { + type = types.str; + default = "Your website (optional):"; + description = "Label for the website field."; + }; + + message = mkOption { + type = types.str; + default = "Your message:"; + description = "Label for the message field."; + }; + }; + + message = { + rows = mkOption { + type = types.int; + default = 8; + description = "Number of rows for the message textarea."; + }; + + cols = mkOption { + type = types.int; + default = 60; + description = "Number of columns for the message textarea."; + }; + }; + }; }; config = mkIf cfg.enable (mkMerge [ @@ -182,27 +194,27 @@ in BOOK_DATA_DIR = cfg.dataDir; BOOK_SITE_TITLE = cfg.siteTitle; - BOOK_TELEGRAM_CHAT_ID = toString cfg.telegramChatId; - BOOK_HONEYPOT = if cfg.honeypot then "true" else "false"; - BOOK_MAX_NAME_LENGTH = toString cfg.maxNameLength; - BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength; - BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; - BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false"; - BOOK_ENABLE_WEBSITE_FIELD = if cfg.enableWebsiteField then "true" else "false"; - BOOK_ALLOW_HTML_INJECTION = if cfg.allowHtmlInjection then "true" else "false"; - BOOK_SEPARATOR = cfg.separator; - BOOK_STYLE = cfg.style; - } // lib.optionalAttrs (cfg.styleFile != null) { - BOOK_STYLE_FILE = cfg.styleFile; - BOOK_FORM_PROMPT = cfg.formPrompt; - BOOK_BUTTON_TEXT = cfg.buttonText; - BOOK_LABEL_NAME = cfg.labelName; - BOOK_LABEL_WEBSITE = cfg.labelWebsite; - BOOK_LABEL_MESSAGE = cfg.labelMessage; - BOOK_TEXTAREA_ROWS = toString cfg.textareaRows; - BOOK_TEXTAREA_COLS = toString cfg.textareaCols; - } // lib.optionalAttrs (cfg.templateFile != null) { - BOOK_TEMPLATE = cfg.templateFile; + BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId; + 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_MAX_NAME_LENGTH = toString cfg.limits.name; + BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message; + BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website; + BOOK_SEPARATOR = cfg.styles.separator; + BOOK_STYLE = cfg.styles.css; + BOOK_FORM_PROMPT = cfg.styles.greeting; + BOOK_BUTTON_TEXT = cfg.styles.labels.submit; + 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; + } // lib.optionalAttrs (cfg.styles.cssFile != null) { + BOOK_STYLE_FILE = cfg.styles.cssFile; + } // lib.optionalAttrs (cfg.styles.templateFile != null) { + BOOK_TEMPLATE = cfg.styles.templateFile; }; serviceConfig = { Type = "simple"; @@ -216,7 +228,7 @@ in ReadWritePaths = [ cfg.dataDir ]; }; script = '' - export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegramBotTokenFile}")" + export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")" exec ${cfg.package}/bin/guestbook ''; }; diff --git a/src/config.rs b/src/config.rs index 0b3406c..308c15f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,13 +9,13 @@ pub struct Config { pub telegram_bot_token: String, pub telegram_chat_id: i64, - pub honeypot: bool, + pub enable_honeypot: bool, pub max_name_length: usize, pub max_message_length: usize, pub max_website_length: usize, - pub open_registration: bool, - pub enable_website_field: bool, - pub allow_html_injection: bool, + pub enable_submissions: bool, + pub enable_website_links: bool, + pub enable_html_injection: bool, pub template: Option, pub separator: String, pub style: String, @@ -50,7 +50,7 @@ impl Config { .map_err(|_| "BOOK_TELEGRAM_CHAT_ID is required")? .parse() .map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer")?, - honeypot: env::var("BOOK_HONEYPOT") + enable_honeypot: env::var("BOOK_ENABLE_HONEYPOT") .map(|v| v != "false") .unwrap_or(true), max_name_length: env::var("BOOK_MAX_NAME_LENGTH") @@ -65,13 +65,13 @@ impl Config { .unwrap_or_else(|_| "100".into()) .parse() .map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?, - open_registration: env::var("BOOK_OPEN_REGISTRATION") + enable_submissions: env::var("BOOK_ENABLE_SUBMISSIONS") .map(|v| v != "false") .unwrap_or(true), - enable_website_field: env::var("BOOK_ENABLE_WEBSITE_FIELD") + enable_website_links: env::var("BOOK_ENABLE_WEBSITE_LINKS") .map(|v| v != "false") .unwrap_or(true), - allow_html_injection: env::var("BOOK_ALLOW_HTML_INJECTION") + enable_html_injection: env::var("BOOK_ENABLE_HTML_INJECTION") .map(|v| v != "false") .unwrap_or(true), separator: env::var("BOOK_SEPARATOR") @@ -167,60 +167,60 @@ mod tests { } #[test] - fn test_enable_website_field_default() { + fn test_enable_website_links_default() { let _lock = ENV_LOCK.lock().unwrap(); env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); - env::remove_var("BOOK_ENABLE_WEBSITE_FIELD"); + env::remove_var("BOOK_ENABLE_WEBSITE_LINKS"); let config = Config::from_env().unwrap(); - assert!(config.enable_website_field); + assert!(config.enable_website_links); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_CHAT_ID"); } #[test] - fn test_enable_website_field_false() { + fn test_enable_website_links_false() { let _lock = ENV_LOCK.lock().unwrap(); env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); - env::set_var("BOOK_ENABLE_WEBSITE_FIELD", "false"); + env::set_var("BOOK_ENABLE_WEBSITE_LINKS", "false"); let config = Config::from_env().unwrap(); - assert!(!config.enable_website_field); + assert!(!config.enable_website_links); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_CHAT_ID"); - env::remove_var("BOOK_ENABLE_WEBSITE_FIELD"); + env::remove_var("BOOK_ENABLE_WEBSITE_LINKS"); } #[test] - fn test_allow_html_injection_default() { + fn test_enable_html_injection_default() { let _lock = ENV_LOCK.lock().unwrap(); env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); - env::remove_var("BOOK_ALLOW_HTML_INJECTION"); + env::remove_var("BOOK_ENABLE_HTML_INJECTION"); let config = Config::from_env().unwrap(); - assert!(config.allow_html_injection); + assert!(config.enable_html_injection); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_CHAT_ID"); } #[test] - fn test_allow_html_injection_false() { + fn test_enable_html_injection_false() { let _lock = ENV_LOCK.lock().unwrap(); env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); - env::set_var("BOOK_ALLOW_HTML_INJECTION", "false"); + env::set_var("BOOK_ENABLE_HTML_INJECTION", "false"); let config = Config::from_env().unwrap(); - assert!(!config.allow_html_injection); + assert!(!config.enable_html_injection); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_CHAT_ID"); - env::remove_var("BOOK_ALLOW_HTML_INJECTION"); + env::remove_var("BOOK_ENABLE_HTML_INJECTION"); } } diff --git a/src/render.rs b/src/render.rs index 60cd5a9..eb04c3e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -20,7 +20,7 @@ pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html } pub fn render_form(config: &Config) -> String { - let website_section = if config.enable_website_field { + let website_section = if config.enable_website_links { format!( "\n\n\n", config.label_website @@ -67,7 +67,7 @@ fn render_entries(entries: &[Entry], config: &Config) -> String { } fn render_entry(entry: &Entry, config: &Config) -> String { - let name = if config.allow_html_injection { + let name = if config.enable_html_injection { entry.meta.name.clone() } else { escape_html(&entry.meta.name) @@ -76,7 +76,7 @@ fn render_entry(entry: &Entry, config: &Config) -> String { "{} - {}", entry.meta.date, name ); - if config.enable_website_field && !entry.meta.website.is_empty() { + if config.enable_website_links && !entry.meta.website.is_empty() { let website = escape_html(&entry.meta.website); header.push_str(&format!( " ({})", @@ -84,7 +84,7 @@ fn render_entry(entry: &Entry, config: &Config) -> String { )); } header.push_str(""); - let body = if config.allow_html_injection { + let body = if config.enable_html_injection { entry.body.clone() } else { escape_html(&entry.body) @@ -109,13 +109,13 @@ mod tests { telegram_bot_token: "fake".into(), telegram_chat_id: 0, - honeypot: true, + enable_honeypot: true, max_name_length: 50, max_message_length: 1000, max_website_length: 100, - open_registration: true, - enable_website_field: true, - allow_html_injection: true, + enable_submissions: true, + enable_website_links: true, + enable_html_injection: true, template: None, separator: "---".into(), style: String::new(), @@ -234,7 +234,7 @@ mod tests { #[test] fn test_render_form_hides_website_when_disabled() { let mut config = test_config(); - config.enable_website_field = false; + config.enable_website_links = false; let form = render_form(&config); assert!(!form.contains("name=\"website\"")); assert!(!form.contains(&config.label_website)); @@ -262,7 +262,7 @@ mod tests { #[test] fn test_render_entry_hides_website_when_disabled() { let mut config = test_config(); - config.enable_website_field = false; + config.enable_website_links = false; let mut entry = make_entry("bob", "2026-04-09", "Hi!"); entry.meta.website = "https://bob.com".into(); let form = render_form(&config); @@ -274,7 +274,7 @@ mod tests { #[test] fn test_render_entry_escapes_html_when_injection_disabled() { let mut config = test_config(); - config.allow_html_injection = false; + config.enable_html_injection = false; let entry = make_entry("hacker", "2026-04-09", ""); let form = render_form(&config); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); @@ -286,7 +286,7 @@ mod tests { #[test] fn test_render_entry_preserves_html_when_injection_enabled() { let mut config = test_config(); - config.allow_html_injection = true; + config.enable_html_injection = true; let entry = make_entry("carol", "2026-04-09", "Bold"); let form = render_form(&config); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); diff --git a/src/web.rs b/src/web.rs index 9764ae7..57cc9ee 100644 --- a/src/web.rs +++ b/src/web.rs @@ -37,7 +37,7 @@ pub fn router(state: Arc) -> Router { async fn index(State(state): State>) -> Html { let entries_dir = state.config.data_dir.join("entries"); let entries = entries::read_approved(&entries_dir); - let form = if state.config.open_registration { + let form = if state.config.enable_submissions { render::render_form(&state.config) } else { String::new() @@ -56,19 +56,19 @@ async fn submit( State(state): State>, Form(form): Form, ) -> Html { - if !state.config.open_registration { + if !state.config.enable_submissions { return Html("Submissions are closed.".to_string()); } // Honeypot check — silently discard - if state.config.honeypot && !form.url.is_empty() { + if state.config.enable_honeypot && !form.url.is_empty() { return Html("Thanks! Your message is pending approval.".to_string()); } // Validation let name = form.name.trim().to_string(); let message = form.message.trim().to_string(); - let website = if state.config.enable_website_field { + let website = if state.config.enable_website_links { form.website.trim().to_string() } else { String::new() @@ -136,13 +136,13 @@ mod tests { telegram_bot_token: "fake".into(), telegram_chat_id: 0, - honeypot: true, + enable_honeypot: true, max_name_length: 50, max_message_length: 1000, max_website_length: 100, - open_registration: true, - enable_website_field: true, - allow_html_injection: true, + enable_submissions: true, + enable_website_links: true, + enable_html_injection: true, template: None, separator: "---".into(), style: String::new(), @@ -186,7 +186,7 @@ mod tests { } #[tokio::test] - async fn test_open_registration_shows_form() { + async fn test_enable_submissions_shows_form() { let dir = tempfile::tempdir().unwrap(); let config = test_config(dir.path()); let (app, _rx) = test_app(config); @@ -198,7 +198,7 @@ mod tests { async fn test_closed_registration_hides_form() { let dir = tempfile::tempdir().unwrap(); let mut config = test_config(dir.path()); - config.open_registration = false; + config.enable_submissions = false; let (app, _rx) = test_app(config); let html = get_index(&app).await; assert!(!html.contains("action=\"/submit\"")); @@ -208,7 +208,7 @@ mod tests { async fn test_closed_registration_rejects_submit() { let dir = tempfile::tempdir().unwrap(); let mut config = test_config(dir.path()); - config.open_registration = false; + config.enable_submissions = false; let (app, _rx) = test_app(config); let (status, body) = post_form(&app, "name=test&message=hello").await; assert_eq!(status, StatusCode::OK); @@ -234,7 +234,7 @@ mod tests { async fn test_honeypot_disabled_allows_url_field() { let dir = tempfile::tempdir().unwrap(); let mut config = test_config(dir.path()); - config.honeypot = false; + config.enable_honeypot = false; let (app, _rx) = test_app(config); let (_, body) = post_form(&app, "name=user&message=hello&url=http://mysite.com").await; assert!(body.contains("pending approval")); @@ -313,7 +313,7 @@ mod tests { async fn test_website_field_disabled_ignores_website() { let dir = tempfile::tempdir().unwrap(); let mut config = test_config(dir.path()); - config.enable_website_field = false; + config.enable_website_links = false; let (app, _rx) = test_app(config); let (_, body) = post_form(&app, "name=alice&message=hello&website=http://evil.com").await; assert!(body.contains("pending approval")); @@ -328,7 +328,7 @@ mod tests { async fn test_website_field_disabled_hides_form_field() { let dir = tempfile::tempdir().unwrap(); let mut config = test_config(dir.path()); - config.enable_website_field = false; + config.enable_website_links = false; let (app, _rx) = test_app(config); let html = get_index(&app).await; assert!(!html.contains("name=\"website\""));