You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

212 lines
7.2 KiB
Rust

//! Application configuration loaded from environment variables.
//!
//! All required variables are validated at startup. The application refuses
//! to start with invalid configuration rather than failing at runtime.
use std::env;
use std::sync::Arc;
/// Typed application configuration.
#[derive(Debug, Clone)]
pub struct AppConfig {
// Database
pub database_url: String,
// Security
pub master_encryption_key: Arc<String>,
// Application
pub app_url: String,
pub port: u16,
pub static_dir: String,
// Email (Resend)
pub resend_api_key: String,
pub email_from: String,
// Captcha (Cloudflare Turnstile)
pub turnstile_secret_key: String,
pub turnstile_site_key: String,
}
impl AppConfig {
/// Load configuration from environment variables.
///
/// Uses `dotenvy` (called before this) to load `.env` if present.
/// Fails fast with a clear error message if a required variable is missing.
pub fn from_env() -> Result<Self, String> {
let database_url = required_var("DATABASE_URL")?;
let master_encryption_key = required_var("MASTER_ENCRYPTION_KEY")?;
let app_url = required_var("APP_URL")?;
let resend_api_key = required_var("RESEND_API_KEY")?;
let email_from = required_var("EMAIL_FROM")?;
let turnstile_secret_key = required_var("TURNSTILE_SECRET_KEY")?;
let turnstile_site_key = required_var("TURNSTILE_SITE_KEY")?;
let port = env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse::<u16>()
.map_err(|_| "PORT must be a valid u16".to_string())?;
let static_dir = env::var("STATIC_DIR")
.unwrap_or_else(|_| "../frontend/dist".to_string());
Ok(Self {
database_url,
master_encryption_key: Arc::new(master_encryption_key),
app_url,
port,
static_dir,
resend_api_key,
email_from,
turnstile_secret_key,
turnstile_site_key,
})
}
/// Validate that configuration values meet minimum requirements.
///
/// Called after loading to ensure secrets are properly formatted
/// and the APP_URL has a valid scheme.
pub fn validate(&self) -> Result<(), String> {
if self.master_encryption_key.len() != 64
|| !self.master_encryption_key.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(
"MASTER_ENCRYPTION_KEY must be exactly 64 hex characters (256-bit key)".into(),
);
}
if !self.app_url.starts_with("http://") && !self.app_url.starts_with("https://") {
return Err("APP_URL must start with http:// or https://".into());
}
if self.app_url.ends_with('/') {
return Err("APP_URL must not end with a trailing slash".into());
}
Ok(())
}
/// Returns true if the application is running in a secure (HTTPS) context.
pub fn is_secure(&self) -> bool {
self.app_url.starts_with("https://")
}
}
/// Read a required environment variable, returning a clear error if missing.
fn required_var(name: &str) -> Result<String, String> {
env::var(name).map_err(|_| format!("Missing required environment variable: {name}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_valid_config() {
let config = AppConfig {
database_url: "postgres://user:pass@localhost/db".into(),
master_encryption_key: Arc::new("ab".repeat(32)),
app_url: "https://synth.example.com".into(),
port: 8080,
static_dir: "./static".into(),
resend_api_key: "re_test".into(),
email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(),
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_bad_encryption_key_length() {
let config = AppConfig {
database_url: "postgres://user:pass@localhost/db".into(),
master_encryption_key: Arc::new("abcd".into()), // too short
app_url: "https://synth.example.com".into(),
port: 8080,
static_dir: "./static".into(),
resend_api_key: "re_test".into(),
email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(),
};
let err = config.validate().unwrap_err();
assert!(err.contains("MASTER_ENCRYPTION_KEY"));
}
#[test]
fn test_validate_bad_encryption_key_not_hex() {
let config = AppConfig {
database_url: "postgres://user:pass@localhost/db".into(),
master_encryption_key: Arc::new("zz".repeat(32)), // not hex
app_url: "https://synth.example.com".into(),
port: 8080,
static_dir: "./static".into(),
resend_api_key: "re_test".into(),
email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(),
};
let err = config.validate().unwrap_err();
assert!(err.contains("MASTER_ENCRYPTION_KEY"));
}
#[test]
fn test_validate_bad_app_url_scheme() {
let config = AppConfig {
database_url: "postgres://user:pass@localhost/db".into(),
master_encryption_key: Arc::new("ab".repeat(32)),
app_url: "ftp://synth.example.com".into(),
port: 8080,
static_dir: "./static".into(),
resend_api_key: "re_test".into(),
email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(),
};
let err = config.validate().unwrap_err();
assert!(err.contains("APP_URL"));
}
#[test]
fn test_validate_trailing_slash() {
let config = AppConfig {
database_url: "postgres://user:pass@localhost/db".into(),
master_encryption_key: Arc::new("ab".repeat(32)),
app_url: "https://synth.example.com/".into(),
port: 8080,
static_dir: "./static".into(),
resend_api_key: "re_test".into(),
email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(),
};
let err = config.validate().unwrap_err();
assert!(err.contains("trailing slash"));
}
#[test]
fn test_is_secure() {
let config = AppConfig {
database_url: "postgres://user:pass@localhost/db".into(),
master_encryption_key: Arc::new("ab".repeat(32)),
app_url: "https://synth.example.com".into(),
port: 8080,
static_dir: "./static".into(),
resend_api_key: "re_test".into(),
email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(),
};
assert!(config.is_secure());
let config_http = AppConfig {
app_url: "http://localhost:3000".into(),
..config
};
assert!(!config_http.is_secure());
}
}