use figment::{
providers::{Env, Format, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use crate::oauth2::types::OAuthConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HtmxSettings {
pub request_timeout_ms: u64,
pub history_enabled: bool,
pub auto_vary: bool,
pub guards_enabled: bool,
}
impl Default for HtmxSettings {
fn default() -> Self {
Self {
request_timeout_ms: 5000,
history_enabled: true,
auto_vary: true,
guards_enabled: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TemplateSettings {
pub template_dir: PathBuf,
pub cache_enabled: bool,
pub hot_reload: bool,
pub watch_extensions: Vec<String>,
}
impl Default for TemplateSettings {
fn default() -> Self {
Self {
template_dir: PathBuf::from("./templates"),
cache_enabled: true,
hot_reload: cfg!(debug_assertions),
watch_extensions: vec!["html".to_string(), "jinja".to_string()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecuritySettings {
pub csrf_enabled: bool,
pub session_max_age_secs: u64,
pub secure_cookies: bool,
pub same_site: SameSitePolicy,
pub security_headers_enabled: bool,
pub rate_limit: RateLimitConfig,
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
csrf_enabled: true,
session_max_age_secs: 86400, secure_cookies: !cfg!(debug_assertions),
same_site: SameSitePolicy::Lax,
security_headers_enabled: true,
rate_limit: RateLimitConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SameSitePolicy {
Strict,
Lax,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitConfig {
pub enabled: bool,
pub per_user_rpm: u32,
pub per_ip_rpm: u32,
pub per_route_rpm: u32,
pub window_secs: u64,
pub redis_enabled: bool,
pub failure_mode: RateLimitFailureMode,
pub strict_routes: Vec<String>,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
per_user_rpm: 120,
per_ip_rpm: 60,
per_route_rpm: 30,
window_secs: 60,
redis_enabled: cfg!(feature = "redis"),
failure_mode: RateLimitFailureMode::default(),
strict_routes: vec![
"/login".to_string(),
"/register".to_string(),
"/password-reset".to_string(),
],
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RateLimitFailureMode {
Closed,
Open,
}
impl Default for RateLimitFailureMode {
fn default() -> Self {
if cfg!(debug_assertions) {
Self::Open
} else {
Self::Closed
}
}
}
#[cfg(feature = "cedar")]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FailureMode {
Closed,
Open,
}
impl Default for FailureMode {
fn default() -> Self {
if cfg!(debug_assertions) {
Self::Open
} else {
Self::Closed
}
}
}
#[cfg(feature = "cedar")]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CedarConfig {
pub enabled: bool,
pub policy_path: PathBuf,
pub hot_reload: bool,
pub hot_reload_interval_secs: u64,
pub cache_enabled: bool,
pub cache_ttl_secs: u64,
pub failure_mode: FailureMode,
}
#[cfg(feature = "cedar")]
impl Default for CedarConfig {
fn default() -> Self {
Self {
enabled: false, policy_path: PathBuf::from("policies/app.cedar"),
hot_reload: false,
hot_reload_interval_secs: 60,
cache_enabled: true,
cache_ttl_secs: 300,
failure_mode: FailureMode::default(), }
}
}
#[cfg(feature = "cedar")]
impl CedarConfig {
#[must_use]
pub const fn hot_reload_interval(&self) -> Duration {
Duration::from_secs(self.hot_reload_interval_secs)
}
#[must_use]
pub const fn cache_ttl(&self) -> Duration {
Duration::from_secs(self.cache_ttl_secs)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActonHtmxConfig {
#[serde(default)]
pub htmx: HtmxSettings,
#[serde(default)]
pub templates: TemplateSettings,
#[serde(default)]
pub security: SecuritySettings,
#[serde(default)]
pub oauth2: OAuthConfig,
#[cfg(feature = "cedar")]
#[serde(default)]
pub cedar: Option<CedarConfig>,
#[serde(default)]
pub features: HashMap<String, bool>,
}
impl ActonHtmxConfig {
pub fn load_for_service(service_name: &str) -> anyhow::Result<Self> {
let mut figment = Figment::new()
.merge(Toml::string(&toml::to_string(&Self::default())?));
let system_config = PathBuf::from("/etc/acton-htmx")
.join(service_name)
.join("config.toml");
if system_config.exists() {
figment = figment.merge(Toml::file(&system_config));
}
let user_config = Self::recommended_path(service_name);
if user_config.exists() {
figment = figment.merge(Toml::file(&user_config));
}
let local_config = PathBuf::from("./config.toml");
if local_config.exists() {
figment = figment.merge(Toml::file(&local_config));
}
figment = figment.merge(Env::prefixed("ACTON_").split("__").lowercase(true));
let config = figment.extract()?;
Ok(config)
}
pub fn load_from(path: &str) -> anyhow::Result<Self> {
let config = Figment::new()
.merge(Toml::string(&toml::to_string(&Self::default())?))
.merge(Toml::file(path))
.merge(Env::prefixed("ACTON_").split("__").lowercase(true))
.extract()?;
Ok(config)
}
#[must_use]
pub fn recommended_path(service_name: &str) -> PathBuf {
dirs::config_dir().map_or_else(
|| PathBuf::from("./config.toml"),
|config_dir| {
config_dir
.join("acton-htmx")
.join(service_name)
.join("config.toml")
},
)
}
pub fn create_config_dir(service_name: &str) -> anyhow::Result<PathBuf> {
let config_path = Self::recommended_path(service_name);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(config_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ActonHtmxConfig::default();
assert_eq!(config.htmx.request_timeout_ms, 5000);
assert!(config.htmx.history_enabled);
assert!(config.htmx.auto_vary);
assert!(config.security.csrf_enabled);
assert_eq!(config.security.session_max_age_secs, 86400);
}
#[test]
fn test_template_defaults() {
let templates = TemplateSettings::default();
assert_eq!(templates.template_dir, PathBuf::from("./templates"));
assert!(templates.cache_enabled);
assert_eq!(templates.watch_extensions, vec!["html", "jinja"]);
}
#[test]
fn test_security_defaults() {
let security = SecuritySettings::default();
assert!(security.csrf_enabled);
assert!(security.security_headers_enabled);
#[cfg(debug_assertions)]
assert!(!security.secure_cookies);
#[cfg(not(debug_assertions))]
assert!(security.secure_cookies);
}
#[test]
fn test_recommended_path() {
let path = ActonHtmxConfig::recommended_path("test-app");
assert!(path.to_str().unwrap().contains("test-app"));
assert!(path.to_str().unwrap().ends_with("config.toml"));
assert!(path.to_str().unwrap().contains("acton-htmx"));
}
#[test]
fn test_load_from_nonexistent_file() {
use std::env;
env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
env::remove_var("ACTON_HTMX__HISTORY_ENABLED");
let result = ActonHtmxConfig::load_from("/nonexistent/path/config.toml");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.htmx.request_timeout_ms, 5000);
}
#[test]
fn test_load_from_toml_file() {
use std::env;
use std::fs;
use std::io::Write;
env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
env::remove_var("ACTON_HTMX__HISTORY_ENABLED");
let temp_dir = std::env::temp_dir();
let config_path = temp_dir.join("test_config.toml");
let toml_content = r"
[htmx]
request_timeout_ms = 10000
history_enabled = false
[security]
csrf_enabled = false
session_max_age_secs = 3600
";
let mut file = fs::File::create(&config_path).unwrap();
file.write_all(toml_content.as_bytes()).unwrap();
let result = ActonHtmxConfig::load_from(config_path.to_str().unwrap());
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.htmx.request_timeout_ms, 10000);
assert!(!config.htmx.history_enabled);
assert!(!config.security.csrf_enabled);
assert_eq!(config.security.session_max_age_secs, 3600);
fs::remove_file(config_path).ok();
}
#[test]
fn test_load_for_service_with_defaults() {
use std::env;
env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
env::remove_var("ACTON_HTMX__HISTORY_ENABLED");
let result = ActonHtmxConfig::load_for_service("nonexistent-service-123");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.htmx.request_timeout_ms, 5000);
assert!(config.htmx.history_enabled);
}
#[test]
fn test_create_config_dir() {
use std::fs;
let temp_service = format!("test-service-{}", std::process::id());
let result = ActonHtmxConfig::create_config_dir(&temp_service);
assert!(result.is_ok());
let config_path = result.unwrap();
if let Some(parent) = config_path.parent() {
assert!(parent.exists() || !config_path.to_str().unwrap().starts_with('/'));
}
if let Some(parent) = config_path.parent() {
if parent.exists() {
fs::remove_dir_all(parent).ok();
}
}
}
}