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

@ -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());
}
}

View file

@ -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="/">&#8592; back</a>
</div>
</body>
</html>"#,
title = config.site_title,
)
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
@ -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>"));
}
}

View file

@ -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"));
}
}