feat: extracts out the success page to an actual template
This commit is contained in:
parent
1371c006e9
commit
c05cbf4cbc
9 changed files with 216 additions and 42 deletions
|
|
@ -88,6 +88,11 @@
|
|||
# Uses built-in default if unset.
|
||||
# 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/.
|
||||
# BOOK_ENABLE_DRAWINGS=false
|
||||
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -458,7 +458,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "guestbook"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "guestbook"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
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."
|
||||
license = "MIT"
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -60,7 +60,7 @@ This will run the site on localhost on the port you've configured, or `8123` by
|
|||
enable = true;
|
||||
package = guestbook.packages.x86_64-linux.default;
|
||||
siteTitle = "my guestbook";
|
||||
telegram = {
|
||||
features.telegram = {
|
||||
enable = true;
|
||||
botTokenFile = "/run/secrets/guestbook-bot-token";
|
||||
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.
|
||||
# Uses built-in default if unset.
|
||||
# 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
|
||||
|
|
@ -205,6 +210,11 @@ services.guestbook = {
|
|||
canvasWidth = 400;
|
||||
canvasHeight = 200;
|
||||
};
|
||||
telegram = {
|
||||
enable = false;
|
||||
# botTokenFile = <path>; -- required when enabled
|
||||
# chatId = <int>; -- required when enabled
|
||||
};
|
||||
security = {
|
||||
htmlInjection.enable = false;
|
||||
honeypot.enable = true;
|
||||
|
|
@ -218,12 +228,6 @@ services.guestbook = {
|
|||
};
|
||||
};
|
||||
|
||||
telegram = {
|
||||
enable = false;
|
||||
# botTokenFile = <path>; -- required when enabled
|
||||
# chatId = <int>; -- required when enabled
|
||||
};
|
||||
|
||||
limits = {
|
||||
name = 0;
|
||||
message = 0;
|
||||
|
|
@ -234,6 +238,7 @@ services.guestbook = {
|
|||
css = "";
|
||||
cssFile = null;
|
||||
templateFile = null;
|
||||
successTemplateFile = null;
|
||||
separator = "------------------------------------------------------------";
|
||||
greeting = "Thanks for visiting. Sign the guestbook!";
|
||||
labels = {
|
||||
|
|
@ -340,6 +345,12 @@ entries
|
|||
</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
|
||||
|
||||
```css
|
||||
|
|
|
|||
44
module.nix
44
module.nix
|
|
@ -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 = {
|
||||
htmlInjection = {
|
||||
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 = {
|
||||
name = mkOption {
|
||||
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.";
|
||||
};
|
||||
|
||||
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 {
|
||||
type = types.str;
|
||||
default = "------------------------------------------------------------";
|
||||
|
|
@ -295,8 +301,10 @@ in
|
|||
BOOK_STYLE_FILE = cfg.styles.cssFile;
|
||||
} // lib.optionalAttrs (cfg.styles.templateFile != null) {
|
||||
BOOK_TEMPLATE = cfg.styles.templateFile;
|
||||
} // lib.optionalAttrs cfg.telegram.enable {
|
||||
BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId;
|
||||
} // lib.optionalAttrs (cfg.styles.successTemplateFile != null) {
|
||||
BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile;
|
||||
} // lib.optionalAttrs cfg.features.telegram.enable {
|
||||
BOOK_TELEGRAM_CHAT_ID = toString cfg.features.telegram.chatId;
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
|
|
@ -310,8 +318,8 @@ in
|
|||
ReadWritePaths = [ cfg.dataDir ];
|
||||
};
|
||||
script = ''
|
||||
${lib.optionalString cfg.telegram.enable ''
|
||||
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")"
|
||||
${lib.optionalString cfg.features.telegram.enable ''
|
||||
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.features.telegram.botTokenFile}")"
|
||||
''}
|
||||
exec ${cfg.package}/bin/guestbook
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub struct Config {
|
|||
pub canvas_width: u32,
|
||||
pub canvas_height: u32,
|
||||
pub template: Option<String>,
|
||||
pub success_template: Option<String>,
|
||||
pub separator: String,
|
||||
pub style: String,
|
||||
pub form_prompt: String,
|
||||
|
|
@ -120,6 +121,10 @@ impl Config {
|
|||
std::fs::read_to_string(&path)
|
||||
.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")
|
||||
.ok()
|
||||
.map(|path| {
|
||||
|
|
@ -280,4 +285,15 @@ mod tests {
|
|||
assert_eq!(config.max_drawing_bytes(), 400 * 200 * 4);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use crate::config::Config;
|
|||
use crate::entries::Entry;
|
||||
|
||||
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 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="/">← back</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = config.site_title,
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
|
|
@ -195,6 +238,7 @@ mod tests {
|
|||
canvas_width: 400,
|
||||
canvas_height: 200,
|
||||
template: None,
|
||||
success_template: None,
|
||||
separator: "---".into(),
|
||||
style: String::new(),
|
||||
form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
|
||||
|
|
@ -432,4 +476,32 @@ mod tests {
|
|||
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
|
||||
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>"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
src/web.rs
64
src/web.rs
|
|
@ -14,7 +14,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::config::Config;
|
||||
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 config: Config,
|
||||
|
|
@ -95,12 +95,12 @@ async fn submit(
|
|||
Form(form): Form<SubmitForm>,
|
||||
) -> Html<String> {
|
||||
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
|
||||
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
|
||||
|
|
@ -132,24 +132,24 @@ async fn submit(
|
|||
}
|
||||
};
|
||||
if !ok {
|
||||
return Html("Wrong answer.".to_string());
|
||||
return Html(render_error_page(&state.config, "Wrong answer."));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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
|
||||
|
|
@ -162,21 +162,21 @@ async fn submit(
|
|||
} else {
|
||||
let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||
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();
|
||||
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
|
||||
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 height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
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)
|
||||
|
|
@ -199,7 +199,7 @@ async fn submit(
|
|||
std::fs::create_dir_all(&drawings_dir).ok();
|
||||
if let Err(e) = std::fs::write(drawings_dir.join(&drawing_name), bytes) {
|
||||
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
|
||||
} else {
|
||||
|
|
@ -223,13 +223,13 @@ async fn submit(
|
|||
let path = entries_dir.join(&filename);
|
||||
if let Err(e) = std::fs::write(&path, entry.to_file_contents()) {
|
||||
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
|
||||
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)]
|
||||
|
|
@ -266,6 +266,7 @@ mod tests {
|
|||
canvas_width: 400,
|
||||
canvas_height: 200,
|
||||
template: None,
|
||||
success_template: None,
|
||||
separator: "---".into(),
|
||||
style: String::new(),
|
||||
form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
|
||||
|
|
@ -784,4 +785,37 @@ mod tests {
|
|||
assert_eq!(status, StatusCode::OK);
|
||||
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
28
templates/success.html
Normal 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="/">← back</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue