From b784f4dd9c3708d64288c11dbcffcb41c02a1478 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 28 Apr 2026 15:50:36 +0100 Subject: [PATCH] feat: set required/optional for message, drawing, and voice notes, independently --- .env.example | 12 ++ README.md | 276 ++++++++++++++++++++++----------- module.nix | 344 +++++++++++++++++++++++------------------- src/config.rs | 16 ++ src/render.rs | 7 +- src/web.rs | 111 +++++++++++++- templates/default.css | 4 + 7 files changed, 526 insertions(+), 244 deletions(-) diff --git a/.env.example b/.env.example index 2ef4faf..a360d1f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index ed5afe4..c794716 100644 --- a/README.md +++ b/README.md @@ -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_` and `/voice_note_` 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,45 +251,54 @@ services.guestbook = { }; }; - features = { - submissions.enable = true; - websites.enable = true; - drawing = { - enable = false; - canvasWidth = 320; - canvasHeight = 200; + submissions.enable = true; + websites.enable = true; + + drawing = { + enable = false; + required = false; + }; + + voice = { + enable = false; + required = false; + }; + + message.required = false; + content.required = true; + + telegram = { + enable = false; + # botTokenFile = ; -- required when enabled + # chatId = ; -- required when enabled + retry = { + interval = 20; + limit = 3; }; - voiceNote = { + reminderInterval = 86400; + }; + + security = { + htmlInjection.enable = false; + honeypot.enable = true; + captcha = { enable = false; - maxDuration = 20; - }; - telegram = { - enable = false; - # botTokenFile = ; -- required when enabled - # chatId = ; -- 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; - }; + question = ""; + answer = ""; + exact = false; + 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 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; +} ``` --- diff --git a/module.nix b/module.nix index cdba7f7..f8f0b27 100644 --- a/module.nix +++ b/module.nix @@ -72,155 +72,183 @@ in }; }; - features = { - submissions = { - enable = mkOption { - type = types.bool; - default = true; - description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; + submissions = { + enable = mkOption { + type = types.bool; + default = true; + 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 = { - 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."; - }; + reminderInterval = mkOption { + type = types.int; + default = 86400; + description = "Seconds between pending entry reminders. Set to 0 to disable."; }; + }; - drawing = { + security = { + htmlInjection = { enable = mkOption { type = types.bool; 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; default = 320; description = "Drawing canvas width in pixels."; }; - canvasHeight = mkOption { + height = mkOption { type = types.int; default = 200; description = "Drawing canvas height in pixels."; }; }; - voiceNote = { - 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 { + voice.duration = mkOption { type = types.int; - default = 0; - description = "Maximum length for names. 0 for unlimited."; - }; - - 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."; + default = 20; + description = "Maximum voice note duration in seconds. Max file size is derived as duration * 10KB."; }; }; @@ -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 ''; diff --git a/src/config.rs b/src/config.rs index 41671f0..67e759c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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}")) diff --git a/src/render.rs b/src/render.rs index eb062af..312a888 100644 --- a/src/render.rs +++ b/src/render.rs @@ -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:")); } diff --git a/src/web.rs b/src/web.rs index 95be1b6..b8d1790 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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(); diff --git a/templates/default.css b/templates/default.css index 4e54ced..e670fdb 100644 --- a/templates/default.css +++ b/templates/default.css @@ -17,6 +17,10 @@ body { .guestbook-form {} .guestbook-label { display: block; + font-style: oblique; +} +.guestbook-label::after { + content: ":"; } .guestbook-input { display: block;