feat: reimplements the captcha and related options
This commit is contained in:
parent
726fe55eb8
commit
75f1644cc1
5 changed files with 242 additions and 30 deletions
25
.env.example
25
.env.example
|
|
@ -25,16 +25,31 @@ 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
|
||||
BOOK_ENABLE_HTML_INJECTION=false
|
||||
|
||||
# Enable captcha on submission form.
|
||||
BOOK_ENABLE_CAPTCHA=false
|
||||
|
||||
# Captcha question displayed as a label.
|
||||
# BOOK_CAPTCHA_QUESTION=What is my name?
|
||||
|
||||
# Captcha answer to validate against.
|
||||
# BOOK_CAPTCHA_ANSWER=lew
|
||||
|
||||
# Require exact match (true) or just "contains" (false).
|
||||
BOOK_CAPTCHA_EXACT=false
|
||||
|
||||
# Require case-sensitive match.
|
||||
BOOK_CAPTCHA_CASESENSITIVE=false
|
||||
|
||||
# Maximum length for names. 0 for unlimited.
|
||||
BOOK_MAX_NAME_LENGTH=50
|
||||
BOOK_MAX_NAME_LENGTH=0
|
||||
|
||||
# Maximum length for messages. 0 for unlimited.
|
||||
BOOK_MAX_MESSAGE_LENGTH=1000
|
||||
BOOK_MAX_MESSAGE_LENGTH=0
|
||||
|
||||
# Maximum length for website URLs. 0 for unlimited.
|
||||
BOOK_MAX_WEBSITE_LENGTH=100
|
||||
BOOK_MAX_WEBSITE_LENGTH=0
|
||||
|
||||
# Separator between guestbook entries.
|
||||
BOOK_SEPARATOR=------------------------------------------------------------
|
||||
|
|
@ -49,7 +64,7 @@ BOOK_SEPARATOR=------------------------------------------------------------
|
|||
# BOOK_STYLE=
|
||||
|
||||
# Text shown above the form.
|
||||
BOOK_FORM_PROMPT=If you visited my site, please sign my guestbook!
|
||||
BOOK_FORM_PROMPT=Thanks for visiting. Sign the guestbook!
|
||||
|
||||
# Submit button text.
|
||||
BOOK_BUTTON_TEXT=sign
|
||||
|
|
|
|||
43
module.nix
43
module.nix
|
|
@ -60,7 +60,7 @@ in
|
|||
|
||||
enableHtmlInjection = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
default = false;
|
||||
description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped.";
|
||||
};
|
||||
|
||||
|
|
@ -75,6 +75,34 @@ in
|
|||
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.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
telegram = {
|
||||
|
|
@ -92,19 +120,19 @@ in
|
|||
limits = {
|
||||
name = mkOption {
|
||||
type = types.int;
|
||||
default = 50;
|
||||
default = 0;
|
||||
description = "Maximum length for names. 0 for unlimited.";
|
||||
};
|
||||
|
||||
message = mkOption {
|
||||
type = types.int;
|
||||
default = 1000;
|
||||
default = 0;
|
||||
description = "Maximum length for messages. 0 for unlimited.";
|
||||
};
|
||||
|
||||
website = mkOption {
|
||||
type = types.int;
|
||||
default = 100;
|
||||
default = 0;
|
||||
description = "Maximum length for website URLs. 0 for unlimited.";
|
||||
};
|
||||
};
|
||||
|
|
@ -136,7 +164,7 @@ in
|
|||
|
||||
greeting = mkOption {
|
||||
type = types.str;
|
||||
default = "If you visited my site, please sign my guestbook!";
|
||||
default = "Thanks for visiting. Sign the guestbook!";
|
||||
description = "Text shown above the form.";
|
||||
};
|
||||
|
||||
|
|
@ -199,6 +227,11 @@ in
|
|||
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_MAX_NAME_LENGTH = toString cfg.limits.name;
|
||||
BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message;
|
||||
BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ pub struct Config {
|
|||
pub enable_submissions: bool,
|
||||
pub enable_website_links: bool,
|
||||
pub enable_html_injection: bool,
|
||||
pub enable_captcha: bool,
|
||||
pub captcha_question: String,
|
||||
pub captcha_answer: String,
|
||||
pub captcha_exact: bool,
|
||||
pub captcha_casesensitive: bool,
|
||||
pub template: Option<String>,
|
||||
pub separator: String,
|
||||
pub style: String,
|
||||
|
|
@ -54,15 +59,15 @@ impl Config {
|
|||
.map(|v| v != "false")
|
||||
.unwrap_or(true),
|
||||
max_name_length: env::var("BOOK_MAX_NAME_LENGTH")
|
||||
.unwrap_or_else(|_| "50".into())
|
||||
.unwrap_or_else(|_| "0".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_MAX_NAME_LENGTH must be a number")?,
|
||||
max_message_length: env::var("BOOK_MAX_MESSAGE_LENGTH")
|
||||
.unwrap_or_else(|_| "1000".into())
|
||||
.unwrap_or_else(|_| "0".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_MAX_MESSAGE_LENGTH must be a number")?,
|
||||
max_website_length: env::var("BOOK_MAX_WEBSITE_LENGTH")
|
||||
.unwrap_or_else(|_| "100".into())
|
||||
.unwrap_or_else(|_| "0".into())
|
||||
.parse()
|
||||
.map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?,
|
||||
enable_submissions: env::var("BOOK_ENABLE_SUBMISSIONS")
|
||||
|
|
@ -73,7 +78,20 @@ impl Config {
|
|||
.unwrap_or(true),
|
||||
enable_html_injection: env::var("BOOK_ENABLE_HTML_INJECTION")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(true),
|
||||
.unwrap_or(false),
|
||||
enable_captcha: env::var("BOOK_ENABLE_CAPTCHA")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(false),
|
||||
captcha_question: env::var("BOOK_CAPTCHA_QUESTION")
|
||||
.unwrap_or_default(),
|
||||
captcha_answer: env::var("BOOK_CAPTCHA_ANSWER")
|
||||
.unwrap_or_default(),
|
||||
captcha_exact: env::var("BOOK_CAPTCHA_EXACT")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(false),
|
||||
captcha_casesensitive: env::var("BOOK_CAPTCHA_CASESENSITIVE")
|
||||
.map(|v| v != "false")
|
||||
.unwrap_or(false),
|
||||
separator: env::var("BOOK_SEPARATOR")
|
||||
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
|
||||
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
|
||||
|
|
@ -89,7 +107,7 @@ impl Config {
|
|||
.or_else(|| env::var("BOOK_STYLE").ok())
|
||||
.unwrap_or_default(),
|
||||
form_prompt: env::var("BOOK_FORM_PROMPT")
|
||||
.unwrap_or_else(|_| "If you visited my site, please sign my guestbook!".into()),
|
||||
.unwrap_or_else(|_| "Thanks for visiting. Sign the guestbook!".into()),
|
||||
button_text: env::var("BOOK_BUTTON_TEXT")
|
||||
.unwrap_or_else(|_| "sign".into()),
|
||||
label_name: env::var("BOOK_LABEL_NAME")
|
||||
|
|
@ -203,21 +221,21 @@ mod tests {
|
|||
env::remove_var("BOOK_ENABLE_HTML_INJECTION");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
assert!(config.enable_html_injection);
|
||||
assert!(!config.enable_html_injection);
|
||||
|
||||
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_html_injection_false() {
|
||||
fn test_enable_html_injection_true() {
|
||||
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_HTML_INJECTION", "false");
|
||||
env::set_var("BOOK_ENABLE_HTML_INJECTION", "true");
|
||||
|
||||
let config = Config::from_env().unwrap();
|
||||
assert!(!config.enable_html_injection);
|
||||
assert!(config.enable_html_injection);
|
||||
|
||||
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
|
||||
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ pub fn render_form(config: &Config) -> String {
|
|||
String::new()
|
||||
};
|
||||
|
||||
let captcha_section = if config.enable_captcha {
|
||||
format!(
|
||||
"\n<label class=\"guestbook-label\">{}</label>\n<input class=\"guestbook-input\" name=\"captcha\" required>\n",
|
||||
config.captcha_question
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<span class="guestbook-prompt">{prompt}</span>
|
||||
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
||||
|
|
@ -37,6 +46,7 @@ pub fn render_form(config: &Config) -> String {
|
|||
{website_section}
|
||||
<label class="guestbook-label">{label_message}</label>
|
||||
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
|
||||
{captcha_section}
|
||||
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
||||
<button class="guestbook-button" type="submit">{button}</button>
|
||||
</form>"#,
|
||||
|
|
@ -46,6 +56,7 @@ pub fn render_form(config: &Config) -> String {
|
|||
label_message = config.label_message,
|
||||
rows = config.textarea_rows,
|
||||
cols = config.textarea_cols,
|
||||
captcha_section = captcha_section,
|
||||
button = config.button_text,
|
||||
)
|
||||
}
|
||||
|
|
@ -110,16 +121,21 @@ mod tests {
|
|||
telegram_bot_token: "fake".into(),
|
||||
telegram_chat_id: 0,
|
||||
enable_honeypot: true,
|
||||
max_name_length: 50,
|
||||
max_message_length: 1000,
|
||||
max_website_length: 100,
|
||||
max_name_length: 0,
|
||||
max_message_length: 0,
|
||||
max_website_length: 0,
|
||||
enable_submissions: true,
|
||||
enable_website_links: true,
|
||||
enable_html_injection: true,
|
||||
enable_html_injection: false,
|
||||
enable_captcha: false,
|
||||
captcha_question: String::new(),
|
||||
captcha_answer: String::new(),
|
||||
captcha_exact: false,
|
||||
captcha_casesensitive: false,
|
||||
template: None,
|
||||
separator: "---".into(),
|
||||
style: String::new(),
|
||||
form_prompt: "Sign my guestbook!".into(),
|
||||
form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
|
||||
button_text: "sign".into(),
|
||||
label_name: "Your name:".into(),
|
||||
label_website: "Your website (optional):".into(),
|
||||
|
|
@ -186,7 +202,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_render_preserves_html_in_body() {
|
||||
let config = test_config();
|
||||
let mut config = test_config();
|
||||
config.enable_html_injection = true;
|
||||
let entry = make_entry("carol", "2026-04-09", "<b>Bold</b>");
|
||||
let form = render_form(&config);
|
||||
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||
|
|
|
|||
139
src/web.rs
139
src/web.rs
|
|
@ -25,6 +25,8 @@ pub struct SubmitForm {
|
|||
message: String,
|
||||
#[serde(default)]
|
||||
url: String, // honeypot
|
||||
#[serde(default)]
|
||||
captcha: String,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
|
|
@ -74,6 +76,30 @@ async fn submit(
|
|||
String::new()
|
||||
};
|
||||
|
||||
// Captcha check
|
||||
if state.config.enable_captcha {
|
||||
let input = form.captcha.trim();
|
||||
let answer = &state.config.captcha_answer;
|
||||
let ok = if state.config.captcha_casesensitive {
|
||||
if state.config.captcha_exact {
|
||||
input == answer
|
||||
} else {
|
||||
input.contains(answer.as_str())
|
||||
}
|
||||
} else {
|
||||
let input_lower = input.to_lowercase();
|
||||
let answer_lower = answer.to_lowercase();
|
||||
if state.config.captcha_exact {
|
||||
input_lower == answer_lower
|
||||
} else {
|
||||
input_lower.contains(&answer_lower)
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
return Html("Wrong answer.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if name.is_empty() || message.is_empty() {
|
||||
return Html("Name and message are required.".to_string());
|
||||
}
|
||||
|
|
@ -137,16 +163,21 @@ mod tests {
|
|||
telegram_bot_token: "fake".into(),
|
||||
telegram_chat_id: 0,
|
||||
enable_honeypot: true,
|
||||
max_name_length: 50,
|
||||
max_message_length: 1000,
|
||||
max_website_length: 100,
|
||||
max_name_length: 0,
|
||||
max_message_length: 0,
|
||||
max_website_length: 0,
|
||||
enable_submissions: true,
|
||||
enable_website_links: true,
|
||||
enable_html_injection: true,
|
||||
enable_html_injection: false,
|
||||
enable_captcha: false,
|
||||
captcha_question: String::new(),
|
||||
captcha_answer: String::new(),
|
||||
captcha_exact: false,
|
||||
captcha_casesensitive: false,
|
||||
template: None,
|
||||
separator: "---".into(),
|
||||
style: String::new(),
|
||||
form_prompt: "Sign my guestbook!".into(),
|
||||
form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
|
||||
button_text: "sign".into(),
|
||||
label_name: "Your name:".into(),
|
||||
label_website: "Your website (optional):".into(),
|
||||
|
|
@ -333,4 +364,102 @@ mod tests {
|
|||
let html = get_index(&app).await;
|
||||
assert!(!html.contains("name=\"website\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_rejects_wrong_answer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_question = "What is my name?".into();
|
||||
config.captcha_answer = "lew".into();
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=wrong").await;
|
||||
assert!(body.contains("Wrong answer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_accepts_correct_answer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_question = "What is my name?".into();
|
||||
config.captcha_answer = "lew".into();
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=lew").await;
|
||||
assert!(body.contains("pending approval"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_inexact_contains() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_exact = false;
|
||||
config.captcha_question = "What is my name?".into();
|
||||
config.captcha_answer = "lew".into();
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=lewis").await;
|
||||
assert!(body.contains("pending approval"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_inexact_rejects_no_match() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_exact = false;
|
||||
config.captcha_question = "What is my name?".into();
|
||||
config.captcha_answer = "lew".into();
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=bob").await;
|
||||
assert!(body.contains("Wrong answer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_casesensitive() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_question = "What is my name?".into();
|
||||
config.captcha_answer = "lew".into();
|
||||
config.captcha_casesensitive = true;
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=Lew").await;
|
||||
assert!(body.contains("Wrong answer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_case_insensitive() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_question = "What is my name?".into();
|
||||
config.captcha_answer = "lew".into();
|
||||
config.captcha_casesensitive = false;
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=LEW").await;
|
||||
assert!(body.contains("pending approval"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_disabled_skips_check() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = test_config(dir.path());
|
||||
let (app, _rx) = test_app(config);
|
||||
let (_, body) = post_form(&app, "name=alice&message=hello").await;
|
||||
assert!(body.contains("pending approval"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_captcha_shows_in_form() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_config(dir.path());
|
||||
config.enable_captcha = true;
|
||||
config.captcha_question = "What is 2+2?".into();
|
||||
config.captcha_answer = "4".into();
|
||||
let (app, _rx) = test_app(config);
|
||||
let html = get_index(&app).await;
|
||||
assert!(html.contains("What is 2+2?"));
|
||||
assert!(html.contains("name=\"captcha\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue