feat: extracts out the success page to an actual template

This commit is contained in:
Lewis Wynne 2026-04-10 01:22:09 +01:00
parent 1371c006e9
commit c05cbf4cbc
9 changed files with 216 additions and 42 deletions

View file

@ -88,6 +88,11 @@
# Uses built-in default if unset. # Uses built-in default if unset.
# BOOK_TEMPLATE=./templates/default.html # BOOK_TEMPLATE=./templates/default.html
# Custom success page template shown after a successful submission.
# Supports {{title}} and {{style}} placeholders. Use <script> for dynamic behavior.
# Uses built-in templates/success.html if unset.
# BOOK_SUCCESS_TEMPLATE=./templates/success.html
# Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/. # Enable drawing canvas in submission form. Drawings are stored as PNG files in DATA_DIR/drawings/.
# BOOK_ENABLE_DRAWINGS=false # BOOK_ENABLE_DRAWINGS=false

2
Cargo.lock generated
View file

@ -458,7 +458,7 @@ dependencies = [
[[package]] [[package]]
name = "guestbook" name = "guestbook"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "guestbook" name = "guestbook"
version = "0.2.1" version = "0.2.2"
edition = "2021" edition = "2021"
description = "A configurable web guestbook made to be easy to use, with entries in plain text files, an optional drawing canvas, honeypots and captchas to deter spam, and moderation via Telegram bot." description = "A configurable web guestbook made to be easy to use, with entries in plain text files, an optional drawing canvas, honeypots and captchas to deter spam, and moderation via Telegram bot."
license = "MIT" license = "MIT"

View file

@ -60,7 +60,7 @@ This will run the site on localhost on the port you've configured, or `8123` by
enable = true; enable = true;
package = guestbook.packages.x86_64-linux.default; package = guestbook.packages.x86_64-linux.default;
siteTitle = "my guestbook"; siteTitle = "my guestbook";
telegram = { features.telegram = {
enable = true; enable = true;
botTokenFile = "/run/secrets/guestbook-bot-token"; botTokenFile = "/run/secrets/guestbook-bot-token";
chatId = 12345; chatId = 12345;
@ -177,6 +177,11 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. # Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
# Uses built-in default if unset. # Uses built-in default if unset.
# BOOK_TEMPLATE=./templates/default.html # BOOK_TEMPLATE=./templates/default.html
# Custom success page template shown after a successful submission.
# Supports {{title}} and {{style}} placeholders. Use <script> for dynamic behavior.
# Uses built-in templates/success.html if unset.
# BOOK_SUCCESS_TEMPLATE=./templates/success.html
``` ```
#### NixOS Module #### NixOS Module
@ -205,6 +210,11 @@ services.guestbook = {
canvasWidth = 400; canvasWidth = 400;
canvasHeight = 200; canvasHeight = 200;
}; };
telegram = {
enable = false;
# botTokenFile = <path>; -- required when enabled
# chatId = <int>; -- required when enabled
};
security = { security = {
htmlInjection.enable = false; htmlInjection.enable = false;
honeypot.enable = true; honeypot.enable = true;
@ -218,12 +228,6 @@ services.guestbook = {
}; };
}; };
telegram = {
enable = false;
# botTokenFile = <path>; -- required when enabled
# chatId = <int>; -- required when enabled
};
limits = { limits = {
name = 0; name = 0;
message = 0; message = 0;
@ -234,6 +238,7 @@ services.guestbook = {
css = ""; css = "";
cssFile = null; cssFile = null;
templateFile = null; templateFile = null;
successTemplateFile = null;
separator = "------------------------------------------------------------"; separator = "------------------------------------------------------------";
greeting = "Thanks for visiting. Sign the guestbook!"; greeting = "Thanks for visiting. Sign the guestbook!";
labels = { labels = {
@ -340,6 +345,12 @@ entries
</html> </html>
``` ```
#### Success Page
After a successful submission, visitors see a success page. The default is built into the binary from `templates/success.html`. To customise it, copy the file and point `BOOK_SUCCESS_TEMPLATE` at your copy. The `{{title}}` and `{{style}}` placeholders work the same as in the main template. Use `<script>` for dynamic behavior like showing the current time.
Validation errors (empty fields, wrong captcha, etc.) show a simple error page with the error message and a back link. This page is not customisable.
#### Default CSS #### Default CSS
```css ```css

View file

@ -94,6 +94,20 @@ in
}; };
}; };
telegram = {
enable = mkEnableOption "Telegram moderation notifications";
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.";
};
};
security = { security = {
htmlInjection = { htmlInjection = {
enable = mkOption { enable = mkOption {
@ -141,20 +155,6 @@ in
}; };
}; };
telegram = {
enable = mkEnableOption "Telegram moderation notifications";
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 = { limits = {
name = mkOption { name = mkOption {
type = types.int; type = types.int;
@ -194,6 +194,12 @@ in
description = "Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. Uses built-in default if null."; description = "Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders. Uses built-in default if null.";
}; };
successTemplateFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Custom success page template with {{title}} and {{style}} placeholders. Uses built-in default if null.";
};
separator = mkOption { separator = mkOption {
type = types.str; type = types.str;
default = "------------------------------------------------------------"; default = "------------------------------------------------------------";
@ -295,8 +301,10 @@ in
BOOK_STYLE_FILE = cfg.styles.cssFile; BOOK_STYLE_FILE = cfg.styles.cssFile;
} // lib.optionalAttrs (cfg.styles.templateFile != null) { } // lib.optionalAttrs (cfg.styles.templateFile != null) {
BOOK_TEMPLATE = cfg.styles.templateFile; BOOK_TEMPLATE = cfg.styles.templateFile;
} // lib.optionalAttrs cfg.telegram.enable { } // lib.optionalAttrs (cfg.styles.successTemplateFile != null) {
BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId; BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile;
} // lib.optionalAttrs cfg.features.telegram.enable {
BOOK_TELEGRAM_CHAT_ID = toString cfg.features.telegram.chatId;
}; };
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
@ -310,8 +318,8 @@ in
ReadWritePaths = [ cfg.dataDir ]; ReadWritePaths = [ cfg.dataDir ];
}; };
script = '' script = ''
${lib.optionalString cfg.telegram.enable '' ${lib.optionalString cfg.features.telegram.enable ''
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")" export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.features.telegram.botTokenFile}")"
''} ''}
exec ${cfg.package}/bin/guestbook exec ${cfg.package}/bin/guestbook
''; '';

View file

@ -26,6 +26,7 @@ pub struct Config {
pub canvas_width: u32, pub canvas_width: u32,
pub canvas_height: u32, pub canvas_height: u32,
pub template: Option<String>, pub template: Option<String>,
pub success_template: Option<String>,
pub separator: String, pub separator: String,
pub style: String, pub style: String,
pub form_prompt: String, pub form_prompt: String,
@ -120,6 +121,10 @@ impl Config {
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read template {path}: {e}")) .unwrap_or_else(|e| panic!("failed to read template {path}: {e}"))
}), }),
success_template: env::var("BOOK_SUCCESS_TEMPLATE").ok().map(|path| {
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read success template {path}: {e}"))
}),
style: env::var("BOOK_STYLE_FILE") style: env::var("BOOK_STYLE_FILE")
.ok() .ok()
.map(|path| { .map(|path| {
@ -280,4 +285,15 @@ mod tests {
assert_eq!(config.max_drawing_bytes(), 400 * 200 * 4); assert_eq!(config.max_drawing_bytes(), 400 * 200 * 4);
assert_eq!(config.label_drawing, "Draw (optional):"); assert_eq!(config.label_drawing, "Draw (optional):");
} }
#[test]
fn test_success_template_default() {
let _lock = ENV_LOCK.lock().unwrap();
env::remove_var("BOOK_SUCCESS_TEMPLATE");
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
let config = Config::from_env().unwrap();
assert!(config.success_template.is_none());
}
} }

View file

@ -2,6 +2,7 @@ use crate::config::Config;
use crate::entries::Entry; use crate::entries::Entry;
pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/default.html"); pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/default.html");
pub const DEFAULT_SUCCESS_TEMPLATE: &str = include_str!("../templates/success.html");
pub const DEFAULT_STYLE: &str = include_str!("../templates/default.css"); pub const DEFAULT_STYLE: &str = include_str!("../templates/default.css");
pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String { pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String {
@ -111,6 +112,48 @@ pub fn render_form(config: &Config) -> String {
) )
} }
pub fn render_success_page(config: &Config) -> String {
let template = config.success_template.as_deref().unwrap_or(DEFAULT_SUCCESS_TEMPLATE);
let css = if config.style.is_empty() {
DEFAULT_STYLE
} else {
&config.style
};
let style = format!("<style>\n{css}\n </style>");
template
.replace("{{title}}", &config.site_title)
.replace("{{style}}", &style)
}
pub fn render_error_page(config: &Config, error: &str) -> String {
let css = if config.style.is_empty() {
DEFAULT_STYLE
} else {
&config.style
};
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
{css}
</style>
</head>
<body>
<div class="page-container">
{error}
<a href="/">&#8592; back</a>
</div>
</body>
</html>"#,
title = config.site_title,
)
}
fn escape_html(s: &str) -> String { fn escape_html(s: &str) -> String {
s.replace('&', "&amp;") s.replace('&', "&amp;")
.replace('<', "&lt;") .replace('<', "&lt;")
@ -195,6 +238,7 @@ mod tests {
canvas_width: 400, canvas_width: 400,
canvas_height: 200, canvas_height: 200,
template: None, template: None,
success_template: None,
separator: "---".into(), separator: "---".into(),
style: String::new(), style: String::new(),
form_prompt: "Thanks for visiting. Sign the guestbook!".into(), form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
@ -432,4 +476,32 @@ mod tests {
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(!html.contains("<img class=\"entry-drawing\"")); assert!(!html.contains("<img class=\"entry-drawing\""));
} }
#[test]
fn test_render_success_page_default() {
let config = test_config();
let html = render_success_page(&config);
assert!(html.contains("<title>test</title>"));
assert!(html.contains("pending approval"));
assert!(html.contains("back"));
assert!(html.contains("<style>"));
}
#[test]
fn test_render_success_page_custom_template() {
let mut config = test_config();
config.success_template = Some("<p>{{title}} - sent!</p>".into());
let html = render_success_page(&config);
assert_eq!(html, "<p>test - sent!</p>");
}
#[test]
fn test_render_error_page() {
let config = test_config();
let html = render_error_page(&config, "Name and message are required.");
assert!(html.contains("<title>test</title>"));
assert!(html.contains("Name and message are required."));
assert!(html.contains("back"));
assert!(html.contains("<style>"));
}
} }

View file

@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::config::Config; use crate::config::Config;
use crate::entries::{self, Entry, EntryMeta, Status}; use crate::entries::{self, Entry, EntryMeta, Status};
use crate::render::{self, DEFAULT_TEMPLATE}; use crate::render::{self, DEFAULT_TEMPLATE, render_error_page, render_success_page};
pub struct AppState { pub struct AppState {
pub config: Config, pub config: Config,
@ -95,12 +95,12 @@ async fn submit(
Form(form): Form<SubmitForm>, Form(form): Form<SubmitForm>,
) -> Html<String> { ) -> Html<String> {
if !state.config.enable_submissions { if !state.config.enable_submissions {
return Html("Submissions are closed.".to_string()); return Html(render_error_page(&state.config, "Submissions are closed."));
} }
// Honeypot check — silently discard // Honeypot check — silently discard
if state.config.enable_honeypot && !form.url.is_empty() { if state.config.enable_honeypot && !form.url.is_empty() {
return Html("Thanks! Your message is pending approval.".to_string()); return Html(render_success_page(&state.config));
} }
// Validation // Validation
@ -132,24 +132,24 @@ async fn submit(
} }
}; };
if !ok { if !ok {
return Html("Wrong answer.".to_string()); return Html(render_error_page(&state.config, "Wrong answer."));
} }
} }
if name.is_empty() || message.is_empty() { if name.is_empty() || message.is_empty() {
return Html("Name and message are required.".to_string()); return Html(render_error_page(&state.config, "Name and message are required."));
} }
let max_name = state.config.max_name_length; let max_name = state.config.max_name_length;
if max_name > 0 && name.len() > max_name { if max_name > 0 && name.len() > max_name {
return Html(format!("Name is too long (max {max_name} chars).")); return Html(render_error_page(&state.config, &format!("Name is too long (max {max_name} chars).")));
} }
let max_web = state.config.max_website_length; let max_web = state.config.max_website_length;
if max_web > 0 && website.len() > max_web { if max_web > 0 && website.len() > max_web {
return Html(format!("Website is too long (max {max_web} chars).")); return Html(render_error_page(&state.config, &format!("Website is too long (max {max_web} chars).")));
} }
let max_msg = state.config.max_message_length; let max_msg = state.config.max_message_length;
if max_msg > 0 && message.len() > max_msg { if max_msg > 0 && message.len() > max_msg {
return Html(format!("Message is too long (max {max_msg} chars).")); return Html(render_error_page(&state.config, &format!("Message is too long (max {max_msg} chars).")));
} }
// Process drawing if enabled and provided // Process drawing if enabled and provided
@ -162,21 +162,21 @@ async fn submit(
} else { } else {
let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) { let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
Ok(b) => b, Ok(b) => b,
Err(_) => return Html("Invalid drawing data.".to_string()), Err(_) => return Html(render_error_page(&state.config, "Invalid drawing data.")),
}; };
let max = state.config.max_drawing_bytes(); let max = state.config.max_drawing_bytes();
if max > 0 && bytes.len() > max { if max > 0 && bytes.len() > max {
return Html(format!("Drawing is too large (max {} bytes).", max)); return Html(render_error_page(&state.config, &format!("Drawing is too large (max {} bytes).", max)));
} }
// Validate PNG: magic bytes + IHDR dimensions match configured canvas // Validate PNG: magic bytes + IHDR dimensions match configured canvas
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" { if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" {
return Html("Invalid drawing format.".to_string()); return Html(render_error_page(&state.config, "Invalid drawing format."));
} }
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
if width != state.config.canvas_width || height != state.config.canvas_height { if width != state.config.canvas_width || height != state.config.canvas_height {
return Html("Invalid drawing dimensions.".to_string()); return Html(render_error_page(&state.config, "Invalid drawing dimensions."));
} }
Some(bytes) Some(bytes)
@ -199,7 +199,7 @@ async fn submit(
std::fs::create_dir_all(&drawings_dir).ok(); std::fs::create_dir_all(&drawings_dir).ok();
if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), bytes) { if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), bytes) {
tracing::error!("failed to write drawing: {e}"); tracing::error!("failed to write drawing: {e}");
return Html("Something went wrong. Please try again.".to_string()); return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
} }
drawing_name drawing_name
} else { } else {
@ -223,13 +223,13 @@ async fn submit(
let path = entries_dir.join(&filename); let path = entries_dir.join(&filename);
if let Err(e) = std::fs::write(&path, entry.to_file_contents()) { if let Err(e) = std::fs::write(&path, entry.to_file_contents()) {
tracing::error!("failed to write entry: {e}"); tracing::error!("failed to write entry: {e}");
return Html("Something went wrong. Please try again.".to_string()); return Html(render_error_page(&state.config, "Something went wrong. Please try again."));
} }
// Notify telegram task // Notify telegram task
let _ = state.tx.send((entry, drawing_bytes)).await; let _ = state.tx.send((entry, drawing_bytes)).await;
Html("Thanks! Your message is pending approval.".to_string()) Html(render_success_page(&state.config))
} }
#[cfg(test)] #[cfg(test)]
@ -266,6 +266,7 @@ mod tests {
canvas_width: 400, canvas_width: 400,
canvas_height: 200, canvas_height: 200,
template: None, template: None,
success_template: None,
separator: "---".into(), separator: "---".into(),
style: String::new(), style: String::new(),
form_prompt: "Thanks for visiting. Sign the guestbook!".into(), form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
@ -784,4 +785,37 @@ mod tests {
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(bytes, png); assert_eq!(bytes, png);
} }
#[tokio::test]
async fn test_submit_success_is_full_page() {
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("<!DOCTYPE html>"));
assert!(body.contains("<title>test</title>"));
assert!(body.contains("pending approval"));
assert!(body.contains("back"));
}
#[tokio::test]
async fn test_submit_custom_success_template() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.success_template = Some("<p>{{title}} — sent!</p>".into());
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello").await;
assert_eq!(body, "<p>test — sent!</p>");
}
#[tokio::test]
async fn test_submit_error_is_full_page() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=&message=").await;
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("Name and message are required"));
assert!(body.contains("back"));
}
} }

28
templates/success.html Normal file
View file

@ -0,0 +1,28 @@
<!--
Default success page shown after a guestbook submission.
Copy this file and point BOOK_SUCCESS_TEMPLATE at your copy to customize.
Available placeholders:
title - Site title (BOOK_SITE_TITLE).
style - Custom CSS (same as the main template).
Everything else is static — write whatever you want. Use <script> for
dynamic behavior like showing the current time.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
{{style}}
</head>
<body>
<div class="page-container">
Thanks! Your message is pending approval.
<a href="/">&#8592; back</a>
</div>
</body>
</html>