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. # Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.
# BOOK_VOICE_NOTE_MAX_DURATION=20 # 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

276
README.md
View file

@ -11,7 +11,7 @@
and more, written in Rust, and inspired by [t0.vc/g](https://t0.vc/g). 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. 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; enable = true;
package = guestbook.packages.x86_64-linux.default; package = guestbook.packages.x86_64-linux.default;
siteTitle = "my guestbook"; siteTitle = "my guestbook";
features.telegram = { telegram = {
enable = true; enable = true;
botTokenFile = "/run/secrets/guestbook-bot-token"; botTokenFile = "/run/secrets/guestbook-bot-token";
chatId = 12345; 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 # BOOK_STYLE_FILE=./templates/default.css
# Custom CSS injected into a style tag. # Custom CSS injected into a style tag.
# Classes: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, # Classes: .guestbook-form, .guestbook-label, .guestbook-input, .guestbook-textarea,
# .guestbook-textarea, .guestbook-button, .entries, .entry-header, .entry-date, # .guestbook-button, .guestbook-canvas, .guestbook-drawing-wrap,
# .entry-name, .entry-website, .entry-body, .entry-drawing-wrap, .entry-drawing, # .guestbook-drawing-tools, .guestbook-drawing-content, .guestbook-swatch,
# .entry-voice-note-wrap # .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= # BOOK_STYLE=
# Text shown above the form. Empty by default.
# BOOK_FORM_PROMPT=Thanks for visiting. Sign the guestbook!
# Submit button text. # Submit button text.
# BOOK_BUTTON_TEXT=sign # BOOK_BUTTON_TEXT=Submit Entry
# Label for the name field. # Label for the name field.
# BOOK_LABEL_NAME=name # BOOK_LABEL_NAME=Your name
# Label for the website field. # Label for the website field.
# BOOK_LABEL_WEBSITE=website (optional) # BOOK_LABEL_WEBSITE=Link a website (optional)
# Label for the message field. # 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. # Message textarea width in pixels.
# BOOK_TEXTAREA_WIDTH=320 # 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. # Message textarea height in pixels.
# BOOK_TEXTAREA_HEIGHT=150 # 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. # Uses built-in default if unset.
# BOOK_TEMPLATE=./templates/default.html # 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. # Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.
# BOOK_VOICE_NOTE_MAX_DURATION=20 # 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 #### NixOS Module
@ -230,45 +251,54 @@ services.guestbook = {
}; };
}; };
features = { submissions.enable = true;
submissions.enable = true; websites.enable = true;
websites.enable = true;
drawing = { drawing = {
enable = false; enable = false;
canvasWidth = 320; required = false;
canvasHeight = 200; };
voice = {
enable = false;
required = false;
};
message.required = false;
content.required = true;
telegram = {
enable = false;
# botTokenFile = <path>; -- required when enabled
# chatId = <int>; -- required when enabled
retry = {
interval = 20;
limit = 3;
}; };
voiceNote = { reminderInterval = 86400;
};
security = {
htmlInjection.enable = false;
honeypot.enable = true;
captcha = {
enable = false; enable = false;
maxDuration = 20; question = "";
}; answer = "";
telegram = { exact = false;
enable = false; caseSensitive = false;
# botTokenFile = <path>; -- required when enabled
# chatId = <int>; -- required when enabled
retry = {
interval = 20;
limit = 3;
};
reminderInterval = 86400;
};
security = {
htmlInjection.enable = false;
honeypot.enable = true;
captcha = {
enable = false;
question = "";
answer = "";
exact = false;
caseSensitive = false;
};
}; };
}; };
limits = { limits = {
name = 0; name.length = 0;
message = 0; message.length = 0;
website = 0; website.length = 0;
drawing = {
width = 320;
height = 200;
};
voice.duration = 20;
}; };
styles = { styles = {
@ -276,12 +306,14 @@ services.guestbook = {
cssFile = null; cssFile = null;
templateFile = null; templateFile = null;
successTemplateFile = null; successTemplateFile = null;
greeting = "";
labels = { labels = {
submit = "sign"; submit = "Submit Entry";
name = "name"; name = "Your name";
website = "website (optional)"; website = "Link a website (optional)";
message = "message"; message = "Leave a message (optional)";
drawing = "Leave a drawing (optional)";
voice = "Leave a voice note (optional)";
voiceRecord = "Start recording";
}; };
message = { message = {
width = 320; width = 320;
@ -390,13 +422,11 @@ entered into the 'message' field.
Available placeholders: Available placeholders:
title - Site title (BOOK_SITE_TITLE). Useful in <title> and headings. 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 form - The submission form (labels, inputs, button). Controlled by
BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, BOOK_LABEL_MESSAGE, BOOK_LABEL_NAME, BOOK_LABEL_WEBSITE, BOOK_LABEL_MESSAGE,
BOOK_BUTTON_TEXT, BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT. BOOK_LABEL_DRAWING, BOOK_LABEL_VOICE_NOTE, BOOK_BUTTON_TEXT,
Empty when BOOK_ENABLE_SUBMISSIONS=false. BOOK_TEXTAREA_WIDTH, BOOK_TEXTAREA_HEIGHT. Empty when
BOOK_ENABLE_SUBMISSIONS=false.
entries - Approved guestbook entries, newest first. entries - Approved guestbook entries, newest first.
style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in style - Custom CSS from BOOK_STYLE or BOOK_STYLE_FILE, wrapped in
a <style> tag. Uses built-in default.css when neither is set. 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"> <div class="page-container">
<h1>{{title}}</h1> <h1>{{title}}</h1>
{{prompt}} <details class="guestbook-details">
<summary class="guestbook-summary">Click me to leave an entry</summary>
{{form}} {{form}}
</details>
<h1>entries</h1> <h1>entries</h1>
{{entries}} {{entries}}
@ -472,41 +504,117 @@ body {
} }
/* Form */ /* Form */
.guestbook-prompt { display: block; margin-bottom: 1em; } .guestbook-prompt {
display: block;
margin-bottom: 1em;
}
.guestbook-form {} .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-label {
.guestbook-input { display: block; margin-bottom: 0.2em; } display: block;
.guestbook-textarea { display: block; box-sizing: border-box; max-width: 100%; margin-bottom: 0.2em; } font-style: oblique;
.guestbook-button { display: block; } }
.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 */ /* Drawings */
.guestbook-canvas { border: 1px solid #000; cursor: crosshair; display: block; max-width: 100%; height: auto; } .guestbook-canvas {
.guestbook-canvas-tools { display: block; } border: 1px solid #000;
.guestbook-canvas-tools a { cursor: pointer; } cursor: crosshair;
.guestbook-drawing-wrap { display: block; } display: block;
.guestbook-drawing-inline a { cursor: pointer; } max-width: 100%;
.guestbook-drawing-content:empty { display: none; } height: auto;
.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-drawing-wrap {
.guestbook-swatch.active { border: 1px solid #000; outline: 1px solid #000; } display: block;
.guestbook-size-slider { width: 4em; vertical-align: middle; } margin-bottom: 0.4em;
.entry-drawing { max-width: 100%; } }
.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 */ /* Voice notes */
.guestbook-voice-wrap { display: block; } .guestbook-voice-wrap {
.guestbook-voice-record.recording { color: red; } display: block;
.guestbook-voice-timer { font-variant-numeric: tabular-nums; } margin-bottom: 0.4em;
.guestbook-voice-playback:empty { display: none; } }
.guestbook-voice-playback { display: block; white-space: normal; } .guestbook-voice-controls a {
audio { display: block; height: 2em; } 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 */
.entries { margin: 0; line-height: 1; } .entries {
.entries dt:not(:first-child) { margin-top: 0.5rem; } margin: 0;
line-height: 1;
}
.entries dt:not(:first-child) {
margin-top: 0.5rem;
}
.entry-date {} .entry-date {}
.entry-name { font-weight: bold; } .entry-name {
font-weight: bold;
}
.entry-website {} .entry-website {}
.entry-body { white-space: pre-wrap; } .entry-body {
white-space: pre-wrap;
}
``` ```
--- ---

View file

@ -72,155 +72,183 @@ in
}; };
}; };
features = { submissions = {
submissions = { enable = mkOption {
enable = mkOption { type = types.bool;
type = types.bool; default = true;
default = true; description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected.";
description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; };
};
websites = {
enable = mkOption {
type = types.bool;
default = true;
description = "Show website field in form and render website links in entries. When false, the input is hidden, submitted values are ignored, and existing links are not displayed.";
};
};
drawing = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable the drawing canvas in the submission form. Stores PNG files in dataDir/drawings/.";
};
required = mkOption {
type = types.bool;
default = false;
description = "Require a drawing on every submission. No-op when drawing.enable=false.";
};
};
voice = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable voice note recording in the submission form. Stores WebM files in dataDir/voice_notes/.";
};
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.";
};
};
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.";
};
retry = {
interval = mkOption {
type = types.int;
default = 20;
description = "Seconds between retry attempts for failed Telegram notifications.";
};
limit = mkOption {
type = types.int;
default = 3;
description = "Maximum number of retry attempts for failed Telegram notifications.";
}; };
}; };
websites = { reminderInterval = mkOption {
enable = mkOption { type = types.int;
type = types.bool; default = 86400;
default = true; description = "Seconds between pending entry reminders. Set to 0 to disable.";
description = "Show website field in form and render website links in entries. When false, the input is hidden, submitted values are ignored, and existing links are not displayed.";
};
}; };
};
drawing = { security = {
htmlInjection = {
enable = mkOption { enable = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
description = "Enable the drawing canvas in the submission form. Stores PNG files in dataDir/drawings/."; description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped.";
};
};
honeypot = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable honeypot field for spam prevention.";
};
};
captcha = {
enable = mkEnableOption "captcha on submission form";
question = mkOption {
type = types.str;
default = "";
description = "Captcha question displayed as a label.";
}; };
canvasWidth = mkOption { answer = mkOption {
type = types.str;
default = "";
description = "Captcha answer to validate against.";
};
exact = mkOption {
type = types.bool;
default = false;
description = "Require exact match. When false, the answer just needs to be contained in the response.";
};
caseSensitive = mkOption {
type = types.bool;
default = false;
description = "Require case-sensitive match.";
};
};
};
limits = {
name.length = mkOption {
type = types.int;
default = 0;
description = "Maximum length for names. 0 for unlimited.";
};
message.length = mkOption {
type = types.int;
default = 0;
description = "Maximum length for messages. 0 for unlimited.";
};
website.length = mkOption {
type = types.int;
default = 0;
description = "Maximum length for website URLs. 0 for unlimited.";
};
drawing = {
width = mkOption {
type = types.int; type = types.int;
default = 320; default = 320;
description = "Drawing canvas width in pixels."; description = "Drawing canvas width in pixels.";
}; };
canvasHeight = mkOption { height = mkOption {
type = types.int; type = types.int;
default = 200; default = 200;
description = "Drawing canvas height in pixels."; description = "Drawing canvas height in pixels.";
}; };
}; };
voiceNote = { voice.duration = mkOption {
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.";
};
};
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.";
};
retry = {
interval = mkOption {
type = types.int;
default = 20;
description = "Seconds between retry attempts for failed Telegram notifications.";
};
limit = mkOption {
type = types.int;
default = 3;
description = "Maximum number of retry attempts for failed Telegram notifications.";
};
};
reminderInterval = mkOption {
type = types.int;
default = 86400;
description = "Seconds between pending entry reminders. Set to 0 to disable.";
};
};
security = {
htmlInjection = {
enable = mkOption {
type = types.bool;
default = false;
description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped.";
};
};
honeypot = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable honeypot field for spam prevention.";
};
};
captcha = {
enable = mkEnableOption "captcha on submission form";
question = mkOption {
type = types.str;
default = "";
description = "Captcha question displayed as a label.";
};
answer = mkOption {
type = types.str;
default = "";
description = "Captcha answer to validate against.";
};
exact = mkOption {
type = types.bool;
default = false;
description = "Require exact match. When false, the answer just needs to be contained in the response.";
};
caseSensitive = mkOption {
type = types.bool;
default = false;
description = "Require case-sensitive match.";
};
};
};
};
limits = {
name = mkOption {
type = types.int; type = types.int;
default = 0; default = 20;
description = "Maximum length for names. 0 for unlimited."; description = "Maximum voice note duration in seconds. Max file size is derived as duration * 10KB.";
};
message = mkOption {
type = types.int;
default = 0;
description = "Maximum length for messages. 0 for unlimited.";
};
website = mkOption {
type = types.int;
default = 0;
description = "Maximum length for website URLs. 0 for unlimited.";
}; };
}; };
@ -280,13 +308,13 @@ in
description = "Label for the drawing field (when drawing.enable=true)."; description = "Label for the drawing field (when drawing.enable=true).";
}; };
voiceNote = mkOption { voice = mkOption {
type = types.str; type = types.str;
default = "Leave a voice note (optional)"; 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; type = types.str;
default = "Start recording"; default = "Start recording";
description = "Initial text on the voice note record button."; description = "Initial text on the voice note record button.";
@ -321,31 +349,35 @@ in
BOOK_DATA_DIR = cfg.dataDir; BOOK_DATA_DIR = cfg.dataDir;
BOOK_SITE_TITLE = cfg.siteTitle; BOOK_SITE_TITLE = cfg.siteTitle;
BOOK_ENABLE_SUBMISSIONS = if cfg.features.submissions.enable then "true" else "false"; BOOK_ENABLE_SUBMISSIONS = if cfg.submissions.enable then "true" else "false";
BOOK_ENABLE_WEBSITE_LINKS = if cfg.features.websites.enable then "true" else "false"; BOOK_ENABLE_WEBSITE_LINKS = if cfg.websites.enable then "true" else "false";
BOOK_ENABLE_DRAWINGS = if cfg.features.drawing.enable then "true" else "false"; BOOK_ENABLE_DRAWINGS = if cfg.drawing.enable then "true" else "false";
BOOK_ENABLE_HTML_INJECTION = if cfg.features.security.htmlInjection.enable then "true" else "false"; BOOK_ENABLE_HTML_INJECTION = if cfg.security.htmlInjection.enable then "true" else "false";
BOOK_ENABLE_HONEYPOT = if cfg.features.security.honeypot.enable then "true" else "false"; BOOK_ENABLE_HONEYPOT = if cfg.security.honeypot.enable then "true" else "false";
BOOK_ENABLE_CAPTCHA = if cfg.features.security.captcha.enable then "true" else "false"; BOOK_ENABLE_CAPTCHA = if cfg.security.captcha.enable then "true" else "false";
BOOK_CAPTCHA_QUESTION = cfg.features.security.captcha.question; BOOK_CAPTCHA_QUESTION = cfg.security.captcha.question;
BOOK_CAPTCHA_ANSWER = cfg.features.security.captcha.answer; BOOK_CAPTCHA_ANSWER = cfg.security.captcha.answer;
BOOK_CAPTCHA_EXACT = if cfg.features.security.captcha.exact then "true" else "false"; BOOK_CAPTCHA_EXACT = if cfg.security.captcha.exact then "true" else "false";
BOOK_CAPTCHA_CASESENSITIVE = if cfg.features.security.captcha.caseSensitive then "true" else "false"; BOOK_CAPTCHA_CASESENSITIVE = if cfg.security.captcha.caseSensitive then "true" else "false";
BOOK_MAX_NAME_LENGTH = toString cfg.limits.name; BOOK_MAX_NAME_LENGTH = toString cfg.limits.name.length;
BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message; BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message.length;
BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website; BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website.length;
BOOK_STYLE = cfg.styles.css; BOOK_STYLE = cfg.styles.css;
BOOK_BUTTON_TEXT = cfg.styles.labels.submit; BOOK_BUTTON_TEXT = cfg.styles.labels.submit;
BOOK_LABEL_NAME = cfg.styles.labels.name; BOOK_LABEL_NAME = cfg.styles.labels.name;
BOOK_LABEL_WEBSITE = cfg.styles.labels.website; BOOK_LABEL_WEBSITE = cfg.styles.labels.website;
BOOK_LABEL_MESSAGE = cfg.styles.labels.message; BOOK_LABEL_MESSAGE = cfg.styles.labels.message;
BOOK_LABEL_DRAWING = cfg.styles.labels.drawing; BOOK_LABEL_DRAWING = cfg.styles.labels.drawing;
BOOK_LABEL_VOICE_NOTE = cfg.styles.labels.voiceNote; BOOK_LABEL_VOICE_NOTE = cfg.styles.labels.voice;
BOOK_VOICE_NOTE_RECORD_TEXT = cfg.styles.labels.voiceNoteRecord; BOOK_VOICE_NOTE_RECORD_TEXT = cfg.styles.labels.voiceRecord;
BOOK_CANVAS_WIDTH = toString cfg.features.drawing.canvasWidth; BOOK_CANVAS_WIDTH = toString cfg.limits.drawing.width;
BOOK_CANVAS_HEIGHT = toString cfg.features.drawing.canvasHeight; BOOK_CANVAS_HEIGHT = toString cfg.limits.drawing.height;
BOOK_ENABLE_VOICE_NOTES = if cfg.features.voiceNote.enable then "true" else "false"; BOOK_ENABLE_VOICE_NOTES = if cfg.voice.enable then "true" else "false";
BOOK_VOICE_NOTE_MAX_DURATION = toString cfg.features.voiceNote.maxDuration; 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_WIDTH = toString cfg.styles.message.width;
BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height; BOOK_TEXTAREA_HEIGHT = toString cfg.styles.message.height;
} // lib.optionalAttrs (cfg.styles.cssFile != null) { } // lib.optionalAttrs (cfg.styles.cssFile != null) {
@ -354,11 +386,11 @@ in
BOOK_TEMPLATE = cfg.styles.templateFile; BOOK_TEMPLATE = cfg.styles.templateFile;
} // lib.optionalAttrs (cfg.styles.successTemplateFile != null) { } // lib.optionalAttrs (cfg.styles.successTemplateFile != null) {
BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile; BOOK_SUCCESS_TEMPLATE = cfg.styles.successTemplateFile;
} // lib.optionalAttrs cfg.features.telegram.enable { } // lib.optionalAttrs cfg.telegram.enable {
BOOK_TELEGRAM_CHAT_ID = toString cfg.features.telegram.chatId; BOOK_TELEGRAM_CHAT_ID = toString cfg.telegram.chatId;
BOOK_TELEGRAM_RETRY_INTERVAL = toString cfg.features.telegram.retry.interval; BOOK_TELEGRAM_RETRY_INTERVAL = toString cfg.telegram.retry.interval;
BOOK_TELEGRAM_RETRY_LIMIT = toString cfg.features.telegram.retry.limit; BOOK_TELEGRAM_RETRY_LIMIT = toString cfg.telegram.retry.limit;
BOOK_TELEGRAM_REMINDER_INTERVAL = toString cfg.features.telegram.reminderInterval; BOOK_TELEGRAM_REMINDER_INTERVAL = toString cfg.telegram.reminderInterval;
}; };
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
@ -372,8 +404,8 @@ in
ReadWritePaths = [ cfg.dataDir ]; ReadWritePaths = [ cfg.dataDir ];
}; };
script = '' script = ''
${lib.optionalString cfg.features.telegram.enable '' ${lib.optionalString cfg.telegram.enable ''
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.features.telegram.botTokenFile}")" export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegram.botTokenFile}")"
''} ''}
exec ${cfg.package}/bin/guestbook exec ${cfg.package}/bin/guestbook
''; '';

View file

@ -34,6 +34,10 @@ pub struct Config {
pub canvas_height: u32, pub canvas_height: u32,
pub enable_voice_notes: bool, pub enable_voice_notes: bool,
pub voice_note_max_duration: u32, 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 template: Option<String>,
pub success_template: Option<String>, pub success_template: Option<String>,
pub style: String, pub style: String,
@ -153,6 +157,18 @@ impl Config {
.unwrap_or_else(|_| "20".into()) .unwrap_or_else(|_| "20".into())
.parse() .parse()
.map_err(|_| "BOOK_VOICE_NOTE_MAX_DURATION must be a number")?, .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| { template: env::var("BOOK_TEMPLATE").ok().map(|path| {
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}"))

View file

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

View file

@ -277,7 +277,20 @@ async fn submit(
None 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.")); return Html(render_error_page(&state.config, "Please leave a message, drawing, or voice note."));
} }
@ -411,6 +424,10 @@ mod tests {
canvas_height: 200, canvas_height: 200,
enable_voice_notes: false, enable_voice_notes: false,
voice_note_max_duration: 20, voice_note_max_duration: 20,
message_required: false,
drawing_required: false,
voice_note_required: false,
content_required: true,
template: None, template: None,
success_template: None, success_template: None,
style: String::new(), style: String::new(),
@ -1164,6 +1181,98 @@ mod tests {
assert_eq!(status, StatusCode::NOT_FOUND); 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] #[tokio::test]
async fn test_voice_note_full_roundtrip() { async fn test_voice_note_full_roundtrip() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

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