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}