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.
242 lines
8.3 KiB
Rust
242 lines
8.3 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;
|
|
|
|
/// Typed application configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppConfig {
|
|
// Database
|
|
pub database_url: String,
|
|
|
|
// Security
|
|
pub session_secret: String,
|
|
pub master_encryption_key: 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 session_secret = required_var("SESSION_SECRET")?;
|
|
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,
|
|
session_secret,
|
|
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.session_secret.len() < 64 {
|
|
return Err("SESSION_SECRET must be at least 64 characters".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(),
|
|
session_secret: "a".repeat(64),
|
|
master_encryption_key: "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(),
|
|
session_secret: "a".repeat(64),
|
|
master_encryption_key: "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(),
|
|
session_secret: "a".repeat(64),
|
|
master_encryption_key: "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_short_session_secret() {
|
|
let config = AppConfig {
|
|
database_url: "postgres://user:pass@localhost/db".into(),
|
|
session_secret: "short".into(),
|
|
master_encryption_key: "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("SESSION_SECRET"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_bad_app_url_scheme() {
|
|
let config = AppConfig {
|
|
database_url: "postgres://user:pass@localhost/db".into(),
|
|
session_secret: "a".repeat(64),
|
|
master_encryption_key: "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(),
|
|
session_secret: "a".repeat(64),
|
|
master_encryption_key: "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(),
|
|
session_secret: "a".repeat(64),
|
|
master_encryption_key: "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());
|
|
}
|
|
}
|