Skip to main content

renox_core/
config.rs

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/// The environment the application runs in, from `APP_ENV`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum Environment {
15    /// `local` (also `dev`, `development`), the default.
16    Local,
17    /// `testing` (also `test`).
18    Testing,
19    /// `production` (also `prod`); requires `APP_KEY`.
20    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/// How strict the Content-Security-Policy header is, from `CSP`.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[non_exhaustive]
37pub enum CspMode {
38    /// Scripts from this site, inline scripts and `eval` (which Alpine.js's
39    /// standard build needs) are allowed; other sites' scripts, framing by
40    /// other sites and plugins are not.
41    #[default]
42    Relaxed,
43    /// Scripts only from this site or with `nonce="{{ csp_nonce() }}"`, no
44    /// `eval`: Renox switches to Alpine's CSP build (expressions are limited
45    /// to properties and methods; put logic in `Alpine.data(...)`) and turns
46    /// off htmx's `eval`.
47    Strict,
48    /// No Content-Security-Policy header.
49    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
63/// The `Permissions-Policy` header, from `PERMISSIONS_POLICY`.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65#[non_exhaustive]
66pub enum PermissionsMode {
67    /// Switches off the features a typical app never uses (camera,
68    /// microphone, geolocation, payment, USB…); `App::permissions_policy`
69    /// allows some back.
70    #[default]
71    Default,
72    /// No `Permissions-Policy` header.
73    Off,
74    /// This header value as the base, e.g. `geolocation=(self), camera=()`.
75    Custom(String),
76}
77
78impl PermissionsMode {
79    fn parse(value: &str) -> anyhow::Result<Self> {
80        let value = value.trim();
81        match value.to_ascii_lowercase().as_str() {
82            "default" | "" => Ok(Self::Default),
83            "off" | "false" | "none" => Ok(Self::Off),
84            _ => {
85                crate::security::parse_permissions_policy(value)
86                    .with_context(|| format!("PERMISSIONS_POLICY is not usable: `{value}`"))?;
87                Ok(Self::Custom(value.to_owned()))
88            }
89        }
90    }
91}
92
93setting_enum! {
94    /// Where sessions live, from `SESSION_DRIVER`.
95    pub enum SessionDriver ("SESSION_DRIVER") {
96        /// The whole session in an encrypted cookie (the default).
97        Cookie = "cookie",
98        /// An id in the cookie, the session in the `sessions` table.
99        Database = "database",
100    }
101}
102
103setting_enum! {
104    /// How log lines are written, from `LOG_FORMAT`.
105    pub enum LogFormat ("LOG_FORMAT") {
106        /// Readable lines (the default).
107        Text = "text",
108        /// One JSON object per line, for a log service.
109        Json = "json",
110    }
111}
112
113setting_enum! {
114    /// Where the cache keeps its values, from `CACHE_STORE`.
115    pub enum CacheStore ("CACHE_STORE") {
116        /// In this process's memory (the default).
117        Memory = "memory",
118        /// In the `cache` table, shared by every server (throttles and the login lock too).
119        Database = "database",
120    }
121}
122
123/// Application configuration, read from the process environment and `.env`.
124#[derive(Clone)]
125#[non_exhaustive]
126pub struct Config {
127    /// The application's name, from `APP_NAME` (Renox).
128    pub name: String,
129    /// The environment, from `APP_ENV` (local).
130    pub env: Environment,
131    /// Show error details and backtraces in responses. Never enable in production.
132    pub debug: bool,
133    /// The public base URL, from `APP_URL` (`http://{host}:{port}`).
134    pub url: String,
135    /// Secret used for signing and encryption. Required in production.
136    pub key: Option<String>,
137    /// The IP address to listen on, from `APP_HOST` (127.0.0.1).
138    pub host: IpAddr,
139    /// The port to listen on, from `APP_PORT` (3000).
140    pub port: u16,
141    /// Where templates live, from `VIEWS_PATH`.
142    pub views_path: PathBuf,
143    /// Files served as-is at the site root, from `PUBLIC_PATH`.
144    pub public_path: PathBuf,
145    /// Inactivity before a session expires, from `SESSION_LIFETIME` in
146    /// minutes (default 120).
147    pub session_lifetime: Duration,
148    /// Name of the session cookie, from `SESSION_COOKIE`.
149    pub session_cookie: String,
150    /// Where sessions live, from `SESSION_DRIVER`: `cookie` (default: the
151    /// whole session in an encrypted cookie) or `database` (the cookie holds
152    /// an id; the `sessions` table holds the rest).
153    pub session_driver: SessionDriver,
154    /// How long a "remember me" login lasts, from `REMEMBER_LIFETIME` in
155    /// minutes (default 43200: 30 days).
156    pub remember_lifetime: Duration,
157    /// SQLite database, from `DATABASE_URL`, e.g. `sqlite://storage/app.db`.
158    pub database_url: String,
159    /// Maximum open connections, from `DATABASE_POOL_SIZE`.
160    pub database_pool_size: u32,
161    /// How long a query waits for a free connection before failing, from
162    /// `DATABASE_ACQUIRE_TIMEOUT` in seconds (default 5).
163    pub database_acquire_timeout: Duration,
164    /// Longest a PostgreSQL statement may run, from `DATABASE_STATEMENT_TIMEOUT`
165    /// in seconds (default 30; 0 for none).
166    pub database_statement_timeout: Option<Duration>,
167    /// Longest a request may take to answer, from `REQUEST_TIMEOUT` in
168    /// seconds (default 60; 0 for none). Streaming a response isn't counted.
169    pub request_timeout: Option<Duration>,
170    /// Default language of the app, from `APP_LOCALE`. Renox's own texts are
171    /// English; other locales need a lang file (`resources/lang/es.json`).
172    pub locale: String,
173    /// Language used for keys missing in the request's locale, from `APP_FALLBACK_LOCALE`.
174    pub fallback_locale: String,
175    /// The currency of the `money` template filter, an ISO 4217 code, from
176    /// `APP_CURRENCY` (default `USD`): `USD` shows `$75.00`, `EUR`
177    /// `€75.00`, `IDR` `Rp 75,000`, with the page's separators.
178    pub currency: String,
179    /// Where translation files live, from `LANG_PATH`.
180    pub lang_path: PathBuf,
181    /// Mail settings, from `MAIL_*`.
182    pub mail: MailConfig,
183    /// Queue workers `serve` runs in-process, from `QUEUE_WORKERS` (0 turns them off).
184    pub queue_workers: usize,
185    /// Whether `serve` runs scheduled tasks, from `SCHEDULER`.
186    pub scheduler: bool,
187    /// `text` (default) or `json` (one object per line), from `LOG_FORMAT`.
188    pub log_format: LogFormat,
189    /// Where logs go instead of stdout, from `LOG_FILE` (appended to).
190    pub log_file: Option<PathBuf>,
191    /// The zone of scheduled times and the `date` filter, from
192    /// `APP_TIMEZONE`: an IANA name (`Asia/Jakarta`), an offset (`+07:00`) or
193    /// `UTC`. See [`crate::timezone::Zone`].
194    pub timezone: crate::timezone::Zone,
195    /// `memory` or `database`, from `CACHE_STORE`.
196    pub cache_store: CacheStore,
197    /// Where the app keeps runtime files (maintenance flag, uploads), from `STORAGE_PATH`.
198    pub storage_path: PathBuf,
199    /// File storage, from `STORAGE_DISK`, `S3_*` and `STORAGE_URL`.
200    pub storage: StorageConfig,
201    /// Largest request body in bytes, from `UPLOAD_MAX_SIZE` in megabytes (default 10).
202    pub upload_max_size: usize,
203    /// Largest live component snapshot in bytes, from `LIVE_SNAPSHOT_MAX_SIZE` in kilobytes (default 64).
204    pub live_snapshot_max: usize,
205    /// The Content-Security-Policy, from `CSP` (`relaxed`, `strict` or `off`).
206    pub csp: CspMode,
207    /// The Permissions-Policy, from `PERMISSIONS_POLICY` (`default`, `off` or a
208    /// full header value such as `geolocation=(self), camera=()`).
209    pub permissions_policy: PermissionsMode,
210    /// Values `var()` returns before looking at the environment, e.g. a
211    /// webhook secret set in a test with `TestApp::with_config`.
212    pub vars: std::collections::HashMap<String, String>,
213    /// Search engines and analytics; used only in production.
214    pub analytics: AnalyticsConfig,
215    /// Reverse proxies whose `X-Forwarded-For` is believed, from `TRUSTED_PROXIES`.
216    pub trusted_proxies: crate::TrustedProxies,
217    /// Host names the app answers, from `TRUSTED_HOSTS` (comma-separated;
218    /// `*.example.com` for subdomains). Empty: any host. When set, `APP_URL`'s
219    /// host is allowed too, and other hosts get a 400.
220    pub trusted_hosts: Vec<String>,
221}
222
223impl std::fmt::Debug for Config {
224    // Secrets show as `[hidden]`, so a logged config doesn't leak them.
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("Config")
227            .field("name", &self.name)
228            .field("env", &self.env)
229            .field("debug", &self.debug)
230            .field("url", &self.url)
231            .field("key", &self.key.as_ref().map(|_| "[hidden]"))
232            .field("host", &self.host)
233            .field("port", &self.port)
234            .field("views_path", &self.views_path)
235            .field("public_path", &self.public_path)
236            .field("permissions_policy", &self.permissions_policy)
237            .field("session_lifetime", &self.session_lifetime)
238            .field("session_cookie", &self.session_cookie)
239            .field("session_driver", &self.session_driver)
240            .field("remember_lifetime", &self.remember_lifetime)
241            .field("database_url", &self.database_url)
242            .field("database_pool_size", &self.database_pool_size)
243            .field("database_acquire_timeout", &self.database_acquire_timeout)
244            .field(
245                "database_statement_timeout",
246                &self.database_statement_timeout,
247            )
248            .field("request_timeout", &self.request_timeout)
249            .field("locale", &self.locale)
250            .field("fallback_locale", &self.fallback_locale)
251            .field("currency", &self.currency)
252            .field("lang_path", &self.lang_path)
253            .field("mail", &self.mail)
254            .field("queue_workers", &self.queue_workers)
255            .field("scheduler", &self.scheduler)
256            .field("log_format", &self.log_format)
257            .field("log_file", &self.log_file)
258            .field("timezone", &self.timezone)
259            .field("cache_store", &self.cache_store)
260            .field("storage_path", &self.storage_path)
261            .field("storage", &self.storage)
262            .field("upload_max_size", &self.upload_max_size)
263            .field("live_snapshot_max", &self.live_snapshot_max)
264            .field("csp", &self.csp)
265            .field(
266                "vars",
267                &self.vars.keys().collect::<std::collections::BTreeSet<_>>(),
268            )
269            .field("analytics", &self.analytics)
270            .field("trusted_proxies", &self.trusted_proxies)
271            .field("trusted_hosts", &self.trusted_hosts)
272            .finish()
273    }
274}
275
276/// Google Search Console, Google Analytics 4 and Google Tag Manager, from
277/// `GOOGLE_SITE_VERIFICATION`, `GA4_MEASUREMENT_ID`, `GA4_API_SECRET` and
278/// `GTM_CONTAINER_ID`. Tags are added to pages only in production.
279#[derive(Clone, Default)]
280#[non_exhaustive]
281pub struct AnalyticsConfig {
282    /// The `content` of Search Console's `google-site-verification` meta tag.
283    pub google_site_verification: Option<String>,
284    /// e.g. `G-XXXXXXXXXX`: adds the GA4 tag to every page.
285    pub ga4_measurement_id: Option<String>,
286    /// For events sent from the server (Measurement Protocol).
287    pub ga4_api_secret: Option<String>,
288    /// e.g. `GTM-XXXXXXX`: adds the Tag Manager container to every page.
289    pub gtm_container_id: Option<String>,
290}
291
292impl std::fmt::Debug for AnalyticsConfig {
293    // Secrets show as `[hidden]`, so a logged config doesn't leak them.
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        f.debug_struct("AnalyticsConfig")
296            .field("google_site_verification", &self.google_site_verification)
297            .field("ga4_measurement_id", &self.ga4_measurement_id)
298            .field(
299                "ga4_api_secret",
300                &self.ga4_api_secret.as_ref().map(|_| "[hidden]"),
301            )
302            .field("gtm_container_id", &self.gtm_container_id)
303            .finish()
304    }
305}
306
307impl Config {
308    /// Loads `.env` (if present) and reads the configuration from the environment.
309    pub fn load() -> crate::Result<Self> {
310        let _ = dotenvy::dotenv();
311        Self::from_env()
312    }
313
314    /// Reads the configuration from the environment only, without loading `.env`.
315    pub fn from_env() -> crate::Result<Self> {
316        Self::from_vars(|name| env::var(name).ok())
317    }
318
319    /// Reads the configuration from `get` (a variable's value by name), e.g.
320    /// a map in a test: `Config::from_vars(|name| vars.get(name).cloned())`.
321    /// A value that doesn't fit is an error naming its variable.
322    pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> crate::Result<Self> {
323        Ok(Self::read(get)?)
324    }
325
326    fn read(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
327        let v = Vars(get);
328        let env = Environment::parse(&v.or("APP_ENV", "local"))?;
329        let debug = v.bool("APP_DEBUG", env == Environment::Local)?;
330        let host = v
331            .or("APP_HOST", "127.0.0.1")
332            .parse()
333            .context("APP_HOST must be an IP address")?;
334        let port = v
335            .or("APP_PORT", "3000")
336            .parse()
337            .context("APP_PORT must be a port number")?;
338        let key = v.get("APP_KEY").filter(|k| !k.is_empty());
339
340        if env == Environment::Production && key.is_none() {
341            bail!("APP_KEY must be set in production");
342        }
343
344        Ok(Self {
345            name: v.or("APP_NAME", "Renox"),
346            env,
347            debug,
348            url: v.or("APP_URL", &format!("http://{host}:{port}")),
349            key,
350            host,
351            port,
352            views_path: v.or("VIEWS_PATH", "resources/views").into(),
353            public_path: v.or("PUBLIC_PATH", "public").into(),
354            session_lifetime: Duration::from_secs(
355                v.or("SESSION_LIFETIME", "120")
356                    .parse::<u64>()
357                    .context("SESSION_LIFETIME must be a number of minutes")?
358                    .saturating_mul(60),
359            ),
360            session_cookie: v.or("SESSION_COOKIE", "renox_session"),
361            session_driver: SessionDriver::parse(&v.or("SESSION_DRIVER", "cookie"))?,
362            remember_lifetime: Duration::from_secs(
363                v.or("REMEMBER_LIFETIME", "43200")
364                    .parse::<u64>()
365                    .context("REMEMBER_LIFETIME must be a number of minutes")?
366                    .saturating_mul(60),
367            ),
368            database_url: v.or("DATABASE_URL", "sqlite://storage/app.db"),
369            database_pool_size: v
370                .or("DATABASE_POOL_SIZE", "8")
371                .parse()
372                .ok()
373                .filter(|n| *n > 0)
374                .context("DATABASE_POOL_SIZE must be a number above 0")?,
375            database_acquire_timeout: v
376                .seconds("DATABASE_ACQUIRE_TIMEOUT", 5)?
377                .unwrap_or(Duration::from_secs(5)),
378            database_statement_timeout: v.seconds("DATABASE_STATEMENT_TIMEOUT", 30)?,
379            request_timeout: v.seconds("REQUEST_TIMEOUT", 60)?,
380            locale: v.or("APP_LOCALE", "en"),
381            fallback_locale: v.or("APP_FALLBACK_LOCALE", "en"),
382            currency: match v.or("APP_CURRENCY", "USD").trim().to_ascii_uppercase() {
383                code if code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase()) => code,
384                other => {
385                    bail!("APP_CURRENCY must be an ISO 4217 code like USD or EUR, got `{other}`")
386                }
387            },
388            lang_path: v.or("LANG_PATH", "resources/lang").into(),
389            mail: MailConfig {
390                mailer: crate::mail::MailDriver::parse(&v.or("MAIL_MAILER", "log"))?,
391                host: v.or("MAIL_HOST", "localhost"),
392                port: v
393                    .get("MAIL_PORT")
394                    .filter(|p| !p.is_empty())
395                    .map(|p| p.parse())
396                    .transpose()
397                    .context("MAIL_PORT must be a port number")?,
398                username: v.get("MAIL_USERNAME").filter(|v| !v.is_empty()),
399                password: v.get("MAIL_PASSWORD").filter(|v| !v.is_empty()),
400                encryption: crate::mail::MailEncryption::parse(
401                    &v.or("MAIL_ENCRYPTION", "starttls"),
402                )?,
403                from_address: v.or("MAIL_FROM_ADDRESS", "hello@example.com"),
404                from_name: v.get("MAIL_FROM_NAME").filter(|v| !v.is_empty()),
405                timeout: v
406                    .seconds("MAIL_TIMEOUT", 10)?
407                    .unwrap_or(Duration::from_secs(10)),
408                failover: v
409                    .or("MAIL_FAILOVER", "")
410                    .split(',')
411                    .map(|name| name.trim().to_owned())
412                    .filter(|name| !name.is_empty())
413                    .collect(),
414            },
415            queue_workers: v
416                .or("QUEUE_WORKERS", "2")
417                .parse()
418                .context("QUEUE_WORKERS must be a number")?,
419            scheduler: v.bool("SCHEDULER", true)?,
420            log_format: LogFormat::parse(&v.or("LOG_FORMAT", "text"))?,
421            log_file: v
422                .get("LOG_FILE")
423                .filter(|p| !p.is_empty())
424                .map(PathBuf::from),
425            timezone: v
426                .or("APP_TIMEZONE", "UTC")
427                .parse()
428                .map_err(|err| anyhow::anyhow!("APP_TIMEZONE: {err}"))?,
429            cache_store: CacheStore::parse(&v.or("CACHE_STORE", "memory"))?,
430            storage_path: v.or("STORAGE_PATH", "storage").into(),
431            storage: StorageConfig {
432                disk: crate::storage::DiskDriver::parse(&v.or("STORAGE_DISK", "local"))?,
433                bucket: v.optional("S3_BUCKET"),
434                region: v.optional("S3_REGION"),
435                endpoint: v.optional("S3_ENDPOINT"),
436                access_key_id: v.optional("S3_ACCESS_KEY_ID"),
437                secret_access_key: v.optional("S3_SECRET_ACCESS_KEY"),
438                url: v.optional("STORAGE_URL"),
439                root: None,
440            },
441            upload_max_size: v
442                .or("UPLOAD_MAX_SIZE", "10")
443                .parse::<usize>()
444                .ok()
445                .and_then(|mb| mb.checked_mul(1024 * 1024))
446                .context("UPLOAD_MAX_SIZE must be a number of megabytes")?,
447            live_snapshot_max: v
448                .or("LIVE_SNAPSHOT_MAX_SIZE", "64")
449                .parse::<usize>()
450                .ok()
451                .and_then(|kb| kb.checked_mul(1024))
452                .context("LIVE_SNAPSHOT_MAX_SIZE must be a number of kilobytes")?,
453            csp: CspMode::parse(&v.or("CSP", "relaxed"))?,
454            permissions_policy: PermissionsMode::parse(&v.or("PERMISSIONS_POLICY", "default"))?,
455            trusted_proxies: crate::TrustedProxies::read(&v.or("TRUSTED_PROXIES", ""))?,
456            trusted_hosts: v
457                .or("TRUSTED_HOSTS", "")
458                .split(',')
459                .map(|host| host.trim().to_ascii_lowercase())
460                .filter(|host| !host.is_empty())
461                .collect(),
462            vars: Default::default(),
463            analytics: AnalyticsConfig {
464                google_site_verification: v.optional("GOOGLE_SITE_VERIFICATION"),
465                ga4_measurement_id: v.optional("GA4_MEASUREMENT_ID"),
466                ga4_api_secret: v.optional("GA4_API_SECRET"),
467                gtm_container_id: v.optional("GTM_CONTAINER_ID"),
468            },
469        })
470    }
471
472    /// Any other setting, e.g. an API key: `config.vars`, else the
473    /// environment (which includes `.env`). Empty values count as missing.
474    pub fn var(&self, name: &str) -> Option<String> {
475        self.vars
476            .get(name)
477            .cloned()
478            .or_else(|| env::var(name).ok())
479            .filter(|v| !v.is_empty())
480    }
481
482    /// The address to listen on: `host` and `port`.
483    pub fn addr(&self) -> SocketAddr {
484        SocketAddr::new(self.host, self.port)
485    }
486}
487
488impl Default for Config {
489    fn default() -> Self {
490        Self {
491            name: "Renox".into(),
492            env: Environment::Local,
493            debug: true,
494            url: "http://127.0.0.1:3000".into(),
495            key: None,
496            host: IpAddr::V4(Ipv4Addr::LOCALHOST),
497            port: 3000,
498            views_path: "resources/views".into(),
499            public_path: "public".into(),
500            session_lifetime: Duration::from_secs(120 * 60),
501            session_cookie: "renox_session".into(),
502            session_driver: SessionDriver::Cookie,
503            remember_lifetime: Duration::from_secs(43_200 * 60),
504            database_url: "sqlite::memory:".into(),
505            database_pool_size: 8,
506            // Generous: parallel test suites open many SQLite files at once.
507            // (An in-memory database still fails fast; see `db::connect`.)
508            database_acquire_timeout: Duration::from_secs(30),
509            database_statement_timeout: Some(Duration::from_secs(30)),
510            request_timeout: Some(Duration::from_secs(60)),
511            locale: "en".into(),
512            fallback_locale: "en".into(),
513            currency: "USD".into(),
514            lang_path: "resources/lang".into(),
515            mail: MailConfig {
516                mailer: crate::mail::MailDriver::Memory,
517                ..MailConfig::default()
518            },
519            queue_workers: 0,
520            scheduler: false,
521            log_format: LogFormat::Text,
522            log_file: None,
523            timezone: crate::timezone::Zone::default(),
524            cache_store: CacheStore::Memory,
525            storage_path: "storage".into(),
526            storage: StorageConfig::default(),
527            upload_max_size: 10 * 1024 * 1024,
528            live_snapshot_max: 64 * 1024,
529            csp: CspMode::Relaxed,
530            permissions_policy: PermissionsMode::Default,
531            vars: Default::default(),
532            analytics: AnalyticsConfig::default(),
533            trusted_proxies: Default::default(),
534            trusted_hosts: Vec::new(),
535        }
536    }
537}
538
539/// Variables by name, for `Config::from_vars`.
540struct Vars<F>(F);
541
542impl<F: Fn(&str) -> Option<String>> Vars<F> {
543    fn get(&self, name: &str) -> Option<String> {
544        (self.0)(name)
545    }
546
547    fn optional(&self, name: &str) -> Option<String> {
548        self.get(name).filter(|v| !v.is_empty())
549    }
550
551    fn or(&self, name: &str, default: &str) -> String {
552        self.get(name).unwrap_or_else(|| default.to_owned())
553    }
554
555    /// A number of seconds from `name`; `None` when it is 0 (no limit).
556    fn seconds(&self, name: &str, default: u64) -> anyhow::Result<Option<Duration>> {
557        let value: u64 = self
558            .or(name, &default.to_string())
559            .parse()
560            .with_context(|| format!("{name} must be a number of seconds"))?;
561        Ok((value > 0).then(|| Duration::from_secs(value)))
562    }
563
564    fn bool(&self, name: &str, default: bool) -> anyhow::Result<bool> {
565        match self.get(name) {
566            None => Ok(default),
567            Some(v) => match v.to_ascii_lowercase().as_str() {
568                "1" | "true" | "yes" | "on" => Ok(true),
569                "0" | "false" | "no" | "off" | "" => Ok(false),
570                other => bail!("{name} must be true or false, got `{other}`"),
571            },
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn parses_environment_aliases() {
582        assert_eq!(Environment::parse("local").unwrap(), Environment::Local);
583        assert_eq!(Environment::parse("DEV").unwrap(), Environment::Local);
584        assert_eq!(Environment::parse("test").unwrap(), Environment::Testing);
585        assert_eq!(Environment::parse("prod").unwrap(), Environment::Production);
586        assert!(Environment::parse("staging").is_err());
587    }
588
589    fn load(pairs: &[(&str, &str)]) -> anyhow::Result<Config> {
590        let vars: std::collections::HashMap<String, String> = pairs
591            .iter()
592            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
593            .collect();
594        Config::read(|name| vars.get(name).cloned())
595    }
596
597    #[test]
598    fn defaults_without_any_variable() {
599        let c = load(&[]).unwrap();
600        assert_eq!(c.env, Environment::Local);
601        assert!(c.debug, "local defaults to debug");
602        assert_eq!(
603            (c.port, c.database_pool_size, c.upload_max_size),
604            (3000, 8, 10 * 1024 * 1024)
605        );
606        assert_eq!(c.database_acquire_timeout, Duration::from_secs(5));
607        assert_eq!(c.request_timeout, Some(Duration::from_secs(60)));
608        assert_eq!(c.cache_store, CacheStore::Memory);
609        assert_eq!(c.csp, CspMode::Relaxed);
610        assert_eq!(c.permissions_policy, PermissionsMode::Default);
611        assert!(c.key.is_none() && c.mail.port.is_none());
612    }
613
614    #[test]
615    fn debug_hides_secrets() {
616        let mut c = load(&[("APP_KEY", "base64:c2VjcmV0"), ("MAIL_PASSWORD", "hunter2")]).unwrap();
617        c.vars
618            .insert("XENDIT_SECRET_KEY".into(), "xnd_live_123".into());
619        let shown = format!("{c:?}");
620        for secret in ["c2VjcmV0", "hunter2", "xnd_live_123"] {
621            assert!(!shown.contains(secret), "{secret} in {shown}");
622        }
623        assert!(
624            shown.contains("XENDIT_SECRET_KEY"),
625            "variable names stay: {shown}"
626        );
627        assert!(shown.contains("[hidden]"));
628    }
629
630    #[test]
631    fn session_driver() {
632        assert_eq!(load(&[]).unwrap().session_driver, SessionDriver::Cookie);
633        assert_eq!(
634            load(&[("SESSION_DRIVER", "database")])
635                .unwrap()
636                .session_driver,
637            SessionDriver::Database
638        );
639        assert!(load(&[("SESSION_DRIVER", "redis")]).is_err());
640    }
641
642    #[test]
643    fn log_format_and_file() {
644        let c = load(&[]).unwrap();
645        assert_eq!((c.log_format.as_str(), c.log_file), ("text", None));
646        let c = load(&[("LOG_FORMAT", "json"), ("LOG_FILE", "storage/logs/app.log")]).unwrap();
647        assert_eq!(c.log_format, LogFormat::Json);
648        assert_eq!(c.log_file, Some(PathBuf::from("storage/logs/app.log")));
649        let err = load(&[("LOG_FORMAT", "xml")]).unwrap_err();
650        assert!(err.to_string().contains("LOG_FORMAT"), "{err}");
651    }
652
653    #[test]
654    fn currency() {
655        assert_eq!(load(&[]).unwrap().currency, "USD");
656        assert_eq!(load(&[("APP_CURRENCY", " idr ")]).unwrap().currency, "IDR");
657        for bad in ["US", "rupiah", "U$D"] {
658            let err = load(&[("APP_CURRENCY", bad)]).unwrap_err();
659            assert!(err.to_string().contains("APP_CURRENCY"), "{err}");
660        }
661    }
662
663    #[test]
664    fn reads_every_kind_of_value() {
665        let c = load(&[
666            ("APP_ENV", "production"),
667            ("APP_KEY", "base64:abc"),
668            ("APP_DEBUG", "off"),
669            ("APP_PORT", "8080"),
670            ("DATABASE_POOL_SIZE", "3"),
671            ("REQUEST_TIMEOUT", "0"),
672            ("DATABASE_STATEMENT_TIMEOUT", "5"),
673            ("UPLOAD_MAX_SIZE", "2"),
674            ("LIVE_SNAPSHOT_MAX_SIZE", "2"),
675            ("MAIL_PORT", "2525"),
676            ("MAIL_USERNAME", ""),
677            ("MAIL_FAILOVER", "backup, log"),
678            ("CSP", "strict"),
679            ("PERMISSIONS_POLICY", "geolocation=(self), camera=()"),
680            ("TRUSTED_PROXIES", "10.0.0.0/8"),
681            ("TRUSTED_HOSTS", "shop.example.com, *.Example.org"),
682            ("S3_BUCKET", "files"),
683        ])
684        .unwrap();
685        assert_eq!(c.env, Environment::Production);
686        assert!(!c.debug);
687        assert_eq!((c.port, c.database_pool_size), (8080, 3));
688        assert_eq!(c.request_timeout, None, "0 means no limit");
689        assert_eq!(c.database_statement_timeout, Some(Duration::from_secs(5)));
690        assert_eq!(c.upload_max_size, 2 * 1024 * 1024);
691        assert_eq!(c.live_snapshot_max, 2048);
692        assert_eq!(c.mail.port, Some(2525));
693        assert_eq!(c.mail.username, None, "empty means unset");
694        assert_eq!(c.mail.failover, ["backup", "log"]);
695        assert_eq!(c.csp, CspMode::Strict);
696        assert_eq!(
697            c.permissions_policy,
698            PermissionsMode::Custom("geolocation=(self), camera=()".into())
699        );
700        assert!(c.trusted_proxies.contains("10.1.2.3".parse().unwrap()));
701        assert_eq!(c.trusted_hosts, ["shop.example.com", "*.example.org"]);
702        assert_eq!(c.storage.bucket.as_deref(), Some("files"));
703    }
704
705    #[test]
706    fn permissions_policy_takes_default_off_or_a_header_value() {
707        for (value, want) in [
708            ("", PermissionsMode::Default),
709            ("DEFAULT", PermissionsMode::Default),
710            ("off", PermissionsMode::Off),
711            ("false", PermissionsMode::Off),
712            (
713                "camera=(self \"https://a.example\"), payment=*",
714                PermissionsMode::Custom("camera=(self \"https://a.example\"), payment=*".into()),
715            ),
716        ] {
717            let c = load(&[("PERMISSIONS_POLICY", value)]).unwrap();
718            assert_eq!(c.permissions_policy, want, "{value}");
719        }
720        // `CSP=off` leaves it alone.
721        let c = load(&[("CSP", "off")]).unwrap();
722        assert_eq!(c.permissions_policy, PermissionsMode::Default);
723    }
724
725    #[test]
726    fn refuses_bad_values_with_the_variable_name() {
727        for (name, value) in [
728            ("APP_ENV", "staging"),
729            ("APP_DEBUG", "maybe"),
730            ("APP_PORT", "http"),
731            ("APP_HOST", "localhost"),
732            ("DATABASE_POOL_SIZE", "0"),
733            ("REQUEST_TIMEOUT", "-1"),
734            ("UPLOAD_MAX_SIZE", "99999999999999999"),
735            ("MAIL_PORT", "99999"),
736            ("CSP", "loose"),
737            ("PERMISSIONS_POLICY", "camera"),
738            ("PERMISSIONS_POLICY", "Camera=(self)"),
739            ("PERMISSIONS_POLICY", "camera=(http://x.example)"),
740            ("TRUSTED_PROXIES", "proxy.local"),
741            ("SESSION_DRIVER", "redis"),
742            ("CACHE_STORE", "redis"),
743            ("LOG_FORMAT", "xml"),
744            ("MAIL_MAILER", "sendgrid"),
745            ("MAIL_ENCRYPTION", "ssl3"),
746            ("STORAGE_DISK", "ftp"),
747            ("APP_TIMEZONE", "Mars/Olympus"),
748        ] {
749            let err = load(&[(name, value)])
750                .err()
751                .map(|e| format!("{e:#}"))
752                .unwrap_or_default();
753            assert!(
754                err.contains(name) || err.contains(value),
755                "{name}={value}: {err}"
756            );
757        }
758        let err = load(&[("APP_ENV", "production")]).unwrap_err();
759        assert!(
760            format!("{err}").contains("APP_KEY"),
761            "production needs a key"
762        );
763    }
764
765    /// Settings print and serialize as the word `.env` takes.
766    #[test]
767    fn settings_print_and_serialize_as_their_word() {
768        assert_eq!(SessionDriver::Database.to_string(), "database");
769        assert_eq!(
770            serde_json::to_string(&CacheStore::Database).unwrap(),
771            r#""database""#
772        );
773        assert_eq!(format!("{}", LogFormat::Json), "json");
774    }
775}