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.
|
# 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
2
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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;
|
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
|
||||||
|
|
|
||||||
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 = {
|
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
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="/">← back</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
title = config.site_title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn escape_html(s: &str) -> String {
|
fn escape_html(s: &str) -> String {
|
||||||
s.replace('&', "&")
|
s.replace('&', "&")
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
|
|
@ -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>"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
src/web.rs
64
src/web.rs
|
|
@ -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
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