use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
pub database: DatabaseSettings,
pub admin: AdminSettings,
pub tenancy: TenancySettings,
pub cache: CacheSettings,
pub jobs: JobsSettings,
pub mail: MailSettings,
pub server: ServerSettings,
pub auth: AuthSettings,
pub brand: BrandSettings,
pub security: SecuritySettings,
pub routes: RoutesSettings,
pub audit: AuditSettings,
pub logging: LoggingSettings,
pub i18n: I18nSettings,
pub mcp: McpSettings,
pub storage: StorageSettings,
pub sso: SsoSettings,
}
impl Settings {
#[cfg(any(test, feature = "manage"))]
pub(crate) fn inert_keys(&self) -> Vec<&'static str> {
let checks = [
(self.sso != SsoSettings::default(), "[sso]"),
(self.auth.jwt.issuer.is_some(), "[auth.jwt] issuer"),
(self.auth.jwt.audience.is_some(), "[auth.jwt] audience"),
(self.admin.primary_color.is_some(), "[admin] primary_color"),
(
self.admin.csrf_cookie_secure.is_some(),
"[admin] csrf_cookie_secure",
),
(
self.admin.session_timeout_minutes.is_some(),
"[admin] session_timeout_minutes",
),
];
checks
.into_iter()
.filter_map(|(set, key)| set.then_some(key))
.collect()
}
#[must_use]
pub fn detected_features() -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
macro_rules! feat {
($name:literal) => {
#[cfg(feature = $name)]
out.push($name);
};
}
feat!("postgres");
feat!("mysql");
feat!("sqlite");
feat!("tenancy");
feat!("admin");
feat!("manage");
feat!("config");
feat!("forms");
feat!("serializer");
feat!("cache");
feat!("signals");
feat!("email");
feat!("storage");
feat!("storage-s3");
feat!("storage-azure");
feat!("scheduler");
feat!("secrets");
feat!("totp");
feat!("webhook");
feat!("webhook-delivery");
feat!("api_keys");
feat!("passwords");
feat!("signed_url");
feat!("notifications");
feat!("jobs");
feat!("jobs-postgres");
feat!("auth_flows");
feat!("sse");
feat!("websocket");
feat!("oauth2");
feat!("http-client");
feat!("compression");
feat!("openapi");
feat!("csp-nonce");
feat!("sessions");
feat!("hmac-auth");
feat!("jwt");
feat!("uploads");
feat!("media");
feat!("runserver");
feat!("mcp");
out
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct McpSettings {
pub prefix: Option<String>,
pub token_ttl_secs: Option<i64>,
pub enable_sse: Option<bool>,
pub allowed_origins: Vec<String>,
pub rate_limit_per_minute: Option<u32>,
pub max_tools_listed: Option<usize>,
pub max_body_bytes: Option<usize>,
}
impl McpSettings {
#[must_use]
pub fn prefix(&self) -> &str {
self.prefix.as_deref().unwrap_or("/mcp")
}
#[must_use]
pub fn token_ttl_secs(&self) -> i64 {
self.token_ttl_secs.unwrap_or(900)
}
#[must_use]
pub fn sse_enabled(&self) -> bool {
self.enable_sse.unwrap_or(true)
}
#[must_use]
pub fn max_body_bytes(&self) -> usize {
self.max_body_bytes.unwrap_or(1024 * 1024)
}
#[must_use]
pub fn rate_limit(&self) -> Option<std::num::NonZeroU32> {
self.rate_limit_per_minute
.and_then(std::num::NonZeroU32::new)
}
}
#[derive(Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct SsoSettings {
pub enabled: Option<bool>,
pub provider: Option<String>,
pub issuer_url: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub redirect_uri: Option<String>,
}
impl SsoSettings {
#[must_use]
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
#[must_use]
pub fn provider(&self) -> Option<&str> {
self.provider.as_deref()
}
}
#[derive(Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct DatabaseSettings {
pub url: Option<String>,
pub backend: Option<String>,
pub pool_max_size: Option<u32>,
pub pool_min_size: Option<u32>,
pub pool_acquire_timeout_secs: Option<u64>,
pub pool_idle_timeout_secs: Option<u64>,
pub pool_max_lifetime_secs: Option<u64>,
}
impl DatabaseSettings {
#[must_use]
pub fn pool_tuning(&self) -> crate::sql::PoolTuning {
use std::time::Duration;
crate::sql::PoolTuning {
max_connections: self.pool_max_size,
min_connections: self.pool_min_size,
acquire_timeout: self.pool_acquire_timeout_secs.map(Duration::from_secs),
idle_timeout: self.pool_idle_timeout_secs.map(Duration::from_secs),
max_lifetime: self.pool_max_lifetime_secs.map(Duration::from_secs),
}
}
}
impl DatabaseSettings {
#[must_use]
pub fn resolved_backend(&self) -> Option<&'static str> {
if let Some(b) = self.backend.as_deref() {
return Some(canonicalize_backend(b));
}
let url = self.url.as_deref()?;
let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
match scheme.as_str() {
"postgres" | "postgresql" => Some("postgres"),
"mysql" | "mariadb" => Some("mysql"),
"sqlite" => Some("sqlite"),
_ => None,
}
}
}
fn canonicalize_backend(raw: &str) -> &'static str {
match raw.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pg" => "postgres",
"mysql" | "mariadb" => "mysql",
"sqlite" | "sqlite3" => "sqlite",
_ => "postgres", }
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct AdminSettings {
pub allowed_tables: Vec<String>,
pub read_only_tables: Vec<String>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub logo_url: Option<String>,
pub primary_color: Option<String>,
pub theme_mode: Option<String>,
pub url_prefix: Option<String>,
pub csrf_cookie_secure: Option<bool>,
pub session_timeout_minutes: Option<u32>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct TenancySettings {
pub apex_domain: Option<String>,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct CacheSettings {
pub backend: Option<String>,
pub redis_url: Option<String>,
pub file_cache_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct JobsSettings {
pub backend: Option<String>,
pub concurrency: Option<u32>,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct MailSettings {
pub backend: Option<String>,
pub smtp_host: Option<String>,
pub smtp_port: Option<u16>,
pub smtp_username: Option<String>,
pub smtp_password: Option<String>,
pub smtp_tls: Option<String>,
#[serde(default)]
pub smtp_timeout_secs: Option<u64>,
pub from_address: Option<String>,
#[serde(default)]
pub server_email: Option<String>,
#[serde(default)]
pub email_subject_prefix: Option<String>,
#[serde(default)]
pub admins: Vec<String>,
#[serde(default)]
pub managers: Vec<String>,
pub file_email_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
#[non_exhaustive]
pub struct StorageSettings {
pub default: Option<String>,
pub disks: std::collections::BTreeMap<String, DiskSettings>,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
#[non_exhaustive]
pub struct DiskSettings {
pub driver: String,
pub cdn: Option<String>,
pub root: Option<String>,
pub base_url: Option<String>,
pub endpoint: Option<String>,
pub public_endpoint: Option<String>,
pub bucket: Option<String>,
pub region: Option<String>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
pub path_style: Option<bool>,
pub account: Option<String>,
pub account_key: Option<String>,
pub container: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct ServerSettings {
pub bind: Option<String>,
pub request_timeout_secs: Option<u64>,
pub max_body_bytes: Option<u64>,
pub shutdown_timeout_secs: Option<u64>,
}
impl ServerSettings {
#[must_use]
pub fn drain_timeout(&self) -> std::time::Duration {
self.shutdown_timeout_secs.map_or(
crate::shutdown::DEFAULT_DRAIN_TIMEOUT,
std::time::Duration::from_secs,
)
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuthSettings {
pub jwt: JwtSettings,
pub argon2_memory_kib: Option<u32>,
pub argon2_iterations: Option<u32>,
pub argon2_parallelism: Option<u32>,
pub lockout_threshold: Option<u32>,
pub lockout_duration_secs: Option<u64>,
pub login_ip_limit: Option<u32>,
pub login_ip_window_secs: Option<u64>,
pub login_global_limit: Option<u32>,
pub login_global_window_secs: Option<u64>,
pub hash_wait_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct JwtSettings {
pub access_ttl_secs: Option<u64>,
pub refresh_ttl_secs: Option<u64>,
pub issuer: Option<String>,
pub audience: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct BrandSettings {
pub name: Option<String>,
pub tagline: Option<String>,
pub logo_url: Option<String>,
pub primary_color: Option<String>,
pub theme_mode: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct SecuritySettings {
pub headers_preset: Option<String>,
pub csp: Option<String>,
pub hsts_max_age_secs: Option<u64>,
pub cors_allowed_origins: Vec<String>,
pub allowed_hosts: Vec<String>,
pub csrf_trusted_origins: Vec<String>,
pub secure_ssl_redirect: Option<bool>,
pub secure_redirect_exempt: Vec<String>,
pub secure_proxy_ssl_header: Vec<String>,
pub secure_cookies: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct RoutesSettings {
pub legacy_preset: Option<bool>,
pub login_url: Option<String>,
pub logout_url: Option<String>,
pub admin_url: Option<String>,
pub audit_url: Option<String>,
pub static_url: Option<String>,
pub brand_url: Option<String>,
pub change_password_url: Option<String>,
pub impersonation_handoff_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuditSettings {
pub retention_days: Option<u32>,
pub redact_query_params: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
#[non_exhaustive]
pub struct LoggingSettings {
pub level: Option<String>,
pub format: Option<String>,
pub color: Option<String>,
pub with_thread_ids: Option<bool>,
pub with_line_numbers: Option<bool>,
pub without_targets: Option<bool>,
pub file_dir: Option<String>,
pub file_prefix: Option<String>,
pub file_rotation: Option<String>,
pub file_only: Option<bool>,
pub access_log: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct I18nSettings {
pub default_locale: Option<String>,
pub languages: Vec<String>,
pub locale_paths: Vec<String>,
pub fallback_chain: Vec<String>,
}
fn redacted(v: &Option<String>) -> Option<&'static str> {
v.as_ref().map(|_| "<redacted>")
}
fn redacted_url(v: &Option<String>) -> Option<String> {
v.as_deref().map(crate::sql::connect_diagnosis::redact)
}
impl std::fmt::Debug for SsoSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SsoSettings")
.field("enabled", &self.enabled)
.field("provider", &self.provider)
.field("issuer_url", &self.issuer_url)
.field("client_id", &self.client_id)
.field("client_secret", &redacted(&self.client_secret))
.field("redirect_uri", &self.redirect_uri)
.finish()
}
}
impl std::fmt::Debug for DatabaseSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DatabaseSettings")
.field("url", &redacted_url(&self.url))
.field("backend", &self.backend)
.field("pool_max_size", &self.pool_max_size)
.field("pool_min_size", &self.pool_min_size)
.field("pool_acquire_timeout_secs", &self.pool_acquire_timeout_secs)
.field("pool_idle_timeout_secs", &self.pool_idle_timeout_secs)
.field("pool_max_lifetime_secs", &self.pool_max_lifetime_secs)
.finish()
}
}
impl std::fmt::Debug for CacheSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CacheSettings")
.field("backend", &self.backend)
.field("redis_url", &redacted_url(&self.redis_url))
.field("file_cache_dir", &self.file_cache_dir)
.finish()
}
}
impl std::fmt::Debug for MailSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailSettings")
.field("backend", &self.backend)
.field("smtp_host", &self.smtp_host)
.field("smtp_port", &self.smtp_port)
.field("smtp_username", &self.smtp_username)
.field("smtp_password", &redacted(&self.smtp_password))
.field("smtp_tls", &self.smtp_tls)
.field("smtp_timeout_secs", &self.smtp_timeout_secs)
.field("from_address", &self.from_address)
.field("server_email", &self.server_email)
.field("email_subject_prefix", &self.email_subject_prefix)
.field("admins", &self.admins)
.field("managers", &self.managers)
.field("file_email_dir", &self.file_email_dir)
.finish()
}
}
impl std::fmt::Debug for DiskSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiskSettings")
.field("driver", &self.driver)
.field("cdn", &self.cdn)
.field("root", &self.root)
.field("base_url", &self.base_url)
.field("endpoint", &self.endpoint)
.field("public_endpoint", &self.public_endpoint)
.field("bucket", &self.bucket)
.field("region", &self.region)
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &redacted(&self.secret_access_key))
.field("path_style", &self.path_style)
.field("account", &self.account)
.field("account_key", &redacted(&self.account_key))
.field("container", &self.container)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_debug_redacts_every_secret() {
let mut s = Settings::default();
s.database.url = Some("postgres://app:DBSECRET@db/app?password=QSECRET".into());
s.sso.client_secret = Some("SSOSECRET".into());
s.cache.redis_url = Some("redis://:REDISSECRET@cache:6379/0".into());
s.mail.smtp_password = Some("SMTPSECRET".into());
let dbg = format!("{s:?} {s:#?}");
for secret in [
"DBSECRET",
"QSECRET",
"SSOSECRET",
"REDISSECRET",
"SMTPSECRET",
] {
assert!(!dbg.contains(secret), "{secret} in {dbg}");
}
assert!(
dbg.contains("db/app") && dbg.contains("cache:6379"),
"{dbg}"
);
}
#[test]
fn resolved_backend_uses_explicit_value() {
let mut s = DatabaseSettings::default();
s.backend = Some("mysql".into());
s.url = Some("postgres://x".into()); assert_eq!(s.resolved_backend(), Some("mysql"));
}
#[test]
fn resolved_backend_canonicalizes_aliases() {
let s = DatabaseSettings {
backend: Some("postgresql".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("postgres"));
let s = DatabaseSettings {
backend: Some("mariadb".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("mysql"));
}
#[test]
fn resolved_backend_sniffs_from_url_scheme() {
let s = DatabaseSettings {
url: Some("sqlite::memory:".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("sqlite"));
let s = DatabaseSettings {
url: Some("mysql://root@localhost/x".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("mysql"));
let s = DatabaseSettings {
url: Some("postgresql://x".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("postgres"));
}
#[test]
fn resolved_backend_none_when_neither_set() {
let s = DatabaseSettings::default();
assert_eq!(s.resolved_backend(), None);
}
#[test]
fn inert_keys_lists_the_settings_nothing_reads() {
let mut s = Settings::default();
assert!(s.inert_keys().is_empty());
s.sso.client_secret = Some("x".into());
s.auth.jwt.issuer = Some("x".into());
s.auth.jwt.audience = Some("x".into());
s.admin.primary_color = Some("#fff".into());
s.admin.csrf_cookie_secure = Some(true);
s.admin.session_timeout_minutes = Some(30);
assert_eq!(s.inert_keys().len(), 6, "{:?}", s.inert_keys());
}
#[test]
fn admin_settings_extended_fields_default_to_none() {
let s = AdminSettings::default();
assert!(s.title.is_none());
assert!(s.subtitle.is_none());
assert!(s.logo_url.is_none());
assert!(s.primary_color.is_none());
assert!(s.theme_mode.is_none());
assert!(s.url_prefix.is_none());
assert!(s.csrf_cookie_secure.is_none());
assert!(s.session_timeout_minutes.is_none());
assert!(s.allowed_tables.is_empty());
assert!(s.read_only_tables.is_empty());
}
#[test]
fn admin_settings_parses_full_section() {
let toml = r##"
title = "Acme Admin"
subtitle = "Tenant management"
logo_url = "/assets/acme.png"
primary_color = "#2c6fb0"
theme_mode = "dark"
url_prefix = "/admin"
csrf_cookie_secure = true
session_timeout_minutes = 30
allowed_tables = ["post", "author"]
read_only_tables = ["audit_log"]
"##;
let parsed: AdminSettings = toml::from_str(toml).expect("valid TOML");
assert_eq!(parsed.title.as_deref(), Some("Acme Admin"));
assert_eq!(parsed.subtitle.as_deref(), Some("Tenant management"));
assert_eq!(parsed.logo_url.as_deref(), Some("/assets/acme.png"));
assert_eq!(parsed.primary_color.as_deref(), Some("#2c6fb0"));
assert_eq!(parsed.theme_mode.as_deref(), Some("dark"));
assert_eq!(parsed.url_prefix.as_deref(), Some("/admin"));
assert_eq!(parsed.csrf_cookie_secure, Some(true));
assert_eq!(parsed.session_timeout_minutes, Some(30));
assert_eq!(parsed.allowed_tables, vec!["post", "author"]);
assert_eq!(parsed.read_only_tables, vec!["audit_log"]);
}
}