1use std::env;
2use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3use std::path::PathBuf;
4use std::time::Duration;
5
6use anyhow::{Context, bail};
7
8use crate::mail::MailConfig;
9use crate::storage::StorageConfig;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum Environment {
15 Local,
17 Testing,
19 Production,
21}
22
23impl Environment {
24 fn parse(value: &str) -> anyhow::Result<Self> {
25 match value.to_ascii_lowercase().as_str() {
26 "local" | "dev" | "development" => Ok(Self::Local),
27 "testing" | "test" => Ok(Self::Testing),
28 "production" | "prod" => Ok(Self::Production),
29 other => bail!("APP_ENV must be local, testing or production, got `{other}`"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[non_exhaustive]
37pub enum CspMode {
38 #[default]
42 Relaxed,
43 Strict,
48 Off,
50}
51
52impl CspMode {
53 fn parse(value: &str) -> anyhow::Result<Self> {
54 match value.to_ascii_lowercase().as_str() {
55 "relaxed" | "" => Ok(Self::Relaxed),
56 "strict" => Ok(Self::Strict),
57 "off" | "false" | "none" => Ok(Self::Off),
58 other => bail!("CSP must be relaxed, strict or off, got `{other}`"),
59 }
60 }
61}
62
63setting_enum! {
64 pub enum SessionDriver ("SESSION_DRIVER") {
66 Cookie = "cookie",
68 Database = "database",
70 }
71}
72
73setting_enum! {
74 pub enum LogFormat ("LOG_FORMAT") {
76 Text = "text",
78 Json = "json",
80 }
81}
82
83setting_enum! {
84 pub enum CacheStore ("CACHE_STORE") {
86 Memory = "memory",
88 Database = "database",
90 }
91}
92
93#[derive(Clone)]
95#[non_exhaustive]
96pub struct Config {
97 pub name: String,
99 pub env: Environment,
101 pub debug: bool,
103 pub url: String,
105 pub key: Option<String>,
107 pub host: IpAddr,
109 pub port: u16,
111 pub views_path: PathBuf,
113 pub public_path: PathBuf,
115 pub session_lifetime: Duration,
118 pub session_cookie: String,
120 pub session_driver: SessionDriver,
124 pub remember_lifetime: Duration,
127 pub database_url: String,
129 pub database_pool_size: u32,
131 pub database_acquire_timeout: Duration,
134 pub database_statement_timeout: Option<Duration>,
137 pub request_timeout: Option<Duration>,
140 pub locale: String,
143 pub fallback_locale: String,
145 pub currency: String,
149 pub lang_path: PathBuf,
151 pub mail: MailConfig,
153 pub queue_workers: usize,
155 pub scheduler: bool,
157 pub log_format: LogFormat,
159 pub log_file: Option<PathBuf>,
161 pub timezone: crate::timezone::Zone,
165 pub cache_store: CacheStore,
167 pub storage_path: PathBuf,
169 pub storage: StorageConfig,
171 pub upload_max_size: usize,
173 pub csp: CspMode,
175 pub vars: std::collections::HashMap<String, String>,
178 pub analytics: AnalyticsConfig,
180 pub trusted_proxies: crate::TrustedProxies,
182 pub trusted_hosts: Vec<String>,
186}
187
188impl std::fmt::Debug for Config {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.debug_struct("Config")
192 .field("name", &self.name)
193 .field("env", &self.env)
194 .field("debug", &self.debug)
195 .field("url", &self.url)
196 .field("key", &self.key.as_ref().map(|_| "[hidden]"))
197 .field("host", &self.host)
198 .field("port", &self.port)
199 .field("views_path", &self.views_path)
200 .field("public_path", &self.public_path)
201 .field("session_lifetime", &self.session_lifetime)
202 .field("session_cookie", &self.session_cookie)
203 .field("session_driver", &self.session_driver)
204 .field("remember_lifetime", &self.remember_lifetime)
205 .field("database_url", &self.database_url)
206 .field("database_pool_size", &self.database_pool_size)
207 .field("database_acquire_timeout", &self.database_acquire_timeout)
208 .field(
209 "database_statement_timeout",
210 &self.database_statement_timeout,
211 )
212 .field("request_timeout", &self.request_timeout)
213 .field("locale", &self.locale)
214 .field("fallback_locale", &self.fallback_locale)
215 .field("currency", &self.currency)
216 .field("lang_path", &self.lang_path)
217 .field("mail", &self.mail)
218 .field("queue_workers", &self.queue_workers)
219 .field("scheduler", &self.scheduler)
220 .field("log_format", &self.log_format)
221 .field("log_file", &self.log_file)
222 .field("timezone", &self.timezone)
223 .field("cache_store", &self.cache_store)
224 .field("storage_path", &self.storage_path)
225 .field("storage", &self.storage)
226 .field("upload_max_size", &self.upload_max_size)
227 .field("csp", &self.csp)
228 .field(
229 "vars",
230 &self.vars.keys().collect::<std::collections::BTreeSet<_>>(),
231 )
232 .field("analytics", &self.analytics)
233 .field("trusted_proxies", &self.trusted_proxies)
234 .field("trusted_hosts", &self.trusted_hosts)
235 .finish()
236 }
237}
238
239#[derive(Clone, Default)]
243#[non_exhaustive]
244pub struct AnalyticsConfig {
245 pub google_site_verification: Option<String>,
247 pub ga4_measurement_id: Option<String>,
249 pub ga4_api_secret: Option<String>,
251 pub gtm_container_id: Option<String>,
253}
254
255impl std::fmt::Debug for AnalyticsConfig {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.debug_struct("AnalyticsConfig")
259 .field("google_site_verification", &self.google_site_verification)
260 .field("ga4_measurement_id", &self.ga4_measurement_id)
261 .field(
262 "ga4_api_secret",
263 &self.ga4_api_secret.as_ref().map(|_| "[hidden]"),
264 )
265 .field("gtm_container_id", &self.gtm_container_id)
266 .finish()
267 }
268}
269
270impl Config {
271 pub fn load() -> crate::Result<Self> {
273 let _ = dotenvy::dotenv();
274 Self::from_env()
275 }
276
277 pub fn from_env() -> crate::Result<Self> {
279 Self::from_vars(|name| env::var(name).ok())
280 }
281
282 pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> crate::Result<Self> {
286 Ok(Self::read(get)?)
287 }
288
289 fn read(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
290 let v = Vars(get);
291 let env = Environment::parse(&v.or("APP_ENV", "local"))?;
292 let debug = v.bool("APP_DEBUG", env == Environment::Local)?;
293 let host = v
294 .or("APP_HOST", "127.0.0.1")
295 .parse()
296 .context("APP_HOST must be an IP address")?;
297 let port = v
298 .or("APP_PORT", "3000")
299 .parse()
300 .context("APP_PORT must be a port number")?;
301 let key = v.get("APP_KEY").filter(|k| !k.is_empty());
302
303 if env == Environment::Production && key.is_none() {
304 bail!("APP_KEY must be set in production");
305 }
306
307 Ok(Self {
308 name: v.or("APP_NAME", "Renox"),
309 env,
310 debug,
311 url: v.or("APP_URL", &format!("http://{host}:{port}")),
312 key,
313 host,
314 port,
315 views_path: v.or("VIEWS_PATH", "resources/views").into(),
316 public_path: v.or("PUBLIC_PATH", "public").into(),
317 session_lifetime: Duration::from_secs(
318 v.or("SESSION_LIFETIME", "120")
319 .parse::<u64>()
320 .context("SESSION_LIFETIME must be a number of minutes")?
321 .saturating_mul(60),
322 ),
323 session_cookie: v.or("SESSION_COOKIE", "renox_session"),
324 session_driver: SessionDriver::parse(&v.or("SESSION_DRIVER", "cookie"))?,
325 remember_lifetime: Duration::from_secs(
326 v.or("REMEMBER_LIFETIME", "43200")
327 .parse::<u64>()
328 .context("REMEMBER_LIFETIME must be a number of minutes")?
329 .saturating_mul(60),
330 ),
331 database_url: v.or("DATABASE_URL", "sqlite://storage/app.db"),
332 database_pool_size: v
333 .or("DATABASE_POOL_SIZE", "8")
334 .parse()
335 .ok()
336 .filter(|n| *n > 0)
337 .context("DATABASE_POOL_SIZE must be a number above 0")?,
338 database_acquire_timeout: v
339 .seconds("DATABASE_ACQUIRE_TIMEOUT", 5)?
340 .unwrap_or(Duration::from_secs(5)),
341 database_statement_timeout: v.seconds("DATABASE_STATEMENT_TIMEOUT", 30)?,
342 request_timeout: v.seconds("REQUEST_TIMEOUT", 60)?,
343 locale: v.or("APP_LOCALE", "en"),
344 fallback_locale: v.or("APP_FALLBACK_LOCALE", "en"),
345 currency: match v.or("APP_CURRENCY", "USD").trim().to_ascii_uppercase() {
346 code if code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase()) => code,
347 other => {
348 bail!("APP_CURRENCY must be an ISO 4217 code like USD or EUR, got `{other}`")
349 }
350 },
351 lang_path: v.or("LANG_PATH", "resources/lang").into(),
352 mail: MailConfig {
353 mailer: crate::mail::MailDriver::parse(&v.or("MAIL_MAILER", "log"))?,
354 host: v.or("MAIL_HOST", "localhost"),
355 port: v
356 .get("MAIL_PORT")
357 .filter(|p| !p.is_empty())
358 .map(|p| p.parse())
359 .transpose()
360 .context("MAIL_PORT must be a port number")?,
361 username: v.get("MAIL_USERNAME").filter(|v| !v.is_empty()),
362 password: v.get("MAIL_PASSWORD").filter(|v| !v.is_empty()),
363 encryption: crate::mail::MailEncryption::parse(
364 &v.or("MAIL_ENCRYPTION", "starttls"),
365 )?,
366 from_address: v.or("MAIL_FROM_ADDRESS", "hello@example.com"),
367 from_name: v.get("MAIL_FROM_NAME").filter(|v| !v.is_empty()),
368 timeout: v
369 .seconds("MAIL_TIMEOUT", 10)?
370 .unwrap_or(Duration::from_secs(10)),
371 failover: v
372 .or("MAIL_FAILOVER", "")
373 .split(',')
374 .map(|name| name.trim().to_owned())
375 .filter(|name| !name.is_empty())
376 .collect(),
377 },
378 queue_workers: v
379 .or("QUEUE_WORKERS", "2")
380 .parse()
381 .context("QUEUE_WORKERS must be a number")?,
382 scheduler: v.bool("SCHEDULER", true)?,
383 log_format: LogFormat::parse(&v.or("LOG_FORMAT", "text"))?,
384 log_file: v
385 .get("LOG_FILE")
386 .filter(|p| !p.is_empty())
387 .map(PathBuf::from),
388 timezone: v
389 .or("APP_TIMEZONE", "UTC")
390 .parse()
391 .map_err(|err| anyhow::anyhow!("APP_TIMEZONE: {err}"))?,
392 cache_store: CacheStore::parse(&v.or("CACHE_STORE", "memory"))?,
393 storage_path: v.or("STORAGE_PATH", "storage").into(),
394 storage: StorageConfig {
395 disk: crate::storage::DiskDriver::parse(&v.or("STORAGE_DISK", "local"))?,
396 bucket: v.optional("S3_BUCKET"),
397 region: v.optional("S3_REGION"),
398 endpoint: v.optional("S3_ENDPOINT"),
399 access_key_id: v.optional("S3_ACCESS_KEY_ID"),
400 secret_access_key: v.optional("S3_SECRET_ACCESS_KEY"),
401 url: v.optional("STORAGE_URL"),
402 root: None,
403 },
404 upload_max_size: v
405 .or("UPLOAD_MAX_SIZE", "10")
406 .parse::<usize>()
407 .ok()
408 .and_then(|mb| mb.checked_mul(1024 * 1024))
409 .context("UPLOAD_MAX_SIZE must be a number of megabytes")?,
410 csp: CspMode::parse(&v.or("CSP", "relaxed"))?,
411 trusted_proxies: crate::TrustedProxies::read(&v.or("TRUSTED_PROXIES", ""))?,
412 trusted_hosts: v
413 .or("TRUSTED_HOSTS", "")
414 .split(',')
415 .map(|host| host.trim().to_ascii_lowercase())
416 .filter(|host| !host.is_empty())
417 .collect(),
418 vars: Default::default(),
419 analytics: AnalyticsConfig {
420 google_site_verification: v.optional("GOOGLE_SITE_VERIFICATION"),
421 ga4_measurement_id: v.optional("GA4_MEASUREMENT_ID"),
422 ga4_api_secret: v.optional("GA4_API_SECRET"),
423 gtm_container_id: v.optional("GTM_CONTAINER_ID"),
424 },
425 })
426 }
427
428 pub fn var(&self, name: &str) -> Option<String> {
431 self.vars
432 .get(name)
433 .cloned()
434 .or_else(|| env::var(name).ok())
435 .filter(|v| !v.is_empty())
436 }
437
438 pub fn addr(&self) -> SocketAddr {
440 SocketAddr::new(self.host, self.port)
441 }
442}
443
444impl Default for Config {
445 fn default() -> Self {
446 Self {
447 name: "Renox".into(),
448 env: Environment::Local,
449 debug: true,
450 url: "http://127.0.0.1:3000".into(),
451 key: None,
452 host: IpAddr::V4(Ipv4Addr::LOCALHOST),
453 port: 3000,
454 views_path: "resources/views".into(),
455 public_path: "public".into(),
456 session_lifetime: Duration::from_secs(120 * 60),
457 session_cookie: "renox_session".into(),
458 session_driver: SessionDriver::Cookie,
459 remember_lifetime: Duration::from_secs(43_200 * 60),
460 database_url: "sqlite::memory:".into(),
461 database_pool_size: 8,
462 database_acquire_timeout: Duration::from_secs(30),
465 database_statement_timeout: Some(Duration::from_secs(30)),
466 request_timeout: Some(Duration::from_secs(60)),
467 locale: "en".into(),
468 fallback_locale: "en".into(),
469 currency: "USD".into(),
470 lang_path: "resources/lang".into(),
471 mail: MailConfig {
472 mailer: crate::mail::MailDriver::Memory,
473 ..MailConfig::default()
474 },
475 queue_workers: 0,
476 scheduler: false,
477 log_format: LogFormat::Text,
478 log_file: None,
479 timezone: crate::timezone::Zone::default(),
480 cache_store: CacheStore::Memory,
481 storage_path: "storage".into(),
482 storage: StorageConfig::default(),
483 upload_max_size: 10 * 1024 * 1024,
484 csp: CspMode::Relaxed,
485 vars: Default::default(),
486 analytics: AnalyticsConfig::default(),
487 trusted_proxies: Default::default(),
488 trusted_hosts: Vec::new(),
489 }
490 }
491}
492
493struct Vars<F>(F);
495
496impl<F: Fn(&str) -> Option<String>> Vars<F> {
497 fn get(&self, name: &str) -> Option<String> {
498 (self.0)(name)
499 }
500
501 fn optional(&self, name: &str) -> Option<String> {
502 self.get(name).filter(|v| !v.is_empty())
503 }
504
505 fn or(&self, name: &str, default: &str) -> String {
506 self.get(name).unwrap_or_else(|| default.to_owned())
507 }
508
509 fn seconds(&self, name: &str, default: u64) -> anyhow::Result<Option<Duration>> {
511 let value: u64 = self
512 .or(name, &default.to_string())
513 .parse()
514 .with_context(|| format!("{name} must be a number of seconds"))?;
515 Ok((value > 0).then(|| Duration::from_secs(value)))
516 }
517
518 fn bool(&self, name: &str, default: bool) -> anyhow::Result<bool> {
519 match self.get(name) {
520 None => Ok(default),
521 Some(v) => match v.to_ascii_lowercase().as_str() {
522 "1" | "true" | "yes" | "on" => Ok(true),
523 "0" | "false" | "no" | "off" | "" => Ok(false),
524 other => bail!("{name} must be true or false, got `{other}`"),
525 },
526 }
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn parses_environment_aliases() {
536 assert_eq!(Environment::parse("local").unwrap(), Environment::Local);
537 assert_eq!(Environment::parse("DEV").unwrap(), Environment::Local);
538 assert_eq!(Environment::parse("test").unwrap(), Environment::Testing);
539 assert_eq!(Environment::parse("prod").unwrap(), Environment::Production);
540 assert!(Environment::parse("staging").is_err());
541 }
542
543 fn load(pairs: &[(&str, &str)]) -> anyhow::Result<Config> {
544 let vars: std::collections::HashMap<String, String> = pairs
545 .iter()
546 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
547 .collect();
548 Config::read(|name| vars.get(name).cloned())
549 }
550
551 #[test]
552 fn defaults_without_any_variable() {
553 let c = load(&[]).unwrap();
554 assert_eq!(c.env, Environment::Local);
555 assert!(c.debug, "local defaults to debug");
556 assert_eq!(
557 (c.port, c.database_pool_size, c.upload_max_size),
558 (3000, 8, 10 * 1024 * 1024)
559 );
560 assert_eq!(c.database_acquire_timeout, Duration::from_secs(5));
561 assert_eq!(c.request_timeout, Some(Duration::from_secs(60)));
562 assert_eq!(c.cache_store, CacheStore::Memory);
563 assert_eq!(c.csp, CspMode::Relaxed);
564 assert!(c.key.is_none() && c.mail.port.is_none());
565 }
566
567 #[test]
568 fn debug_hides_secrets() {
569 let mut c = load(&[("APP_KEY", "base64:c2VjcmV0"), ("MAIL_PASSWORD", "hunter2")]).unwrap();
570 c.vars
571 .insert("XENDIT_SECRET_KEY".into(), "xnd_live_123".into());
572 let shown = format!("{c:?}");
573 for secret in ["c2VjcmV0", "hunter2", "xnd_live_123"] {
574 assert!(!shown.contains(secret), "{secret} in {shown}");
575 }
576 assert!(
577 shown.contains("XENDIT_SECRET_KEY"),
578 "variable names stay: {shown}"
579 );
580 assert!(shown.contains("[hidden]"));
581 }
582
583 #[test]
584 fn session_driver() {
585 assert_eq!(load(&[]).unwrap().session_driver, SessionDriver::Cookie);
586 assert_eq!(
587 load(&[("SESSION_DRIVER", "database")])
588 .unwrap()
589 .session_driver,
590 SessionDriver::Database
591 );
592 assert!(load(&[("SESSION_DRIVER", "redis")]).is_err());
593 }
594
595 #[test]
596 fn log_format_and_file() {
597 let c = load(&[]).unwrap();
598 assert_eq!((c.log_format.as_str(), c.log_file), ("text", None));
599 let c = load(&[("LOG_FORMAT", "json"), ("LOG_FILE", "storage/logs/app.log")]).unwrap();
600 assert_eq!(c.log_format, LogFormat::Json);
601 assert_eq!(c.log_file, Some(PathBuf::from("storage/logs/app.log")));
602 let err = load(&[("LOG_FORMAT", "xml")]).unwrap_err();
603 assert!(err.to_string().contains("LOG_FORMAT"), "{err}");
604 }
605
606 #[test]
607 fn currency() {
608 assert_eq!(load(&[]).unwrap().currency, "USD");
609 assert_eq!(load(&[("APP_CURRENCY", " idr ")]).unwrap().currency, "IDR");
610 for bad in ["US", "rupiah", "U$D"] {
611 let err = load(&[("APP_CURRENCY", bad)]).unwrap_err();
612 assert!(err.to_string().contains("APP_CURRENCY"), "{err}");
613 }
614 }
615
616 #[test]
617 fn reads_every_kind_of_value() {
618 let c = load(&[
619 ("APP_ENV", "production"),
620 ("APP_KEY", "base64:abc"),
621 ("APP_DEBUG", "off"),
622 ("APP_PORT", "8080"),
623 ("DATABASE_POOL_SIZE", "3"),
624 ("REQUEST_TIMEOUT", "0"),
625 ("DATABASE_STATEMENT_TIMEOUT", "5"),
626 ("UPLOAD_MAX_SIZE", "2"),
627 ("MAIL_PORT", "2525"),
628 ("MAIL_USERNAME", ""),
629 ("MAIL_FAILOVER", "backup, log"),
630 ("CSP", "strict"),
631 ("TRUSTED_PROXIES", "10.0.0.0/8"),
632 ("TRUSTED_HOSTS", "shop.example.com, *.Example.org"),
633 ("S3_BUCKET", "files"),
634 ])
635 .unwrap();
636 assert_eq!(c.env, Environment::Production);
637 assert!(!c.debug);
638 assert_eq!((c.port, c.database_pool_size), (8080, 3));
639 assert_eq!(c.request_timeout, None, "0 means no limit");
640 assert_eq!(c.database_statement_timeout, Some(Duration::from_secs(5)));
641 assert_eq!(c.upload_max_size, 2 * 1024 * 1024);
642 assert_eq!(c.mail.port, Some(2525));
643 assert_eq!(c.mail.username, None, "empty means unset");
644 assert_eq!(c.mail.failover, ["backup", "log"]);
645 assert_eq!(c.csp, CspMode::Strict);
646 assert!(c.trusted_proxies.contains("10.1.2.3".parse().unwrap()));
647 assert_eq!(c.trusted_hosts, ["shop.example.com", "*.example.org"]);
648 assert_eq!(c.storage.bucket.as_deref(), Some("files"));
649 }
650
651 #[test]
652 fn refuses_bad_values_with_the_variable_name() {
653 for (name, value) in [
654 ("APP_ENV", "staging"),
655 ("APP_DEBUG", "maybe"),
656 ("APP_PORT", "http"),
657 ("APP_HOST", "localhost"),
658 ("DATABASE_POOL_SIZE", "0"),
659 ("REQUEST_TIMEOUT", "-1"),
660 ("UPLOAD_MAX_SIZE", "99999999999999999"),
661 ("MAIL_PORT", "99999"),
662 ("CSP", "loose"),
663 ("TRUSTED_PROXIES", "proxy.local"),
664 ("SESSION_DRIVER", "redis"),
665 ("CACHE_STORE", "redis"),
666 ("LOG_FORMAT", "xml"),
667 ("MAIL_MAILER", "sendgrid"),
668 ("MAIL_ENCRYPTION", "ssl3"),
669 ("STORAGE_DISK", "ftp"),
670 ("APP_TIMEZONE", "Mars/Olympus"),
671 ] {
672 let err = load(&[(name, value)])
673 .err()
674 .map(|e| format!("{e:#}"))
675 .unwrap_or_default();
676 assert!(
677 err.contains(name) || err.contains(value),
678 "{name}={value}: {err}"
679 );
680 }
681 let err = load(&[("APP_ENV", "production")]).unwrap_err();
682 assert!(
683 format!("{err}").contains("APP_KEY"),
684 "production needs a key"
685 );
686 }
687
688 #[test]
690 fn settings_print_and_serialize_as_their_word() {
691 assert_eq!(SessionDriver::Database.to_string(), "database");
692 assert_eq!(
693 serde_json::to_string(&CacheStore::Database).unwrap(),
694 r#""database""#
695 );
696 assert_eq!(format!("{}", LogFormat::Json), "json");
697 }
698}