Compare commits
10 commits
660c02d63f
...
107f43ae0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 107f43ae0c | |||
| 1f688e23a9 | |||
| 5e71ee1be6 | |||
| 0a827492c0 | |||
| 81d44da41c | |||
| c3ceb39b71 | |||
| 5bfba1b6ff | |||
| 996da6cf8b | |||
| 66e314810b | |||
| 37d2a2b99e |
12 changed files with 597 additions and 39 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
.env
|
.env
|
||||||
/target
|
/target
|
||||||
|
/data
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
|
||||||
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -245,6 +245,12 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenvy"
|
||||||
|
version = "0.15.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dptree"
|
name = "dptree"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -450,10 +456,14 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dotenvy",
|
||||||
|
"http-body-util",
|
||||||
"serde",
|
"serde",
|
||||||
"teloxide",
|
"teloxide",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
name = "guestbook"
|
name = "guestbook"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
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]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
|
@ -9,7 +12,13 @@ tokio = { version = "1", features = ["full"] }
|
||||||
teloxide = { version = "0.13", features = ["macros"] }
|
teloxide = { version = "0.13", features = ["macros"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
dotenvy = "0.15"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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
|
|
||||||
28
flake.nix
Normal file
28
flake.nix
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
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 ];
|
||||||
|
};
|
||||||
|
})) // {
|
||||||
|
nixosModules.default = ./module.nix;
|
||||||
|
};
|
||||||
|
}
|
||||||
152
module.nix
Normal file
152
module.nix
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
{ 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.";
|
||||||
|
};
|
||||||
|
|
||||||
|
honeypot = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
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.";
|
||||||
|
};
|
||||||
|
|
||||||
|
maxWebsiteLength = mkOption {
|
||||||
|
type = types.int;
|
||||||
|
default = 100;
|
||||||
|
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";
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false";
|
||||||
|
};
|
||||||
|
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
|
||||||
|
'';
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
123
src/config.rs
123
src/config.rs
|
|
@ -1,43 +1,124 @@
|
||||||
use serde::Deserialize;
|
use std::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub listen: String,
|
pub port: u16,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub site_title: String,
|
pub site_title: String,
|
||||||
pub site_url: String,
|
pub site_url: String,
|
||||||
pub telegram_bot_token: String,
|
pub telegram_bot_token: String,
|
||||||
pub telegram_chat_id: i64,
|
pub telegram_chat_id: i64,
|
||||||
|
pub honeypot: bool,
|
||||||
|
pub max_name_length: usize,
|
||||||
|
pub max_message_length: usize,
|
||||||
|
pub max_website_length: usize,
|
||||||
|
pub open_registration: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn listen_addr(&self) -> String {
|
||||||
let content = std::fs::read_to_string(path)?;
|
format!("127.0.0.1:{}", self.port)
|
||||||
let config: Config = toml::from_str(&content)?;
|
}
|
||||||
Ok(config)
|
|
||||||
|
pub fn from_env() -> Result<Self, String> {
|
||||||
|
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")?,
|
||||||
|
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")?,
|
||||||
|
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")?,
|
||||||
|
open_registration: env::var("BOOK_OPEN_REGISTRATION")
|
||||||
|
.map(|v| v != "false")
|
||||||
|
.unwrap_or(true),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_config() {
|
fn test_from_env() {
|
||||||
let toml_str = r#"
|
let _lock = ENV_LOCK.lock().unwrap();
|
||||||
listen = "127.0.0.1:8123"
|
env::set_var("BOOK_PORT", "9999");
|
||||||
data_dir = "/var/lib/guestbook"
|
env::set_var("BOOK_DATA_DIR", "/tmp/gb");
|
||||||
site_title = "ily.rs"
|
env::set_var("BOOK_SITE_TITLE", "test.rs");
|
||||||
site_url = "https://ily.rs"
|
env::set_var("BOOK_SITE_URL", "https://test.rs");
|
||||||
telegram_bot_token = "123:ABC"
|
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
|
||||||
telegram_chat_id = 12345
|
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
|
||||||
"#;
|
|
||||||
let config: Config = toml::from_str(toml_str).unwrap();
|
let config = Config::from_env().unwrap();
|
||||||
assert_eq!(config.listen, "127.0.0.1:8123");
|
assert_eq!(config.port, 9999);
|
||||||
assert_eq!(config.data_dir, PathBuf::from("/var/lib/guestbook"));
|
assert_eq!(config.listen_addr(), "127.0.0.1:9999");
|
||||||
assert_eq!(config.site_title, "ily.rs");
|
assert_eq!(config.data_dir, PathBuf::from("/tmp/gb"));
|
||||||
assert_eq!(config.site_url, "https://ily.rs");
|
assert_eq!(config.site_title, "test.rs");
|
||||||
|
assert_eq!(config.site_url, "https://test.rs");
|
||||||
assert_eq!(config.telegram_chat_id, 12345);
|
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() {
|
||||||
|
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");
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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");
|
||||||
|
|
||||||
|
let result = Config::from_env();
|
||||||
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
src/main.rs
25
src/main.rs
|
|
@ -1,22 +1,41 @@
|
||||||
mod config;
|
mod config;
|
||||||
mod entries;
|
mod entries;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod telegram;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use teloxide::prelude::*;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let config = config::Config::load("config.toml").expect("failed to load config.toml");
|
dotenvy::dotenv().ok();
|
||||||
let listen = config.listen.clone();
|
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);
|
||||||
|
|
||||||
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 state = Arc::new(web::AppState { config, tx });
|
||||||
let app = web::router(state);
|
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}");
|
tracing::info!("listening on {listen}");
|
||||||
let listener = tokio::net::TcpListener::bind(&listen).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&listen).await.unwrap();
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ pub fn render_page(site_title: &str, site_url: &str, entries: &[Entry], form_htm
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>guestbook - {site_title}</title>
|
<title>{site_title}</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
68
src/telegram.rs
Normal file
68
src/telegram.rs
Normal file
|
|
@ -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<Entry>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
191
src/web.rs
191
src/web.rs
|
|
@ -39,11 +39,12 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||||
async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
|
async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
|
||||||
let entries_dir = state.config.data_dir.join("entries");
|
let entries_dir = state.config.data_dir.join("entries");
|
||||||
let entries = entries::read_approved(&entries_dir);
|
let entries = entries::read_approved(&entries_dir);
|
||||||
|
let form = if state.config.open_registration { FORM_HTML } else { "" };
|
||||||
let html = render::render_page(
|
let html = render::render_page(
|
||||||
&state.config.site_title,
|
&state.config.site_title,
|
||||||
&state.config.site_url,
|
&state.config.site_url,
|
||||||
&entries,
|
&entries,
|
||||||
FORM_HTML,
|
form,
|
||||||
);
|
);
|
||||||
Html(html)
|
Html(html)
|
||||||
}
|
}
|
||||||
|
|
@ -52,8 +53,12 @@ async fn submit(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Form(form): Form<SubmitForm>,
|
Form(form): Form<SubmitForm>,
|
||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
|
if !state.config.open_registration {
|
||||||
|
return Html("Submissions are closed.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Honeypot check — silently discard
|
// 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());
|
return Html("Thanks! Your message is pending approval.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,14 +70,17 @@ async fn submit(
|
||||||
if name.is_empty() || message.is_empty() {
|
if name.is_empty() || message.is_empty() {
|
||||||
return Html("Name and message are required.".to_string());
|
return Html("Name and message are required.".to_string());
|
||||||
}
|
}
|
||||||
if name.len() > 50 {
|
let max_name = state.config.max_name_length;
|
||||||
return Html("Name is too long (max 50 chars).".to_string());
|
if max_name > 0 && name.len() > max_name {
|
||||||
|
return Html(format!("Name is too long (max {max_name} chars)."));
|
||||||
}
|
}
|
||||||
if website.len() > 100 {
|
let max_web = state.config.max_website_length;
|
||||||
return Html("Website is too long (max 100 chars).".to_string());
|
if max_web > 0 && website.len() > max_web {
|
||||||
|
return Html(format!("Website is too long (max {max_web} chars)."));
|
||||||
}
|
}
|
||||||
if message.len() > 1000 {
|
let max_msg = state.config.max_message_length;
|
||||||
return Html("Message is too long (max 1000 chars).".to_string());
|
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];
|
let short_id = &Uuid::new_v4().to_string()[..8];
|
||||||
|
|
@ -108,3 +116,170 @@ async fn submit(
|
||||||
async fn style() -> impl IntoResponse {
|
async fn style() -> impl IntoResponse {
|
||||||
([(header::CONTENT_TYPE, "text/css")], STYLE_CSS)
|
([(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<Entry>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue