From 37d2a2b99e46ee26090af278a4b849baa1f42250 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 12:39:45 +0100 Subject: [PATCH 01/10] feat: telegram bot for moderation (allow/deny commands) --- src/main.rs | 20 ++++++++++++++- src/telegram.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/telegram.rs diff --git a/src/main.rs b/src/main.rs index 24b8703..dd3b651 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ mod config; mod entries; mod render; +mod telegram; mod web; use std::sync::Arc; +use teloxide::prelude::*; #[tokio::main] async fn main() { @@ -11,12 +13,28 @@ async fn main() { let config = config::Config::load("config.toml").expect("failed to load config.toml"); let listen = config.listen.clone(); + let entries_dir = config.data_dir.join("entries"); + let chat_id = ChatId(config.telegram_chat_id); - let (tx, _rx) = tokio::sync::mpsc::channel(32); + std::fs::create_dir_all(&entries_dir).ok(); + + let bot = Bot::new(&config.telegram_bot_token); + + let (tx, rx) = tokio::sync::mpsc::channel(32); let state = Arc::new(web::AppState { config, tx }); let app = web::router(state); + // Spawn telegram notification sender + let notify_bot = bot.clone(); + tokio::spawn(telegram::notification_task(notify_bot, chat_id, rx)); + + // Spawn telegram command listener + let cmd_bot = bot.clone(); + let cmd_entries_dir = entries_dir.clone(); + tokio::spawn(telegram::bot_task(cmd_bot, chat_id, cmd_entries_dir)); + + // Run web server tracing::info!("listening on {listen}"); let listener = tokio::net::TcpListener::bind(&listen).await.unwrap(); axum::serve(listener, app).await.unwrap(); diff --git a/src/telegram.rs b/src/telegram.rs new file mode 100644 index 0000000..0215cc2 --- /dev/null +++ b/src/telegram.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; + +use teloxide::prelude::*; +use tokio::sync::mpsc::Receiver; + +use crate::entries::{self, Entry, Status}; + +/// Send a notification to Telegram about a new entry. +async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) { + let short_id = entry.id.split('-').last().unwrap_or(&entry.id); + let text = format!( + "New guestbook entry:\n\nName: {}\nWebsite: {}\n\n{}\n\n/allow_{}\n/deny_{}", + entry.meta.name, entry.meta.website, entry.body, short_id, short_id + ); + if let Err(e) = bot.send_message(chat_id, &text).await { + tracing::error!("failed to send telegram message: {e}"); + } +} + +/// Listen for new entries on the channel and send Telegram notifications. +pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver) { + while let Some(entry) = rx.recv().await { + notify(&bot, chat_id, &entry).await; + } +} + +/// Run the Telegram bot that listens for /allow_ and /deny_ commands. +pub async fn bot_task(bot: Bot, chat_id: ChatId, entries_dir: PathBuf) { + let handler = Update::filter_message().endpoint( + |bot: Bot, msg: Message, entries_dir: PathBuf, chat_id: ChatId| async move { + let text = msg.text().unwrap_or(""); + // Only respond to the configured chat + if msg.chat.id != chat_id { + return respond(()); + } + + if let Some(id) = text.strip_prefix("/allow_") { + match entries::set_status(&entries_dir, id, Status::Approved) { + Ok(name) => { + bot.send_message(msg.chat.id, format!("Approved ({name}).")) + .await?; + } + Err(e) => { + bot.send_message(msg.chat.id, e).await?; + } + } + } else if let Some(id) = text.strip_prefix("/deny_") { + match entries::set_status(&entries_dir, id, Status::Denied) { + Ok(name) => { + bot.send_message(msg.chat.id, format!("Denied ({name}).")) + .await?; + } + Err(e) => { + bot.send_message(msg.chat.id, e).await?; + } + } + } + + Ok(()) + }, + ); + + Dispatcher::builder(bot, handler) + .dependencies(dptree::deps![entries_dir, chat_id]) + .build() + .dispatch() + .await; +} From 66e314810bf3840c0a26a230b925d7f7da11b0f4 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 12:42:16 +0100 Subject: [PATCH 02/10] chore: gitignore data directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1b57a19..819e664 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env /target +/data CLAUDE.md From 996da6cf8bee2fa751d8b48e1ec9540f29e92a5e Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 13:12:13 +0100 Subject: [PATCH 03/10] feat: nix flake for building guestbook --- flake.nix | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 flake.nix diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..529f489 --- /dev/null +++ b/flake.nix @@ -0,0 +1,26 @@ +{ + description = "Guestbook"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + crane.url = "github:ipetkov/crane"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, crane, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + craneLib = crane.mkLib pkgs; + guestbook = craneLib.buildPackage { + src = craneLib.cleanCargoSource ./.; + buildInputs = with pkgs; [ openssl ]; + nativeBuildInputs = with pkgs; [ pkg-config ]; + }; + in { + packages.default = guestbook; + devShells.default = craneLib.devShell { + packages = with pkgs; [ cargo rustc rust-analyzer ]; + }; + }); +} From 5bfba1b6ff0edb0873a59e8c67171e671bca8ae0 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 14:24:46 +0100 Subject: [PATCH 04/10] switch config to env vars, add nix module --- Cargo.lock | 7 +++ Cargo.toml | 1 + config.toml | 6 --- flake.nix | 6 ++- module.nix | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 94 +++++++++++++++++++++++++++++++--------- src/main.rs | 5 ++- src/render.rs | 2 +- 8 files changed, 206 insertions(+), 32 deletions(-) delete mode 100644 config.toml create mode 100644 module.nix diff --git a/Cargo.lock b/Cargo.lock index 052f576..b2f2a2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dptree" version = "0.3.0" @@ -450,6 +456,7 @@ version = "0.1.0" dependencies = [ "axum", "chrono", + "dotenvy", "serde", "teloxide", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 21bd232..801f2ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] } teloxide = { version = "0.13", features = ["macros"] } serde = { version = "1", features = ["derive"] } toml = "0.8" +dotenvy = "0.15" uuid = { version = "1", features = ["v4"] } chrono = "0.4" tracing = "0.1" diff --git a/config.toml b/config.toml deleted file mode 100644 index 7302f23..0000000 --- a/config.toml +++ /dev/null @@ -1,6 +0,0 @@ -listen = "127.0.0.1:8123" -data_dir = "./data" -site_title = "ily.rs" -site_url = "https://ily.rs" -telegram_bot_token = "REPLACE_ME" -telegram_chat_id = 0 diff --git a/flake.nix b/flake.nix index 529f489..1cc3975 100644 --- a/flake.nix +++ b/flake.nix @@ -8,7 +8,7 @@ }; outputs = { self, nixpkgs, crane, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem (system: + (flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; craneLib = crane.mkLib pkgs; @@ -22,5 +22,7 @@ devShells.default = craneLib.devShell { packages = with pkgs; [ cargo rustc rust-analyzer ]; }; - }); + })) // { + nixosModules.default = ./module.nix; + }; } diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..46cfbb4 --- /dev/null +++ b/module.nix @@ -0,0 +1,117 @@ +{ config, lib, pkgs, ... }: +let + inherit (lib) mkOption mkEnableOption types mkIf mkMerge; + cfg = config.services.guestbook; +in +{ + options.services.guestbook = { + enable = mkEnableOption "guestbook service"; + + package = mkOption { + type = types.package; + description = "The guestbook package to use."; + }; + + port = mkOption { + type = types.port; + default = 8123; + description = "Port to listen on (binds to 127.0.0.1)."; + }; + + dataDir = mkOption { + type = types.str; + default = "/srv/guestbook/data"; + description = "Directory for guestbook entry files."; + }; + + siteTitle = mkOption { + type = types.str; + default = "guestbook"; + description = "Site title shown in nav and page title."; + }; + + siteUrl = mkOption { + type = types.str; + description = "Base URL of the main site (for absolute nav links)."; + }; + + telegramChatId = mkOption { + type = types.int; + description = "Telegram chat ID for moderation messages."; + }; + + telegramBotTokenFile = mkOption { + type = types.path; + description = "Path to a file containing the Telegram bot token."; + }; + + user = mkOption { + type = types.str; + default = "guestbook"; + description = "User to run the service as."; + }; + + group = mkOption { + type = types.str; + default = "guestbook"; + description = "Group to run the service as."; + }; + + caddy = { + enable = mkEnableOption "Caddy reverse proxy for guestbook"; + + domain = mkOption { + type = types.str; + description = "Domain for the Caddy virtual host."; + }; + }; + }; + + config = mkIf cfg.enable (mkMerge [ + { + systemd.services.guestbook = { + description = "Guestbook for ${cfg.siteTitle}"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + environment = { + BOOK_PORT = toString cfg.port; + BOOK_DATA_DIR = cfg.dataDir; + BOOK_SITE_TITLE = cfg.siteTitle; + BOOK_SITE_URL = cfg.siteUrl; + BOOK_TELEGRAM_CHAT_ID = toString cfg.telegramChatId; + }; + serviceConfig = { + Type = "simple"; + ExecStartPre = "+${pkgs.writeShellScript "guestbook-prepare" '' + mkdir -p ${cfg.dataDir}/entries + chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir} + ''}"; + Restart = "on-failure"; + User = cfg.user; + Group = cfg.group; + ReadWritePaths = [ cfg.dataDir ]; + }; + script = '' + export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegramBotTokenFile}")" + exec ${cfg.package}/bin/guestbook + ''; + }; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + }; + + users.groups.${cfg.group} = {}; + } + + (mkIf cfg.caddy.enable { + services.caddy.virtualHosts.${cfg.caddy.domain}.extraConfig = '' + reverse_proxy localhost:${toString cfg.port} + encode zstd gzip + ''; + }) + ]); +} diff --git a/src/config.rs b/src/config.rs index 0446feb..f47c55a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,9 @@ -use serde::Deserialize; +use std::env; use std::path::PathBuf; -#[derive(Debug, Deserialize)] +#[derive(Debug)] pub struct Config { - pub listen: String, + pub port: u16, pub data_dir: PathBuf, pub site_title: String, pub site_url: String, @@ -12,10 +12,28 @@ pub struct Config { } impl Config { - pub fn load(path: &str) -> Result> { - let content = std::fs::read_to_string(path)?; - let config: Config = toml::from_str(&content)?; - Ok(config) + pub fn listen_addr(&self) -> String { + format!("127.0.0.1:{}", self.port) + } + + pub fn from_env() -> Result { + Ok(Config { + port: env::var("BOOK_PORT") + .unwrap_or_else(|_| "8123".into()) + .parse() + .map_err(|_| "BOOK_PORT must be a number")?, + data_dir: env::var("BOOK_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("./data")), + site_title: env::var("BOOK_SITE_TITLE").unwrap_or_else(|_| "guestbook".into()), + site_url: env::var("BOOK_SITE_URL").map_err(|_| "BOOK_SITE_URL is required")?, + telegram_bot_token: env::var("BOOK_TELEGRAM_BOT_TOKEN") + .map_err(|_| "BOOK_TELEGRAM_BOT_TOKEN is required")?, + telegram_chat_id: env::var("BOOK_TELEGRAM_CHAT_ID") + .map_err(|_| "BOOK_TELEGRAM_CHAT_ID is required")? + .parse() + .map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer")?, + }) } } @@ -24,20 +42,54 @@ mod tests { use super::*; #[test] - fn test_parse_config() { - let toml_str = r#" -listen = "127.0.0.1:8123" -data_dir = "/var/lib/guestbook" -site_title = "ily.rs" -site_url = "https://ily.rs" -telegram_bot_token = "123:ABC" -telegram_chat_id = 12345 -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.listen, "127.0.0.1:8123"); - assert_eq!(config.data_dir, PathBuf::from("/var/lib/guestbook")); - assert_eq!(config.site_title, "ily.rs"); - assert_eq!(config.site_url, "https://ily.rs"); + fn test_from_env() { + env::set_var("BOOK_PORT", "9999"); + env::set_var("BOOK_DATA_DIR", "/tmp/gb"); + env::set_var("BOOK_SITE_TITLE", "test.rs"); + env::set_var("BOOK_SITE_URL", "https://test.rs"); + env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); + env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.port, 9999); + assert_eq!(config.listen_addr(), "127.0.0.1:9999"); + assert_eq!(config.data_dir, PathBuf::from("/tmp/gb")); + assert_eq!(config.site_title, "test.rs"); + assert_eq!(config.site_url, "https://test.rs"); assert_eq!(config.telegram_chat_id, 12345); + + // Clean up + env::remove_var("BOOK_PORT"); + env::remove_var("BOOK_DATA_DIR"); + env::remove_var("BOOK_SITE_TITLE"); + env::remove_var("BOOK_SITE_URL"); + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + } + + #[test] + fn test_defaults() { + env::set_var("BOOK_SITE_URL", "https://test.rs"); + env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); + env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.port, 8123); + assert_eq!(config.data_dir, PathBuf::from("./data")); + assert_eq!(config.site_title, "guestbook"); + + env::remove_var("BOOK_SITE_URL"); + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + } + + #[test] + fn test_missing_required() { + env::remove_var("BOOK_SITE_URL"); + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + + let result = Config::from_env(); + assert!(result.is_err()); } } diff --git a/src/main.rs b/src/main.rs index dd3b651..76df872 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,9 @@ use teloxide::prelude::*; async fn main() { tracing_subscriber::fmt::init(); - let config = config::Config::load("config.toml").expect("failed to load config.toml"); - let listen = config.listen.clone(); + dotenvy::dotenv().ok(); + let config = config::Config::from_env().expect("failed to load config"); + let listen = config.listen_addr(); let entries_dir = config.data_dir.join("entries"); let chat_id = ChatId(config.telegram_chat_id); diff --git a/src/render.rs b/src/render.rs index da2e3c8..68f6fab 100644 --- a/src/render.rs +++ b/src/render.rs @@ -8,7 +8,7 @@ pub fn render_page(site_title: &str, site_url: &str, entries: &[Entry], form_htm - guestbook - {site_title} + {site_title} From c3ceb39b71bfe5a7c7a9913f74b9c9839e9f379f Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 14:46:40 +0100 Subject: [PATCH 05/10] configurable honeypot toggle --- module.nix | 7 +++++++ src/config.rs | 4 ++++ src/web.rs | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/module.nix b/module.nix index 46cfbb4..3285e1a 100644 --- a/module.nix +++ b/module.nix @@ -45,6 +45,12 @@ in description = "Path to a file containing the Telegram bot token."; }; + honeypot = mkOption { + type = types.bool; + default = true; + description = "Enable honeypot field for spam prevention."; + }; + user = mkOption { type = types.str; default = "guestbook"; @@ -80,6 +86,7 @@ in BOOK_SITE_TITLE = cfg.siteTitle; BOOK_SITE_URL = cfg.siteUrl; BOOK_TELEGRAM_CHAT_ID = toString cfg.telegramChatId; + BOOK_HONEYPOT = if cfg.honeypot then "true" else "false"; }; serviceConfig = { Type = "simple"; diff --git a/src/config.rs b/src/config.rs index f47c55a..bce8033 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ pub struct Config { pub site_url: String, pub telegram_bot_token: String, pub telegram_chat_id: i64, + pub honeypot: bool, } impl Config { @@ -33,6 +34,9 @@ impl Config { .map_err(|_| "BOOK_TELEGRAM_CHAT_ID is required")? .parse() .map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer")?, + honeypot: env::var("BOOK_HONEYPOT") + .map(|v| v != "false") + .unwrap_or(true), }) } } diff --git a/src/web.rs b/src/web.rs index d749133..9cf9ce4 100644 --- a/src/web.rs +++ b/src/web.rs @@ -53,7 +53,7 @@ async fn submit( Form(form): Form, ) -> Html { // Honeypot check — silently discard - if !form.url.is_empty() { + if state.config.honeypot && !form.url.is_empty() { return Html("Thanks! Your message is pending approval.".to_string()); } From 81d44da41c1792fb9b20d6c629bb372e21e7bc4b Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 14:48:48 +0100 Subject: [PATCH 06/10] configurable max name/message length (0 for unlimited) --- module.nix | 14 ++++++++++++++ src/config.rs | 10 ++++++++++ src/web.rs | 10 ++++++---- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/module.nix b/module.nix index 3285e1a..9ec4ff6 100644 --- a/module.nix +++ b/module.nix @@ -51,6 +51,18 @@ in description = "Enable honeypot field for spam prevention."; }; + maxNameLength = mkOption { + type = types.int; + default = 50; + description = "Maximum length for names. 0 for unlimited."; + }; + + maxMessageLength = mkOption { + type = types.int; + default = 1000; + description = "Maximum length for messages. 0 for unlimited."; + }; + user = mkOption { type = types.str; default = "guestbook"; @@ -87,6 +99,8 @@ in BOOK_SITE_URL = cfg.siteUrl; BOOK_TELEGRAM_CHAT_ID = toString cfg.telegramChatId; BOOK_HONEYPOT = if cfg.honeypot then "true" else "false"; + BOOK_MAX_NAME_LENGTH = toString cfg.maxNameLength; + BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength; }; serviceConfig = { Type = "simple"; diff --git a/src/config.rs b/src/config.rs index bce8033..cccf1d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { pub telegram_bot_token: String, pub telegram_chat_id: i64, pub honeypot: bool, + pub max_name_length: usize, + pub max_message_length: usize, } impl Config { @@ -37,6 +39,14 @@ impl Config { honeypot: env::var("BOOK_HONEYPOT") .map(|v| v != "false") .unwrap_or(true), + max_name_length: env::var("BOOK_MAX_NAME_LENGTH") + .unwrap_or_else(|_| "50".into()) + .parse() + .map_err(|_| "BOOK_MAX_NAME_LENGTH must be a number")?, + max_message_length: env::var("BOOK_MAX_MESSAGE_LENGTH") + .unwrap_or_else(|_| "1000".into()) + .parse() + .map_err(|_| "BOOK_MAX_MESSAGE_LENGTH must be a number")?, }) } } diff --git a/src/web.rs b/src/web.rs index 9cf9ce4..17f9761 100644 --- a/src/web.rs +++ b/src/web.rs @@ -65,14 +65,16 @@ async fn submit( if name.is_empty() || message.is_empty() { return Html("Name and message are required.".to_string()); } - if name.len() > 50 { - return Html("Name is too long (max 50 chars).".to_string()); + let max_name = state.config.max_name_length; + if max_name > 0 && name.len() > max_name { + return Html(format!("Name is too long (max {max_name} chars).")); } if website.len() > 100 { return Html("Website is too long (max 100 chars).".to_string()); } - if message.len() > 1000 { - return Html("Message is too long (max 1000 chars).".to_string()); + let max_msg = state.config.max_message_length; + if max_msg > 0 && message.len() > max_msg { + return Html(format!("Message is too long (max {max_msg} chars).")); } let short_id = &Uuid::new_v4().to_string()[..8]; From 0a827492c04d03fc9018dce99742a462bbe3bc4c Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 14:50:51 +0100 Subject: [PATCH 07/10] configurable max website length --- module.nix | 7 +++++++ src/config.rs | 11 +++++++++++ src/web.rs | 5 +++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/module.nix b/module.nix index 9ec4ff6..10f26a1 100644 --- a/module.nix +++ b/module.nix @@ -63,6 +63,12 @@ in description = "Maximum length for messages. 0 for unlimited."; }; + maxWebsiteLength = mkOption { + type = types.int; + default = 100; + description = "Maximum length for website URLs. 0 for unlimited."; + }; + user = mkOption { type = types.str; default = "guestbook"; @@ -101,6 +107,7 @@ in BOOK_HONEYPOT = if cfg.honeypot then "true" else "false"; BOOK_MAX_NAME_LENGTH = toString cfg.maxNameLength; BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength; + BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; }; serviceConfig = { Type = "simple"; diff --git a/src/config.rs b/src/config.rs index cccf1d4..64f67c7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub struct Config { pub honeypot: bool, pub max_name_length: usize, pub max_message_length: usize, + pub max_website_length: usize, } impl Config { @@ -47,6 +48,10 @@ impl Config { .unwrap_or_else(|_| "1000".into()) .parse() .map_err(|_| "BOOK_MAX_MESSAGE_LENGTH must be a number")?, + max_website_length: env::var("BOOK_MAX_WEBSITE_LENGTH") + .unwrap_or_else(|_| "100".into()) + .parse() + .map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?, }) } } @@ -54,9 +59,13 @@ impl Config { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn test_from_env() { + let _lock = ENV_LOCK.lock().unwrap(); env::set_var("BOOK_PORT", "9999"); env::set_var("BOOK_DATA_DIR", "/tmp/gb"); env::set_var("BOOK_SITE_TITLE", "test.rs"); @@ -83,6 +92,7 @@ mod tests { #[test] fn test_defaults() { + let _lock = ENV_LOCK.lock().unwrap(); env::set_var("BOOK_SITE_URL", "https://test.rs"); env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); @@ -99,6 +109,7 @@ mod tests { #[test] fn test_missing_required() { + let _lock = ENV_LOCK.lock().unwrap(); env::remove_var("BOOK_SITE_URL"); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_CHAT_ID"); diff --git a/src/web.rs b/src/web.rs index 17f9761..7a53b51 100644 --- a/src/web.rs +++ b/src/web.rs @@ -69,8 +69,9 @@ async fn submit( if max_name > 0 && name.len() > max_name { return Html(format!("Name is too long (max {max_name} chars).")); } - if website.len() > 100 { - return Html("Website is too long (max 100 chars).".to_string()); + let max_web = state.config.max_website_length; + if max_web > 0 && website.len() > max_web { + return Html(format!("Website is too long (max {max_web} chars).")); } let max_msg = state.config.max_message_length; if max_msg > 0 && message.len() > max_msg { From 5e71ee1be6c9af9885aefada72bbf64bd11d91e6 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 14:56:40 +0100 Subject: [PATCH 08/10] open registration toggle --- module.nix | 7 +++++++ src/config.rs | 4 ++++ src/web.rs | 7 ++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/module.nix b/module.nix index 10f26a1..7326c7e 100644 --- a/module.nix +++ b/module.nix @@ -69,6 +69,12 @@ in description = "Maximum length for website URLs. 0 for unlimited."; }; + openRegistration = mkOption { + type = types.bool; + default = true; + description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; + }; + user = mkOption { type = types.str; default = "guestbook"; @@ -108,6 +114,7 @@ in BOOK_MAX_NAME_LENGTH = toString cfg.maxNameLength; BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength; BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; + BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false"; }; serviceConfig = { Type = "simple"; diff --git a/src/config.rs b/src/config.rs index 64f67c7..84a1128 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ pub struct Config { pub max_name_length: usize, pub max_message_length: usize, pub max_website_length: usize, + pub open_registration: bool, } impl Config { @@ -52,6 +53,9 @@ impl Config { .unwrap_or_else(|_| "100".into()) .parse() .map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?, + open_registration: env::var("BOOK_OPEN_REGISTRATION") + .map(|v| v != "false") + .unwrap_or(true), }) } } diff --git a/src/web.rs b/src/web.rs index 7a53b51..e6b2de7 100644 --- a/src/web.rs +++ b/src/web.rs @@ -39,11 +39,12 @@ pub fn router(state: Arc) -> Router { async fn index(State(state): State>) -> Html { let entries_dir = state.config.data_dir.join("entries"); let entries = entries::read_approved(&entries_dir); + let form = if state.config.open_registration { FORM_HTML } else { "" }; let html = render::render_page( &state.config.site_title, &state.config.site_url, &entries, - FORM_HTML, + form, ); Html(html) } @@ -52,6 +53,10 @@ async fn submit( State(state): State>, Form(form): Form, ) -> Html { + if !state.config.open_registration { + return Html("Submissions are closed.".to_string()); + } + // Honeypot check — silently discard if state.config.honeypot && !form.url.is_empty() { return Html("Thanks! Your message is pending approval.".to_string()); From 1f688e23a93e28d88e3f968d2d50e951840a67d8 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 15:03:26 +0100 Subject: [PATCH 09/10] tests for web routes and config toggles --- Cargo.lock | 3 + Cargo.toml | 5 ++ src/web.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b2f2a2c..5edfe70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,10 +457,13 @@ dependencies = [ "axum", "chrono", "dotenvy", + "http-body-util", "serde", "teloxide", + "tempfile", "tokio", "toml", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 801f2ea..50b5f96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,8 @@ uuid = { version = "1", features = ["v4"] } chrono = "0.4" tracing = "0.1" tracing-subscriber = "0.3" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +tempfile = "3" diff --git a/src/web.rs b/src/web.rs index e6b2de7..4e7b21c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -116,3 +116,170 @@ async fn submit( async fn style() -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/css")], STYLE_CSS) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + fn test_config(dir: &std::path::Path) -> Config { + Config { + port: 0, + data_dir: dir.to_path_buf(), + site_title: "test".into(), + site_url: "https://test.rs".into(), + telegram_bot_token: "fake".into(), + telegram_chat_id: 0, + honeypot: true, + max_name_length: 50, + max_message_length: 1000, + max_website_length: 100, + open_registration: true, + } + } + + fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver) { + let (tx, rx) = tokio::sync::mpsc::channel(32); + let state = Arc::new(AppState { config, tx }); + (router(state), rx) + } + + async fn post_form(app: &Router, body: &str) -> (StatusCode, String) { + let req = Request::builder() + .method("POST") + .uri("/submit") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body.to_string())) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) + } + + async fn get_index(app: &Router) -> String { + let req = Request::builder() + .uri("/") + .body(Body::empty()) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + #[tokio::test] + async fn test_open_registration_shows_form() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + let html = get_index(&app).await; + assert!(html.contains("action=\"/submit\"")); + } + + #[tokio::test] + async fn test_closed_registration_hides_form() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.open_registration = false; + let (app, _rx) = test_app(config); + let html = get_index(&app).await; + assert!(!html.contains("action=\"/submit\"")); + } + + #[tokio::test] + async fn test_closed_registration_rejects_submit() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.open_registration = false; + let (app, _rx) = test_app(config); + let (status, body) = post_form(&app, "name=test&message=hello").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains("Submissions are closed")); + } + + #[tokio::test] + async fn test_honeypot_discards() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=bot&message=spam&url=http://spam.com").await; + assert!(body.contains("Thanks!")); + // No entry file should exist + let entries: Vec<_> = std::fs::read_dir(dir.path().join("entries")) + .into_iter() + .flatten() + .collect(); + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn test_honeypot_disabled_allows_url_field() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.honeypot = false; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=user&message=hello&url=http://mysite.com").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_max_name_length() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_name_length = 5; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=toolong&message=hi").await; + assert!(body.contains("too long")); + } + + #[tokio::test] + async fn test_max_name_length_zero_unlimited() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_name_length = 0; + let (app, _rx) = test_app(config); + let long_name = "a".repeat(200); + let (_, body) = post_form(&app, &format!("name={long_name}&message=hi")).await; + assert!(body.contains("pending approval")); + } + + #[tokio::test] + async fn test_max_message_length() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_message_length = 10; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=test&message=this+message+is+way+too+long").await; + assert!(body.contains("too long")); + } + + #[tokio::test] + async fn test_max_website_length() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.max_website_length = 5; + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=test&message=hi&website=http://toolong.com").await; + assert!(body.contains("too long")); + } + + #[tokio::test] + async fn test_valid_submission_creates_entry() { + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + let (app, _rx) = test_app(config); + let (_, body) = post_form(&app, "name=alice&message=hello").await; + assert!(body.contains("pending approval")); + let count = std::fs::read_dir(dir.path().join("entries")) + .unwrap() + .count(); + assert_eq!(count, 1); + } +} From 107f43ae0c6bd53c03cd3efcd3ca43f32be5147d Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 15:24:43 +0100 Subject: [PATCH 10/10] add crates.io metadata and MIT license --- Cargo.toml | 3 +++ LICENSE | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 LICENSE diff --git a/Cargo.toml b/Cargo.toml index 50b5f96..2e85995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "guestbook" version = "0.1.0" edition = "2021" +description = "A configurable web guestbook made to be easy to use, with entries in plain text files, options for honeypots and captchas to deter spam, and moderation via Telegram bot (or editing entries directly in-file to mark them as approved)." +license = "MIT" +repository = "https://git.ily.rs/lew/guestbook" [dependencies] axum = "0.8" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e8a6c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Llywelwyn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.