#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RegistrationMode {
Open,
Closed,
}
#[derive(Clone, Debug)]
pub struct ServeConfig {
pub token: String,
pub bind: String,
pub db_path: String,
pub base_url: String,
pub max_size: usize,
pub reaper_interval: u64,
pub default_theme: String,
pub webhook_url: Option<String>,
pub webhook_secret: Option<String>,
pub rate_limit_read: u32,
pub rate_limit_write: u32,
pub rate_limit_window: u64,
pub registration_limit: u32,
pub registration_mode: RegistrationMode,
}
impl ServeConfig {
pub fn from_env() -> Result<Self, String> {
let token = std::env::var("TWOFOLD_TOKEN").map_err(|_| {
"TWOFOLD_TOKEN is required but not set. \
Set it to a secret bearer token before starting the server."
.to_string()
})?;
if token.is_empty() {
return Err("TWOFOLD_TOKEN must not be empty.".to_string());
}
let bind = std::env::var("TWOFOLD_BIND").unwrap_or_else(|_| "127.0.0.1:3000".to_string());
let db_path =
std::env::var("TWOFOLD_DB_PATH").unwrap_or_else(|_| "./twofold.db".to_string());
let base_url = std::env::var("TWOFOLD_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string());
url::Url::parse(&base_url)
.map_err(|e| format!("TWOFOLD_BASE_URL is not a valid URL (got '{base_url}'): {e}"))?;
let max_size = match std::env::var("TWOFOLD_MAX_SIZE") {
Ok(s) => s
.parse::<usize>()
.map_err(|_| format!("TWOFOLD_MAX_SIZE must be a positive integer, got: {s}"))?,
Err(_) => 1_048_576,
};
let reaper_interval = match std::env::var("TWOFOLD_REAPER_INTERVAL") {
Ok(s) => s.parse::<u64>().map_err(|_| {
format!("TWOFOLD_REAPER_INTERVAL must be a positive integer, got: {s}")
})?,
Err(_) => 60,
};
let default_theme =
std::env::var("TWOFOLD_DEFAULT_THEME").unwrap_or_else(|_| "clean".to_string());
let webhook_url = std::env::var("TWOFOLD_WEBHOOK_URL").ok().and_then(|s| {
if s.is_empty() {
None
} else {
Some(s)
}
});
if let Some(ref u) = webhook_url {
url::Url::parse(u)
.map_err(|e| format!("TWOFOLD_WEBHOOK_URL is not a valid URL (got '{u}'): {e}"))?;
}
let webhook_secret = std::env::var("TWOFOLD_WEBHOOK_SECRET").ok().and_then(|s| {
if s.is_empty() {
None
} else {
Some(s)
}
});
let rate_limit_read = match std::env::var("TWOFOLD_RATE_LIMIT_READ") {
Ok(s) => s.parse::<u32>().map_err(|_| {
format!("TWOFOLD_RATE_LIMIT_READ must be a positive integer, got: {s}")
})?,
Err(_) => 60,
};
let rate_limit_write = match std::env::var("TWOFOLD_RATE_LIMIT_WRITE") {
Ok(s) => s.parse::<u32>().map_err(|_| {
format!("TWOFOLD_RATE_LIMIT_WRITE must be a positive integer, got: {s}")
})?,
Err(_) => 30,
};
let rate_limit_window = match std::env::var("TWOFOLD_RATE_LIMIT_WINDOW") {
Ok(s) => s.parse::<u64>().map_err(|_| {
format!("TWOFOLD_RATE_LIMIT_WINDOW must be a positive integer, got: {s}")
})?,
Err(_) => 60,
};
let registration_limit = match std::env::var("TWOFOLD_REGISTRATION_LIMIT") {
Ok(s) => s.parse::<u32>().map_err(|_| {
format!("TWOFOLD_REGISTRATION_LIMIT must be a positive integer, got: {s}")
})?,
Err(_) => 5,
};
let registration_mode = match std::env::var("TWOFOLD_REGISTRATION_MODE")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"closed" => RegistrationMode::Closed,
_ => RegistrationMode::Open,
};
Ok(ServeConfig {
token,
bind,
db_path,
base_url,
max_size,
reaper_interval,
default_theme,
webhook_url,
webhook_secret,
rate_limit_read,
rate_limit_write,
rate_limit_window,
registration_limit,
registration_mode,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn clear_twofold_env() {
for key in &[
"TWOFOLD_TOKEN",
"TWOFOLD_BIND",
"TWOFOLD_DB_PATH",
"TWOFOLD_BASE_URL",
"TWOFOLD_MAX_SIZE",
"TWOFOLD_REAPER_INTERVAL",
"TWOFOLD_DEFAULT_THEME",
"TWOFOLD_WEBHOOK_URL",
"TWOFOLD_WEBHOOK_SECRET",
"TWOFOLD_RATE_LIMIT_READ",
"TWOFOLD_RATE_LIMIT_WRITE",
"TWOFOLD_RATE_LIMIT_WINDOW",
"TWOFOLD_REGISTRATION_LIMIT",
"TWOFOLD_REGISTRATION_MODE",
] {
std::env::remove_var(key);
}
}
#[test]
fn from_env_missing_token_errors() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
let result = ServeConfig::from_env();
assert!(
result.is_err(),
"expected error when TWOFOLD_TOKEN is absent"
);
let msg = result.unwrap_err();
assert!(
msg.contains("TWOFOLD_TOKEN"),
"error message should mention TWOFOLD_TOKEN, got: {msg}"
);
}
#[test]
fn from_env_defaults() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "test-secret");
let cfg = ServeConfig::from_env().expect("from_env should succeed with token set");
assert_eq!(cfg.token, "test-secret");
assert_eq!(cfg.bind, "127.0.0.1:3000");
assert_eq!(cfg.db_path, "./twofold.db");
assert_eq!(cfg.base_url, "http://localhost:3000");
assert_eq!(cfg.max_size, 1_048_576);
assert_eq!(cfg.reaper_interval, 60);
assert_eq!(cfg.default_theme, "clean");
assert!(cfg.webhook_url.is_none());
assert!(cfg.webhook_secret.is_none());
assert_eq!(cfg.rate_limit_read, 60);
assert_eq!(cfg.rate_limit_write, 30);
assert_eq!(cfg.rate_limit_window, 60);
assert_eq!(cfg.registration_limit, 5);
assert_eq!(cfg.registration_mode, RegistrationMode::Open);
}
#[test]
fn from_env_bad_url_errors() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "test-secret");
std::env::set_var("TWOFOLD_WEBHOOK_URL", "not-a-valid-url");
let result = ServeConfig::from_env();
assert!(
result.is_err(),
"expected error for invalid TWOFOLD_WEBHOOK_URL"
);
let msg = result.unwrap_err();
assert!(
msg.contains("TWOFOLD_WEBHOOK_URL"),
"error message should mention TWOFOLD_WEBHOOK_URL, got: {msg}"
);
}
#[test]
fn from_env_rate_limit_read() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_RATE_LIMIT_READ", "42");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.rate_limit_read, 42,
"TWOFOLD_RATE_LIMIT_READ should be 42"
);
}
#[test]
fn from_env_rate_limit_write() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_RATE_LIMIT_WRITE", "15");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.rate_limit_write, 15,
"TWOFOLD_RATE_LIMIT_WRITE should be 15"
);
}
#[test]
fn from_env_rate_limit_window() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_RATE_LIMIT_WINDOW", "120");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.rate_limit_window, 120,
"TWOFOLD_RATE_LIMIT_WINDOW should be 120"
);
}
#[test]
fn from_env_registration_limit() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_REGISTRATION_LIMIT", "10");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.registration_limit, 10,
"TWOFOLD_REGISTRATION_LIMIT should be 10"
);
}
#[test]
fn from_env_registration_mode_closed() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_REGISTRATION_MODE", "closed");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.registration_mode,
RegistrationMode::Closed,
"TWOFOLD_REGISTRATION_MODE=closed should set Closed"
);
}
#[test]
fn from_env_registration_mode_default_open() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.registration_mode,
RegistrationMode::Open,
"unset TWOFOLD_REGISTRATION_MODE should default to Open"
);
}
#[test]
fn from_env_max_size() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_MAX_SIZE", "2097152");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.max_size, 2_097_152,
"TWOFOLD_MAX_SIZE should be 2097152"
);
}
#[test]
fn from_env_reaper_interval() {
let _guard = ENV_LOCK.lock().unwrap();
clear_twofold_env();
std::env::set_var("TWOFOLD_TOKEN", "tok");
std::env::set_var("TWOFOLD_REAPER_INTERVAL", "300");
let cfg = ServeConfig::from_env().expect("should parse");
assert_eq!(
cfg.reaper_interval, 300,
"TWOFOLD_REAPER_INTERVAL should be 300"
);
}
}