434 lines
18 KiB
Rust
434 lines
18 KiB
Rust
use lettre::message::Mailbox;
|
|
use std::{collections::HashMap, env, net::IpAddr, path::Path};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AuthorizationType {
|
|
Local,
|
|
Ldap,
|
|
Ad,
|
|
}
|
|
|
|
impl AuthorizationType {
|
|
fn from_values(values: &ConfigValues) -> Result<Self, Box<dyn std::error::Error>> {
|
|
match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() {
|
|
"local" => Ok(Self::Local),
|
|
"ldap" => Ok(Self::Ldap),
|
|
"ad" => Ok(Self::Ad),
|
|
_ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()),
|
|
}
|
|
}
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Local => "local",
|
|
Self::Ldap => "ldap",
|
|
Self::Ad => "ad",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Config {
|
|
pub host: IpAddr,
|
|
pub port: u16,
|
|
pub database_url: String,
|
|
pub database_max_connections: u32,
|
|
pub static_dir: String,
|
|
pub files_dir: String,
|
|
pub storage: crate::storage::StorageConfig,
|
|
pub upload_max_size_bytes: usize,
|
|
pub asset_version: String,
|
|
pub asset_cache_max_age_seconds: u64,
|
|
pub file_cache_max_age_seconds: u64,
|
|
pub smtp: Option<crate::state::SmtpConfig>,
|
|
pub registration_enabled: bool,
|
|
pub account_confirmation_required: bool,
|
|
pub share_confirmation_required: bool,
|
|
pub frontend_log_level: String,
|
|
pub anonymous_access_token_ttl_days: i64,
|
|
pub user_session_ttl_days: i64,
|
|
pub unconfirmed_account_ttl_days: i64,
|
|
pub authorization_type: AuthorizationType,
|
|
pub ldap: Option<crate::auth::ldap::LdapConfig>,
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let values = ConfigValues::load(path)?;
|
|
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
|
|
let port = values.get("APP_PORT", "3000").parse()?;
|
|
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
|
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
|
let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
|
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
|
|
let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
|
let files_dir = values.get("FILES_DIR", "data/files");
|
|
|
|
let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() {
|
|
"local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() },
|
|
"s3" => crate::storage::StorageConfig::S3 {
|
|
endpoint: values.optional("S3_ENDPOINT"),
|
|
region: values.get("S3_REGION", "us-east-1"),
|
|
bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?,
|
|
access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?,
|
|
secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?,
|
|
force_path_style: values.bool("S3_FORCE_PATH_STYLE", false)?,
|
|
},
|
|
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
|
|
};
|
|
|
|
if upload_max_size_mb == 0 {
|
|
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
|
|
}
|
|
|
|
let authorization_type = AuthorizationType::from_values(&values)?;
|
|
let ldap = match authorization_type {
|
|
AuthorizationType::Local => None,
|
|
AuthorizationType::Ldap | AuthorizationType::Ad => {
|
|
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
|
|
let (default_filter, default_username_attribute) = match authorization_type {
|
|
AuthorizationType::Ldap => ("(uid={username})", "uid"),
|
|
AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"),
|
|
AuthorizationType::Local => unreachable!(),
|
|
};
|
|
Some(crate::auth::ldap::LdapConfig {
|
|
url: values.required("LDAP_URL", &context)?,
|
|
starttls: values.bool("LDAP_STARTTLS", false)?,
|
|
bind_dn: values.get("LDAP_BIND_DN", ""),
|
|
bind_password: values.get("LDAP_BIND_PASSWORD", ""),
|
|
base_dn: values.required("LDAP_BASE_DN", &context)?,
|
|
user_filter: values.get("LDAP_USER_FILTER", default_filter),
|
|
username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
|
|
email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
|
display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
|
external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type {
|
|
AuthorizationType::Ldap => "entryUUID",
|
|
AuthorizationType::Ad => "objectGUID",
|
|
AuthorizationType::Local => unreachable!(),
|
|
}),
|
|
organization: values.get("LDAP_ORGANIZATION", "organization"),
|
|
provider: authorization_type.as_str().to_owned(),
|
|
email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?,
|
|
link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
|
|
tls_verify: values.bool("LDAP_TLS_VERIFY", true)?,
|
|
connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
|
operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
|
|
})
|
|
}
|
|
};
|
|
|
|
let smtp = if let Some(host) = values.optional("SMTP_HOST") {
|
|
Some(crate::state::SmtpConfig {
|
|
host,
|
|
port: values.get("SMTP_PORT", "587").parse()?,
|
|
username: values.get("SMTP_USERNAME", ""),
|
|
password: values.get("SMTP_PASSWORD", ""),
|
|
from: normalize_smtp_from(
|
|
values.required("SMTP_FROM", "SMTP_HOST is set")?,
|
|
)?,
|
|
public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let config = Self {
|
|
host,
|
|
port,
|
|
database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
|
|
database_max_connections,
|
|
static_dir: values.get("STATIC_DIR", "static"),
|
|
files_dir,
|
|
storage,
|
|
upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
|
|
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
|
asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
|
file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
|
smtp,
|
|
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
|
|
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
|
share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?,
|
|
frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
|
anonymous_access_token_ttl_days,
|
|
user_session_ttl_days,
|
|
unconfirmed_account_ttl_days,
|
|
authorization_type,
|
|
ldap,
|
|
};
|
|
config.validate()?;
|
|
Ok(config)
|
|
}
|
|
|
|
fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
if !(self.database_url.starts_with("sqlite:")
|
|
|| self.database_url.starts_with("postgres:")
|
|
|| self.database_url.starts_with("postgresql:")
|
|
|| self.database_url.starts_with("mysql:"))
|
|
{
|
|
return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into());
|
|
}
|
|
if self.database_max_connections == 0 {
|
|
return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into());
|
|
}
|
|
if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() {
|
|
return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
|
|
}
|
|
if let Some(smtp) = &self.smtp {
|
|
smtp.from
|
|
.parse::<Mailbox>()
|
|
.map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?;
|
|
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
|
|
return Err("PUBLIC_URL must start with http:// or https://".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Error>> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("SMTP_FROM cannot be empty".into());
|
|
}
|
|
|
|
if trimmed.parse::<Mailbox>().is_ok() {
|
|
return Ok(trimmed.to_owned());
|
|
}
|
|
|
|
let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) {
|
|
(Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => {
|
|
trimmed[1..trimmed.len() - 1].trim()
|
|
}
|
|
_ => {
|
|
return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into());
|
|
}
|
|
};
|
|
|
|
if unquoted.is_empty() {
|
|
return Err("SMTP_FROM cannot be empty".into());
|
|
}
|
|
|
|
unquoted
|
|
.parse::<Mailbox>()
|
|
.map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?;
|
|
|
|
Ok(unquoted.to_owned())
|
|
}
|
|
|
|
const KNOWN_CONFIG_KEYS: &[&str] = &[
|
|
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
|
|
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
|
|
"ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS",
|
|
"REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED",
|
|
"FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS",
|
|
"UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE",
|
|
"S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
|
|
"S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD",
|
|
"SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN",
|
|
"LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE",
|
|
"LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE",
|
|
"LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL",
|
|
"LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS",
|
|
];
|
|
|
|
#[derive(Default)]
|
|
struct ConfigValues {
|
|
file: HashMap<String, String>,
|
|
}
|
|
|
|
impl ConfigValues {
|
|
fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let Some(path) = path else { return Ok(Self::default()); };
|
|
let content = std::fs::read_to_string(path)
|
|
.map_err(|error| format!("cannot read config file {}: {error}", path.display()))?;
|
|
let file = parse_yaml_config(&content)
|
|
.map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?;
|
|
for key in file.keys() {
|
|
if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) {
|
|
return Err(format!("unknown configuration key: {key}").into());
|
|
}
|
|
}
|
|
Ok(Self { file })
|
|
}
|
|
|
|
fn get(&self, name: &str, default: &str) -> String {
|
|
env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned())
|
|
}
|
|
|
|
fn optional(&self, name: &str) -> Option<String> {
|
|
env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty())
|
|
}
|
|
|
|
fn required(&self, name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
|
|
}
|
|
|
|
fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
|
match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() {
|
|
"1" | "true" | "yes" | "on" => Ok(true),
|
|
"0" | "false" | "no" | "off" => Ok(false),
|
|
_ => Err(format!("{name} must be true or false").into()),
|
|
}
|
|
}
|
|
|
|
fn log_level(&self, name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
let value = self.get(name, default).trim().to_ascii_lowercase();
|
|
match value.as_str() {
|
|
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
|
|
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
|
|
}
|
|
}
|
|
|
|
fn positive_i64(&self, name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
|
|
let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?;
|
|
if value <= 0 { return Err(format!("{name} must be greater than 0").into()); }
|
|
Ok(value)
|
|
}
|
|
|
|
fn positive_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
|
let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?;
|
|
if value == 0 { return Err(format!("{name} must be greater than 0").into()); }
|
|
Ok(value)
|
|
}
|
|
|
|
fn nonnegative_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
|
self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into())
|
|
}
|
|
}
|
|
|
|
fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
|
|
let mut output = HashMap::new();
|
|
let mut sections: Vec<(usize, String)> = Vec::new();
|
|
|
|
for (index, original) in content.lines().enumerate() {
|
|
let line_number = index + 1;
|
|
if original.contains('\t') {
|
|
return Err(format!("line {line_number}: tabs are not allowed for indentation"));
|
|
}
|
|
let without_comment = strip_yaml_comment(original);
|
|
if without_comment.trim().is_empty() || without_comment.trim() == "---" {
|
|
continue;
|
|
}
|
|
let indent = without_comment.len() - without_comment.trim_start().len();
|
|
let line = without_comment.trim();
|
|
let (raw_key, raw_value) = line
|
|
.split_once(':')
|
|
.ok_or_else(|| format!("line {line_number}: expected key: value"))?;
|
|
let key = raw_key.trim();
|
|
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
|
|
return Err(format!("line {line_number}: invalid key {key:?}"));
|
|
}
|
|
while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) {
|
|
sections.pop();
|
|
}
|
|
let normalized = key.to_ascii_uppercase().replace('-', "_");
|
|
let value = raw_value.trim();
|
|
if value.is_empty() {
|
|
sections.push((indent, normalized));
|
|
continue;
|
|
}
|
|
if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) {
|
|
return Err(format!("line {line_number}: only scalar values and nested mappings are supported"));
|
|
}
|
|
let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect();
|
|
path.push(&normalized);
|
|
let full_key = path.join("_");
|
|
let parsed_value = parse_yaml_scalar(value)
|
|
.map_err(|error| format!("line {line_number}: {error}"))?;
|
|
if output.insert(full_key.clone(), parsed_value).is_some() {
|
|
return Err(format!("line {line_number}: duplicate key {full_key}"));
|
|
}
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
fn strip_yaml_comment(line: &str) -> &str {
|
|
let mut single = false;
|
|
let mut double = false;
|
|
let mut escaped = false;
|
|
for (index, character) in line.char_indices() {
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
match character {
|
|
'\\' if double => escaped = true,
|
|
'\'' if !double => single = !single,
|
|
'"' if !single => double = !double,
|
|
'#' if !single && !double => return &line[..index],
|
|
_ => {}
|
|
}
|
|
}
|
|
line
|
|
}
|
|
|
|
fn parse_yaml_scalar(value: &str) -> Result<String, String> {
|
|
if value.starts_with('"') {
|
|
if !value.ends_with('"') || value.len() < 2 {
|
|
return Err("unterminated double-quoted value".to_owned());
|
|
}
|
|
return serde_json::from_str::<String>(value)
|
|
.map_err(|error| format!("invalid double-quoted value: {error}"));
|
|
}
|
|
if value.starts_with('\'') {
|
|
if !value.ends_with('\'') || value.len() < 2 {
|
|
return Err("unterminated single-quoted value".to_owned());
|
|
}
|
|
return Ok(value[1..value.len() - 1].replace("''", "'"));
|
|
}
|
|
if value.eq_ignore_ascii_case("null") || value == "~" {
|
|
return Ok(String::new());
|
|
}
|
|
Ok(value.to_owned())
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::normalize_smtp_from;
|
|
|
|
#[test]
|
|
fn smtp_from_accepts_unquoted_value() {
|
|
assert_eq!(
|
|
normalize_smtp_from("RustPad <rustpad@notes.example>".to_owned()).unwrap(),
|
|
"RustPad <rustpad@notes.example>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smtp_from_removes_matching_double_quotes() {
|
|
assert_eq!(
|
|
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
|
|
"RustPad <rustpad@notes.example>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smtp_from_removes_matching_single_quotes() {
|
|
assert_eq!(
|
|
normalize_smtp_from(" 'RustPad <rustpad@notes.example>' ".to_owned()).unwrap(),
|
|
"RustPad <rustpad@notes.example>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smtp_from_preserves_valid_quoted_display_name() {
|
|
assert_eq!(
|
|
normalize_smtp_from("\"Rust, Pad\" <rustpad@notes.example>".to_owned()).unwrap(),
|
|
"\"Rust, Pad\" <rustpad@notes.example>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smtp_from_rejects_unmatched_quotes() {
|
|
assert!(normalize_smtp_from("\"RustPad <rustpad@notes.example>".to_owned()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn smtp_from_rejects_invalid_mailbox() {
|
|
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
|
|
}
|
|
}
|