feat: set required/optional for message, drawing, and voice notes, independently

This commit is contained in:
Lewis Wynne 2026-04-28 15:50:36 +01:00
parent 1910eec649
commit b784f4dd9c
7 changed files with 526 additions and 244 deletions

View file

@ -119,3 +119,15 @@
# Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.
# BOOK_VOICE_NOTE_MAX_DURATION=20
# Require a non-empty message field. Individual checks take priority over BOOK_CONTENT_REQUIRED.
# BOOK_MESSAGE_REQUIRED=false
# Require a drawing. No-op when BOOK_ENABLE_DRAWINGS=false.
# BOOK_DRAWING_REQUIRED=false
# Require a voice note. No-op when BOOK_ENABLE_VOICE_NOTES=false.
# BOOK_VOICE_NOTE_REQUIRED=false
# Require at least one of message, drawing, or voice note. Set to false to allow name-only submissions.
# BOOK_CONTENT_REQUIRED=true

228
README.md
View file

@ -11,7 +11,7 @@
and more, written in Rust, and inspired by [t0.vc/g](https://t0.vc/g).
`guestbook` is a single binary that serves a single-page guestbook aimed at personal sites. There's a form for visitors to submit a name, message, and optionally a link to their own site. Visitors can also draw a picture or leave a voice note if those features are enabled. Entries are written to plain text files with TOML frontmatter, and are initially marked as pending. The frontmatter can be manually edited to mark entries as approved or denied, or a Telegram bot can be hooked up for notifications and moderation (drawings are sent as photos and voice notes as voice messages so you can review them before approving). Running the Telegram bot just requires handing over a bot token, and it'll run off the same binary.
`guestbook` is a single-page guestbook designed for personal sites. There's a form for visitors to submit a name, and optionally a message, a link to their own site, a drawing, or a voice note. Entries are written to plain text files with TOML frontmatter, and are initially marked as pending. The frontmatter can be manually edited to mark entries as approved or denied, or a Telegram bot can be hooked up for notifications and moderation (drawings and voice notes are fetched on demand via `/drawing_<id>` and `/voice_note_<id>` so the chat doesn't fill up with attachments). Running the Telegram bot just requires handing over a bot token.
Everything is configured through environment variables (see [`.env.example`](#default-config) for the defaults). If you're hosting with Nix, there's a flake that can set up the `guestbook` service end-to-end, running on a systemd service with a Caddy reverse proxy. Optionally, just ignore the flake and set up all the extra stuff yourself.
@ -61,7 +61,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";
features.telegram = {
telegram = {
enable = true;
botTokenFile = "/run/secrets/guestbook-bot-token";
chatId = 12345;
@ -155,26 +155,35 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
# BOOK_STYLE_FILE=./templates/default.css
# Custom CSS injected into a style tag.
# Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input,
# .guestbook-textarea, .guestbook-button, .entries, .entry-header, .entry-date,
# .entry-name, .entry-website, .entry-body, .entry-drawing-wrap, .entry-drawing,
# .entry-voice-note-wrap
# Classes: .guestbook-form, .guestbook-label, .guestbook-input, .guestbook-textarea,
# .guestbook-button, .guestbook-canvas, .guestbook-drawing-wrap,
# .guestbook-drawing-tools, .guestbook-drawing-content, .guestbook-swatch,
# .guestbook-size-slider, .guestbook-voice-wrap, .guestbook-voice-controls,
# .guestbook-voice-record, .guestbook-voice-timer, .guestbook-voice-playback,
# .entries, .entry-header, .entry-date, .entry-name, .entry-website,
# .entry-body, .entry-drawing-wrap, .entry-drawing, .entry-voice-note-wrap
# BOOK_STYLE=
# Text shown above the form. Empty by default.
# BOOK_FORM_PROMPT=Thanks for visiting. Sign the guestbook!
# Submit button text.
# BOOK_BUTTON_TEXT=sign
# BOOK_BUTTON_TEXT=Submit Entry
# Label for the name field.
# BOOK_LABEL_NAME=name
# BOOK_LABEL_NAME=Your name
# Label for the website field.
# BOOK_LABEL_WEBSITE=website (optional)
# BOOK_LABEL_WEBSITE=Link a website (optional)
# Label for the message field.
# BOOK_LABEL_MESSAGE=message
# BOOK_LABEL_MESSAGE=Leave a message (optional)
# Label for the drawing field (when BOOK_ENABLE_DRAWINGS=true).
# BOOK_LABEL_DRAWING=Leave a drawing (optional)
# Label for the voice note field (when BOOK_ENABLE_VOICE_NOTES=true).
# BOOK_LABEL_VOICE_NOTE=Leave a voice note (optional)
# Initial text on the voice note record button.
# BOOK_VOICE_NOTE_RECORD_TEXT=Start recording
# Message textarea width in pixels.
# BOOK_TEXTAREA_WIDTH=320
@ -182,7 +191,7 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
# Message textarea height in pixels.
# BOOK_TEXTAREA_HEIGHT=150
# Custom HTML template file with {{title}}, {{prompt}}, {{form}}, {{entries}}, and {{style}} placeholders.
# Custom HTML template file with {{title}}, {{form}}, {{entries}}, and {{style}} placeholders.
# Uses built-in default if unset.
# BOOK_TEMPLATE=./templates/default.html
@ -205,6 +214,18 @@ Running `guestbook` with no env vars will give you a working guestbook on `local
# Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.
# BOOK_VOICE_NOTE_MAX_DURATION=20
# Require a non-empty message field. Individual checks take priority over BOOK_CONTENT_REQUIRED.
# BOOK_MESSAGE_REQUIRED=false
# Require a drawing. No-op when BOOK_ENABLE_DRAWINGS=false.
# BOOK_DRAWING_REQUIRED=false
# Require a voice note. No-op when BOOK_ENABLE_VOICE_NOTES=false.
# BOOK_VOICE_NOTE_REQUIRED=false
# Require at least one of message, drawing, or voice note. Set to false to allow name-only submissions.
# BOOK_CONTENT_REQUIRED=true
```
#### NixOS Module
@ -230,18 +251,22 @@ services.guestbook = {
};
};
features = {
submissions.enable = true;
websites.enable = true;
drawing = {
enable = false;
canvasWidth = 320;
canvasHeight = 200;
required = false;
};
voiceNote = {
voice = {
enable = false;
maxDuration = 20;
required = false;
};
message.required = false;
content.required = true;
telegram = {
enable = false;
# botTokenFile = <path>; -- required when enabled
@ -252,6 +277,7 @@ services.guestbook = {
};
reminderInterval = 86400;
};
security = {
htmlInjection.enable = false;
honeypot.enable = true;
@ -263,12 +289,16 @@ services.guestbook = {
caseSensitive = false;
};
};
};
limits = {
name = 0;
message = 0;
website = 0;
name.length = 0;
message.length = 0;
website.length = 0;
drawing = {
width = 320;
height = 200;
};
voice.duration = 20;
};
styles = {
@ -276,12 +306,14 @@ services.guestbook = {
cssFile = null;
templateFile = null;
successTemplateFile = null;
greeting = "";
labels = {
submit = "sign";
name = "name";
website = "website (optional)";
message = "message";
submit = "Submit Entry";
name = "Your name";
website = "Link a website (optional)";
message = "Leave a message (optional)";
drawing = "Leave a drawing (optional)";
voice = "Leave a voice note (optional)";
voiceRecord = "Start recording";
};
message = {
width = 320;
@ -390,13 +422,11 @@ entered into the 'message' field.
Available placeholders:
title - Site title (BOOK_SITE_TITLE). Useful in <title> and headings.
prompt - The form prompt text (BOOK_FORM_PROMPT), wrapped in a
<span class="guestbook-prompt">. Empty when submissions
are disabled. Place anywhere relative to the form.
form - The submission form (labels, inputs, button). Controlled by
BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, BOOK_LABEL_MESSAGE,
BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT.
Empty when BOOK_ENABLE_SUBMISSIONS=false.
BOOK_LABEL_DRAWING, BOOK_LABEL_VOICE_NOTE, BOOK_BUTTON_TEXT,
BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT. Empty when
BOOK_ENABLE_SUBMISSIONS=false.
entries - Approved guestbook entries, newest first.
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
a <style> tag. Uses built-in default.css when neither is set.
@ -415,8 +445,10 @@ entered into the 'message' field.
<div class="page-container">
<h1>{{title}}</h1>
{{prompt}}
<details class="guestbook-details">
<summary class="guestbook-summary">Click me to leave an entry</summary>
{{form}}
</details>
<h1>entries</h1>
{{entries}}
@ -472,41 +504,117 @@ body {
}
/* Form */
.guestbook-prompt { display: block; margin-bottom: 1em; }
.guestbook-prompt {
display: block;
margin-bottom: 1em;
}
.guestbook-form {}
.guestbook-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.guestbook-input { display: block; margin-bottom: 0.2em; }
.guestbook-textarea { display: block; box-sizing: border-box; max-width: 100%; margin-bottom: 0.2em; }
.guestbook-button { display: block; }
.guestbook-label {
display: block;
font-style: oblique;
}
.guestbook-label::after {
content: ":";
}
.guestbook-input {
display: block;
margin-bottom: 0.4em;
}
.guestbook-textarea {
display: block;
box-sizing: border-box;
max-width: 100%;
margin-bottom: 0.4em;
}
.guestbook-button {
display: block;
}
/* Drawings */
.guestbook-canvas { border: 1px solid #000; cursor: crosshair; display: block; max-width: 100%; height: auto; }
.guestbook-canvas-tools { display: block; }
.guestbook-canvas-tools a { cursor: pointer; }
.guestbook-drawing-wrap { display: block; }
.guestbook-drawing-inline a { cursor: pointer; }
.guestbook-drawing-content:empty { display: none; }
.guestbook-drawing-content { display: block; }
.guestbook-swatch { display: inline-block; width: 0.85em; height: 0.85em; border: 1px solid #000; cursor: pointer; vertical-align: middle; box-sizing: border-box; margin: 0 1px; }
.guestbook-swatch.active { border: 1px solid #000; outline: 1px solid #000; }
.guestbook-size-slider { width: 4em; vertical-align: middle; }
.entry-drawing { max-width: 100%; }
.guestbook-canvas {
border: 1px solid #000;
cursor: crosshair;
display: block;
max-width: 100%;
height: auto;
}
.guestbook-drawing-wrap {
display: block;
margin-bottom: 0.4em;
}
.guestbook-drawing-tools {
display: block;
}
.guestbook-drawing-tools a {
cursor: pointer;
}
.guestbook-drawing-content {
display: block;
}
.guestbook-swatch {
display: inline-block;
width: 0.85em;
height: 0.85em;
border: 1px solid #000;
cursor: pointer;
vertical-align: middle;
box-sizing: border-box;
margin: 0 1px;
}
.guestbook-swatch.active {
border: 1px solid #000;
outline: 1px solid #000;
}
.guestbook-size-slider {
width: 4em;
vertical-align: middle;
}
.entry-drawing {
max-width: 100%;
}
/* Voice notes */
.guestbook-voice-wrap { display: block; }
.guestbook-voice-record.recording { color: red; }
.guestbook-voice-timer { font-variant-numeric: tabular-nums; }
.guestbook-voice-playback:empty { display: none; }
.guestbook-voice-playback { display: block; white-space: normal; }
audio { display: block; height: 2em; }
.guestbook-voice-wrap {
display: block;
margin-bottom: 0.4em;
}
.guestbook-voice-controls a {
cursor: pointer;
}
.guestbook-voice-record.recording {
color: red;
}
.guestbook-voice-timer {
font-variant-numeric: tabular-nums;
}
.guestbook-voice-playback:empty {
display: none;
}
.guestbook-voice-playback {
display: block;
white-space: normal;
}
audio {
display: block;
height: 2em;
}
/* Entries */
.entries { margin: 0; line-height: 1; }
.entries dt:not(:first-child) { margin-top: 0.5rem; }
.entries {
margin: 0;
line-height: 1;
}
.entries dt:not(:first-child) {
margin-top: 0.5rem;
}
.entry-date {}
.entry-name { font-weight: bold; }
.entry-name {
font-weight: bold;
}
.entry-website {}
.entry-body { white-space: pre-wrap; }
.entry-body {
white-space: pre-wrap;
}
```
---

View file

@ -72,7 +72,6 @@ in
};
};
features = {
submissions = {
enable = mkOption {
type = types.bool;
@ -96,30 +95,40 @@ in
description = "Enable the drawing canvas in the submission form. Stores PNG files in dataDir/drawings/.";
};
canvasWidth = mkOption {
type = types.int;
default = 320;
description = "Drawing canvas width in pixels.";
};
canvasHeight = mkOption {
type = types.int;
default = 200;
description = "Drawing canvas height in pixels.";
required = mkOption {
type = types.bool;
default = false;
description = "Require a drawing on every submission. No-op when drawing.enable=false.";
};
};
voiceNote = {
voice = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable voice note recording in the submission form. Stores WebM files in dataDir/voice_notes/.";
};
maxDuration = mkOption {
type = types.int;
default = 20;
description = "Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.";
required = mkOption {
type = types.bool;
default = false;
description = "Require a voice note on every submission. No-op when voice.enable=false.";
};
};
message = {
required = mkOption {
type = types.bool;
default = false;
description = "Require a non-empty message on every submission. Individual checks take priority over content.required.";
};
};
content = {
required = mkOption {
type = types.bool;
default = true;
description = "Require at least one of message, drawing, or voice note. Set to false to allow name-only submissions.";
};
};
@ -202,26 +211,45 @@ in
};
};
};
};
limits = {
name = mkOption {
name.length = mkOption {
type = types.int;
default = 0;
description = "Maximum length for names. 0 for unlimited.";
};
message = mkOption {
message.length = mkOption {
type = types.int;
default = 0;
description = "Maximum length for messages. 0 for unlimited.";
};
website = mkOption {
website.length = mkOption {
type = types.int;
default = 0;
description = "Maximum length for website URLs. 0 for unlimited.";
};
drawing = {
width = mkOption {
type = types.int;
default = 320;
description = "Drawing canvas width in pixels.";
};
height = mkOption {
type = types.int;
default = 200;
description = "Drawing canvas height in pixels.";
};
};
voice.duration = mkOption {
type = types.int;
default = 20;
description = "Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.";
};
};
styles = {
@ -280,13 +308,13 @@ in
description = "Label for the drawing field (when drawing.enable=true).";
};
voiceNote = mkOption {
voice = mkOption {
type = types.str;
default = "Leave a voice note (optional)";
description = "Label for the voice note field (when voiceNote.enable=true).";
description = "Label for the voice note field (when voice.enable=true).";
};
voiceNoteRecord = mkOption {
voiceRecord = mkOption {
type = types.str;
default = "Start recording";
description = "Initial text on the voice note record button.";
@ -321,31 +349,35 @@ in
BOOK_DATA_DIR = cfg.dataDir;
BOOK_SITE_TITLE = cfg.siteTitle;
BOOK_ENABLE_SUBMISSIONS = if cfg.features.submissions.enable then "true" else "false";
BOOK_ENABLE_WEBSITE_LINKS = if cfg.features.websites.enable then "true" else "false";
BOOK_ENABLE_DRAWINGS = if cfg.features.drawing.enable then "true" else "false";
BOOK_ENABLE_HTML_INJECTION = if cfg.features.security.htmlInjection.enable then "true" else "false";
BOOK_ENABLE_HONEYPOT = if cfg.features.security.honeypot.enable then "true" else "false";
BOOK_ENABLE_CAPTCHA = if cfg.features.security.captcha.enable then "true" else "false";
BOOK_CAPTCHA_QUESTION = cfg.features.security.captcha.question;
BOOK_CAPTCHA_ANSWER = cfg.features.security.captcha.answer;
BOOK_CAPTCHA_EXACT = if cfg.features.security.captcha.exact then "true" else "false";
BOOK_CAPTCHA_CASESENSITIVE = if cfg.features.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;
BOOK_ENABLE_SUBMISSIONS = if cfg.submissions.enable then "true" else "false";
BOOK_ENABLE_WEBSITE_LINKS = if cfg.websites.enable then "true" else "false";
BOOK_ENABLE_DRAWINGS = if cfg.drawing.enable then "true" else "false";
BOOK_ENABLE_HTML_INJECTION = if cfg.security.htmlInjection.enable then "true" else "false";
BOOK_ENABLE_HONEYPOT = if cfg.security.honeypot.enable 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.length;
BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message.length;
BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website.length;
BOOK_STYLE = cfg.styles.css;
BOOK_BUTTON_TEXT = cfg.styles.labels.submit;
BOOK_LABEL_NAME = cfg.styles.labels.name;
BOOK_LABEL_WEBSITE = cfg.styles.labels.website;
BOOK_LABEL_MESSAGE = cfg.styles.labels.message;
BOOK_LABEL_DRAWING = cfg.styles.labels.drawing;
BOOK_LABEL_VOICE_NOTE = cfg.styles.labels.voiceNote;
BOOK_VOICE_NOTE_RECORD_TEXT = cfg.styles.labels.voiceNoteRecord;
BOOK_CANVAS_WIDTH = toString cfg.features.drawing.canvasWidth;
BOOK_CANVAS_HEIGHT = toString cfg.features.drawing.canvasHeight;
BOOK_ENABLE_VOICE_NOTES = if cfg.features.voiceNote.enable then "true" else "false";
BOOK_VOICE_NOTE_MAX_DURATION = toString cfg.features.voiceNote.maxDuration;
BOOK_LABEL_VOICE_NOTE = cfg.styles.labels.voice;
BOOK_VOICE_NOTE_RECORD_TEXT = cfg.styles.labels.voiceRecord;
BOOK_CANVAS_WIDTH = toString cfg.limits.drawing.width;
BOOK_CANVAS_HEIGHT = toString cfg.limits.drawing.height;
BOOK_ENABLE_VOICE_NOTES = if cfg.voice.enable then "true" else "false";
BOOK_VOICE_NOTE_MAX_DURATION = toString cfg.limits.voice.duration;
BOOK_MESSAGE_REQUIRED = if cfg.message.required then "true" else "false";
BOOK_DRAWING_REQUIRED = if cfg.drawing.required then "true" else "false";
BOOK_VOICE_NOTE_REQUIRED = if cfg.voice.required then "true" else "false";
BOOK_CONTENT_REQUIRED = if cfg.content.required then "true" else "false";
BOOK_TEXTAREA_WIDTH = toString cfg.styles.message.width;
BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height;
} // lib.optionalAttrs (cfg.styles.cssFile != null) {
@ -354,11 +386,11 @@ in
BOOK_TEMPLATE = cfg.styles.templateFile;
} // 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;
BOOK_TELEGRAM_RETRY_INTERVAL = toString cfg.features.telegram.retry.interval;
BOOK_TELEGRAM_RETRY_LIMIT = toString cfg.features.telegram.retry.limit;
BOOK_TELEGRAM_REMINDER_INTERVAL = toString cfg.features.telegram.reminderInterval;
} // lib.optionalAttrs cfg.telegram.enable {
BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId;
BOOK_TELEGRAM_RETRY_INTERVAL = toString cfg.telegram.retry.interval;
BOOK_TELEGRAM_RETRY_LIMIT = toString cfg.telegram.retry.limit;
BOOK_TELEGRAM_REMINDER_INTERVAL = toString cfg.telegram.reminderInterval;
};
serviceConfig = {
Type = "simple";
@ -372,8 +404,8 @@ in
ReadWritePaths = [ cfg.dataDir ];
};
script = ''
${lib.optionalString cfg.features.telegram.enable ''
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.features.telegram.botTokenFile}")"
${lib.optionalString cfg.telegram.enable ''
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")"
''}
exec ${cfg.package}/bin/guestbook
'';

View file

@ -34,6 +34,10 @@ pub struct Config {
pub canvas_height: u32,
pub enable_voice_notes: bool,
pub voice_note_max_duration: u32,
pub message_required: bool,
pub drawing_required: bool,
pub voice_note_required: bool,
pub content_required: bool,
pub template: Option<String>,
pub success_template: Option<String>,
pub style: String,
@ -153,6 +157,18 @@ impl Config {
.unwrap_or_else(|_| "20".into())
.parse()
.map_err(|_| "BOOK_VOICE_NOTE_MAX_DURATION must be a number")?,
message_required: env::var("BOOK_MESSAGE_REQUIRED")
.map(|v| v != "false")
.unwrap_or(false),
drawing_required: env::var("BOOK_DRAWING_REQUIRED")
.map(|v| v != "false")
.unwrap_or(false),
voice_note_required: env::var("BOOK_VOICE_NOTE_REQUIRED")
.map(|v| v != "false")
.unwrap_or(false),
content_required: env::var("BOOK_CONTENT_REQUIRED")
.map(|v| v != "false")
.unwrap_or(true),
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read template {path}: {e}"))

View file

@ -336,6 +336,10 @@ mod tests {
canvas_height: 200,
enable_voice_notes: false,
voice_note_max_duration: 20,
message_required: false,
drawing_required: false,
voice_note_required: false,
content_required: true,
template: None,
success_template: None,
style: String::new(),
@ -438,12 +442,9 @@ mod tests {
#[test]
fn test_render_form_custom_labels() {
let mut config = test_config();
config.form_prompt = "Leave a note!".into();
config.button_text = "submit".into();
config.label_name = "Name:".into();
let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[], &form);
assert!(html.contains("Leave a note!"));
assert!(form.contains("submit"));
assert!(form.contains("Name:"));
}

View file

@ -277,7 +277,20 @@ async fn submit(
None
};
if message.is_empty() && drawing_bytes.is_none() && voice_note_bytes.is_none() {
if state.config.message_required && message.is_empty() {
return Html(render_error_page(&state.config, "Message is required."));
}
if state.config.drawing_required && state.config.enable_drawings && drawing_bytes.is_none() {
return Html(render_error_page(&state.config, "Drawing is required."));
}
if state.config.voice_note_required && state.config.enable_voice_notes && voice_note_bytes.is_none() {
return Html(render_error_page(&state.config, "Voice note is required."));
}
if state.config.content_required
&& message.is_empty()
&& drawing_bytes.is_none()
&& voice_note_bytes.is_none()
{
return Html(render_error_page(&state.config, "Please leave a message, drawing, or voice note."));
}
@ -411,6 +424,10 @@ mod tests {
canvas_height: 200,
enable_voice_notes: false,
voice_note_max_duration: 20,
message_required: false,
drawing_required: false,
voice_note_required: false,
content_required: true,
template: None,
success_template: None,
style: String::new(),
@ -1164,6 +1181,98 @@ mod tests {
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_content_required_false_allows_name_only() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.content_required = false;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=").await;
assert!(body.contains("pending approval"));
let count = std::fs::read_dir(dir.path().join("entries")).unwrap().count();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_message_required_rejects_empty_message() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.message_required = true;
config.content_required = false;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=").await;
assert!(body.contains("Message is required"));
}
#[tokio::test]
async fn test_drawing_required_rejects_missing_drawing() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_drawings = true;
config.drawing_required = true;
config.content_required = false;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hi").await;
assert!(body.contains("Drawing is required"));
}
#[tokio::test]
async fn test_drawing_required_noop_when_drawings_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_drawings = false;
config.drawing_required = true;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hi").await;
assert!(body.contains("pending approval"));
}
#[tokio::test]
async fn test_voice_note_required_rejects_missing() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_voice_notes = true;
config.voice_note_required = true;
config.content_required = false;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hi").await;
assert!(body.contains("Voice note is required"));
}
#[tokio::test]
async fn test_voice_note_required_noop_when_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_voice_notes = false;
config.voice_note_required = true;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hi").await;
assert!(body.contains("pending approval"));
}
#[tokio::test]
async fn test_individual_required_takes_priority_over_content() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_drawings = true;
config.canvas_width = 400;
config.canvas_height = 200;
config.message_required = true;
// content_required is true by default, but the individual check should fire first
let (app, _rx) = test_app(config);
// Submit with a drawing but no message — content would be satisfied, but message_required fires
let png = fake_png(400, 200);
let drawing_data = base64::engine::general_purpose::STANDARD.encode(&png);
let data_url = format!("data:image/png;base64,{drawing_data}");
let body = format!(
"name=alice&message=&drawing={}",
urlencoding::encode(&data_url)
);
let (_, resp) = post_form(&app, &body).await;
assert!(resp.contains("Message is required"));
}
#[tokio::test]
async fn test_voice_note_full_roundtrip() {
let dir = tempfile::tempdir().unwrap();

View file

@ -17,6 +17,10 @@ body {
.guestbook-form {}
.guestbook-label {
display: block;
font-style: oblique;
}
.guestbook-label::after {
content: ":";
}
.guestbook-input {
display: block;