diff --git a/src/config.rs b/src/config.rs index b86eced..0b3406c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { 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 template: Option, pub separator: String, pub style: String, @@ -66,6 +68,12 @@ impl Config { open_registration: env::var("BOOK_OPEN_REGISTRATION") .map(|v| v != "false") .unwrap_or(true), + enable_website_field: env::var("BOOK_ENABLE_WEBSITE_FIELD") + .map(|v| v != "false") + .unwrap_or(true), + allow_html_injection: env::var("BOOK_ALLOW_HTML_INJECTION") + .map(|v| v != "false") + .unwrap_or(true), separator: env::var("BOOK_SEPARATOR") .unwrap_or_else(|_| "------------------------------------------------------------".into()), template: env::var("BOOK_TEMPLATE").ok().map(|path| { @@ -157,4 +165,62 @@ mod tests { let result = Config::from_env(); assert!(result.is_err()); } + + #[test] + fn test_enable_website_field_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"); + + let config = Config::from_env().unwrap(); + assert!(config.enable_website_field); + + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + } + + #[test] + fn test_enable_website_field_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"); + + let config = Config::from_env().unwrap(); + assert!(!config.enable_website_field); + + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + env::remove_var("BOOK_ENABLE_WEBSITE_FIELD"); + } + + #[test] + fn test_allow_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"); + + let config = Config::from_env().unwrap(); + assert!(config.allow_html_injection); + + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + } + + #[test] + fn test_allow_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"); + + let config = Config::from_env().unwrap(); + assert!(!config.allow_html_injection); + + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + env::remove_var("BOOK_ALLOW_HTML_INJECTION"); + } } diff --git a/src/render.rs b/src/render.rs index 9fcf179..60cd5a9 100644 --- a/src/render.rs +++ b/src/render.rs @@ -5,7 +5,7 @@ pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/default.html"); pub const DEFAULT_STYLE: &str = include_str!("../templates/default.css"); pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String { - let entries_html = render_entries(entries, &config.separator); + let entries_html = render_entries(entries, config); let css = if config.style.is_empty() { DEFAULT_STYLE } else { @@ -20,15 +20,21 @@ 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 { + format!( + "\n\n\n", + config.label_website + ) + } else { + String::new() + }; + format!( r#"{prompt}
- - - - +{website_section} @@ -36,7 +42,7 @@ pub fn render_form(config: &Config) -> String {
"#, prompt = config.form_prompt, label_name = config.label_name, - label_website = config.label_website, + website_section = website_section, label_message = config.label_message, rows = config.textarea_rows, cols = config.textarea_cols, @@ -44,29 +50,48 @@ pub fn render_form(config: &Config) -> String { ) } -fn render_entries(entries: &[Entry], separator: &str) -> String { +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn render_entries(entries: &[Entry], config: &Config) -> String { let mut html = String::new(); for entry in entries { - html.push_str(&render_entry(entry, separator)); + html.push_str(&render_entry(entry, config)); } html } -fn render_entry(entry: &Entry, separator: &str) -> String { +fn render_entry(entry: &Entry, config: &Config) -> String { + let name = if config.allow_html_injection { + entry.meta.name.clone() + } else { + escape_html(&entry.meta.name) + }; let mut header = format!( "{} - {}", - entry.meta.date, entry.meta.name + entry.meta.date, name ); - if !entry.meta.website.is_empty() { + if config.enable_website_field && !entry.meta.website.is_empty() { + let website = escape_html(&entry.meta.website); header.push_str(&format!( " ({})", - entry.meta.website, entry.meta.website + website, website )); } header.push_str(""); + let body = if config.allow_html_injection { + entry.body.clone() + } else { + escape_html(&entry.body) + }; format!( - "\n{header}\n\n{}\n\n{separator}\n", - entry.body + "\n{header}\n\n{body}\n\n{}\n", + config.separator ) } @@ -89,6 +114,8 @@ mod tests { max_message_length: 1000, max_website_length: 100, open_registration: true, + enable_website_field: true, + allow_html_injection: true, template: None, separator: "---".into(), style: String::new(), @@ -203,4 +230,74 @@ mod tests { assert!(form.contains("rows=\"12\"")); assert!(form.contains("cols=\"40\"")); } + + #[test] + fn test_render_form_hides_website_when_disabled() { + let mut config = test_config(); + config.enable_website_field = false; + let form = render_form(&config); + assert!(!form.contains("name=\"website\"")); + assert!(!form.contains(&config.label_website)); + } + + #[test] + fn test_render_form_shows_website_when_enabled() { + let config = test_config(); + let form = render_form(&config); + assert!(form.contains("name=\"website\"")); + assert!(form.contains(&config.label_website)); + } + + #[test] + fn test_render_entry_always_escapes_website() { + let config = test_config(); + let mut entry = make_entry("bob", "2026-04-09", "Hi!"); + entry.meta.website = "https://example.com?a=1&b=2".into(); + let form = render_form(&config); + let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); + assert!(html.contains("href=\"https://example.com?a=1&b=2\"")); + assert!(!html.contains("href=\"https://example.com?a=1&b=2\"")); + } + + #[test] + fn test_render_entry_hides_website_when_disabled() { + let mut config = test_config(); + config.enable_website_field = false; + let mut entry = make_entry("bob", "2026-04-09", "Hi!"); + entry.meta.website = "https://bob.com".into(); + let form = render_form(&config); + let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); + assert!(!html.contains("href=\"https://bob.com\"")); + assert!(!html.contains("class=\"entry-website\"")); + } + + #[test] + fn test_render_entry_escapes_html_when_injection_disabled() { + let mut config = test_config(); + config.allow_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); + assert!(html.contains("<b>hacker</b>")); + assert!(html.contains("<script>alert('xss')</script>")); + assert!(!html.contains("