use std::{collections::BTreeMap, ops::Deref, path::Path, sync::Arc}; use regex::Regex; use serde::{Deserialize, Serialize}; use crate::storage::CompressionAlg; #[derive(Clone, Default)] pub struct Config { i: Arc, } #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ConfigInner { pub server: ServerCfg, pub compression: CompressionCfg, pub keys: BTreeMap, } #[derive(thiserror::Error, Debug)] pub enum ConfigError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("parsing error: {0}")] Parse(#[from] toml::de::Error), } type Result = std::result::Result; impl Deref for Config { type Target = ConfigInner; fn deref(&self) -> &Self::Target { &self.i } } impl Serialize for Config { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { ConfigInner::serialize(self, serializer) } } impl<'de> Deserialize<'de> for Config { fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { ConfigInner::deserialize(deserializer).map(|c| Self { i: c.into() }) } } impl Config { pub fn new(cfg: ConfigInner) -> Self { Self { i: cfg.into() } } /// Read the configuration from the given file /// or create a default config at the given location pub fn from_file>(path: P) -> Result { let path = path.as_ref(); if path.is_file() { let cfg_str = std::fs::read_to_string(path)?; Ok(toml::from_str::(&cfg_str)?) } else { let cfg = Self::default(); if let Ok(cfg_str) = toml::to_string_pretty(&cfg) { std::fs::write(path, cfg_str) .unwrap_or_else(|e| log::error!("Could not write default config file: {e}")); } Ok(cfg) } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ServerCfg { /// Address to bind the server to /// /// Default: `0.0.0.0:3000` pub address: String, /// Root domain (and port if non-standard) under which Talon is available /// /// Default: `localhost:3000` pub root_domain: String, /// Subdomain used for Talon internals (API, assets) /// /// Default: `talon` pub internal_subdomain: String, /// URL under which the internals are available /// /// Default: `http://talon.localhost:3000` pub internal_url: String, /// Interval in minutes between file storage purges /// /// Default: 1440 (24h) pub purge_interval: u32, } impl Default for ServerCfg { fn default() -> Self { Self { address: "0.0.0.0:3000".to_owned(), root_domain: "localhost:3000".to_owned(), internal_subdomain: "talon".to_owned(), internal_url: "http://talon.localhost:3000".to_owned(), purge_interval: 1440, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct CompressionCfg { /// Enable gzip compression pub gzip_en: bool, /// Gzip compression level (0-9) pub gzip_level: u8, /// Enable brotli compression pub brotli_en: bool, /// Brozli compression level (0-11) pub brotli_level: u8, } impl Default for CompressionCfg { fn default() -> Self { Self { gzip_en: true, gzip_level: 6, brotli_en: true, brotli_level: 7, } } } impl CompressionCfg { pub fn enabled(&self) -> bool { self.gzip_en || self.brotli_en } pub fn algs(&self) -> Vec { let mut res = vec![CompressionAlg::None]; if self.gzip_en { res.push(CompressionAlg::Gzip) } if self.brotli_en { res.push(CompressionAlg::Brotli) } res } } #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(default)] pub struct KeyCfg { #[serde(skip_serializing_if = "Domains::is_none")] pub domains: Domains, pub upload: bool, pub modify: bool, } #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum Domains { #[default] None, Single(String), Multiple(Vec), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Access { /// Dont modify anything Read, /// Update a new website version Upload, /// Create, update or delete websites Modify, } impl Domains { fn is_none(&self) -> bool { matches!(self, Domains::None) } fn pattern_matches_domain(pattern: &str, domain: &str) -> bool { if pattern == "*" { true } else if pattern.starts_with('/') && pattern.ends_with('/') { let regex_str = &pattern[1..pattern.len() - 1]; let re = match Regex::new(regex_str) { Ok(re) => re, Err(e) => { log::error!("could not parse regex `{regex_str}`, error: {e}"); return false; } }; re.is_match(domain) } else { domain == pattern } } pub fn matches_domain(&self, domain: &str) -> bool { match self { Domains::None => false, Domains::Single(pattern) => Self::pattern_matches_domain(pattern, domain), Domains::Multiple(patterns) => patterns .iter() .any(|pattern| Self::pattern_matches_domain(pattern, domain)), } } } impl KeyCfg { pub fn allows(&self, access: Access) -> bool { match access { Access::Read => true, Access::Upload => self.upload, Access::Modify => self.modify, } } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; #[rstest] #[case("*", "hello-world", true)] #[case("hello-world", "hello-world", true)] #[case("hello-world", "hello-world2", false)] #[case("/^talon-\\d+/", "talon-1", true)] #[case("/^talon-\\d+/", "talon-x", false)] fn pattern_matches_domain(#[case] pattern: &str, #[case] domain: &str, #[case] expect: bool) { assert_eq!(Domains::pattern_matches_domain(pattern, domain), expect); } }