Skip to main content

laterite_core/
config.rs

1//! Layered configuration loading.
2
3use std::path::Path;
4
5use config::{Config, Environment, File};
6use serde::de::DeserializeOwned;
7use serde::Deserialize;
8
9use crate::error::{CoreError, CoreResult};
10
11/// Database connection settings.
12#[derive(Debug, Clone, Deserialize)]
13pub struct DatabaseConfig {
14    pub url: String,
15    #[serde(default = "default_max_connections")]
16    pub max_connections: u32,
17    #[serde(default = "default_acquire_timeout_secs")]
18    pub acquire_timeout_secs: u64,
19}
20
21/// HTTP listener settings.
22#[derive(Debug, Clone, Deserialize)]
23pub struct ServerConfig {
24    #[serde(default = "default_listen")]
25    pub listen: String,
26}
27
28/// Application-level metadata. The `name` is the human-readable application
29/// name, the baseline for the admin brand: a `BrandSetting` in the admin can
30/// override it, but this is the default when none is set.
31///
32/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
33/// in a later release is additive.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36#[non_exhaustive]
37pub struct AppMeta {
38    /// The display name of the application (e.g. `"Acme Blog"`). Shown as the
39    /// admin brand unless overridden by a brand setting.
40    pub name: String,
41    /// The application's public base URL (scheme and host, with any port), such
42    /// as `https://acme.example`. It is the canonical origin absolute links build
43    /// on, distinct from the server bind address (they differ behind a proxy or a
44    /// local `.test` domain). When unset, [`base_url`] derives it from the bind
45    /// address.
46    pub url: Option<String>,
47    /// The application's default locale (BCP 47, e.g. `"en"`): the deployment
48    /// default for UI-string translation, the last stop in a request's locale
49    /// chain before the English source when neither the operator's preference nor
50    /// the request's `Accept-Language` resolves.
51    pub locale: String,
52    /// Debug mode: error pages include the internal cause. Off by default;
53    /// enable only in development. Read via [`debug`] where request context is
54    /// not available (error rendering).
55    pub debug: bool,
56    /// The prefix of the environment variables that override this configuration
57    /// (`<PREFIX>__SECTION__KEY`). Declared here so the application and the `lat`
58    /// command agree on it; unset means the framework default. Read from the files
59    /// alone by [`declared_env_prefix`], since it selects the environment layer.
60    pub env_prefix: Option<String>,
61    /// The application key, `id:<base64 of 32 bytes>` (`lat key generate`
62    /// writes one; `LAT__APP__KEY` overrides). Everything the framework
63    /// encrypts or signs uses it. Unset, a key derived from `backend.secret` or
64    /// the key stored at the first boot is used, so a deployment without one
65    /// keeps working; a production deployment sets it.
66    pub key: Option<String>,
67    /// Keys retired by a rotation, each `id:<base64>`, still read; see
68    /// [`crate::crypt`].
69    pub previous_keys: Vec<String>,
70}
71
72impl Default for AppMeta {
73    fn default() -> Self {
74        Self {
75            name: "Laterite".to_string(),
76            url: None,
77            locale: "en".to_string(),
78            debug: false,
79            env_prefix: None,
80            key: None,
81            previous_keys: Vec::new(),
82        }
83    }
84}
85
86/// Records whether the deployment is in debug mode, once, at boot from the loaded
87/// config. Later calls are ignored (the first wins), so error rendering has a
88/// stable answer without threading config through every handler.
89pub fn set_debug(on: bool) {
90    let _ = DEBUG.set(on);
91}
92
93/// Whether the deployment is in debug mode (default `false` until [`set_debug`]).
94pub fn debug() -> bool {
95    *DEBUG.get().unwrap_or(&false)
96}
97
98static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
99
100/// The application's public base URL, without a trailing slash: the configured
101/// `url` when set and non-empty, otherwise derived from the server bind address
102/// as `http://<listen>`. A wildcard bind host (`0.0.0.0` or `[::]`) is shown via
103/// loopback so the result is browsable. Absolute links (the admin URL in the
104/// startup banner, and later notifications and share cards) build on this.
105pub fn base_url(url: Option<&str>, listen: &str) -> String {
106    match url.map(str::trim).filter(|u| !u.is_empty()) {
107        Some(url) => url.trim_end_matches('/').to_string(),
108        None => format!("http://{}", browsable_host(listen)),
109    }
110}
111
112/// Rewrites a wildcard bind host to a loopback address so a bind string like
113/// `0.0.0.0:8080` yields a URL a browser can actually open.
114fn browsable_host(listen: &str) -> String {
115    if let Some(port) = listen.strip_prefix("0.0.0.0") {
116        format!("127.0.0.1{port}")
117    } else if let Some(port) = listen.strip_prefix("[::]") {
118        format!("[::1]{port}")
119    } else {
120        listen.to_string()
121    }
122}
123
124/// The queue (`[queue]`): where jobs wait and who runs them.
125#[derive(Debug, Clone, Deserialize)]
126#[serde(default)]
127pub struct QueueConfig {
128    /// `auto`, `database` or `redis`. `auto` is `database`.
129    pub store: String,
130    /// The Redis URL (`redis://host:6379/0`) for the `redis` store.
131    pub url: Option<String>,
132    /// The prefix of every Redis key, `laterite` by default.
133    pub prefix: String,
134    /// `worker` (workers in this process), `external` (another process runs
135    /// `queue work`) or `inline` (a job runs as it is pushed).
136    pub mode: String,
137    /// How many workers the process runs in `worker` mode.
138    pub workers: usize,
139    /// The queues a worker takes from, in priority order.
140    pub queues: Vec<String>,
141    /// Seconds after which a claimed job counts as abandoned and is claimed
142    /// again. Longer than any job's timeout.
143    pub retry_after_seconds: u64,
144    /// Seconds an idle worker waits before looking again.
145    pub poll_seconds: u64,
146}
147
148impl Default for QueueConfig {
149    fn default() -> Self {
150        Self {
151            store: "auto".to_string(),
152            url: None,
153            prefix: "laterite".to_string(),
154            mode: "worker".to_string(),
155            workers: 2,
156            queues: vec!["default".to_string()],
157            retry_after_seconds: 90,
158            poll_seconds: 1,
159        }
160    }
161}
162
163/// The scheduler (`[schedule]`): who evaluates the entries each minute.
164#[derive(Debug, Clone, Deserialize)]
165#[serde(default)]
166pub struct ScheduleConfig {
167    /// `ticker` (this process evaluates the entries every minute), `external`
168    /// (another process runs `schedule run`) or `off`.
169    pub mode: String,
170}
171
172impl Default for ScheduleConfig {
173    fn default() -> Self {
174        Self {
175            mode: "ticker".to_string(),
176        }
177    }
178}
179
180/// Mail (`[mail]`): the sender, the driver and the SMTP server. A Mail
181/// settings model saved in the admin overrides these.
182#[derive(Debug, Clone, Deserialize)]
183#[serde(default)]
184pub struct MailConfig {
185    /// `smtp`, `log`, `stub`, or a driver a module registered. `log` keeps
186    /// every message in the mail log instead of sending it.
187    pub driver: String,
188    /// The default sender, `Name <address>` or a bare address.
189    pub from: String,
190    /// Every message goes to this address instead of its recipients while
191    /// set: for development.
192    pub to: Option<String>,
193    pub smtp: SmtpConfig,
194}
195
196impl Default for MailConfig {
197    fn default() -> Self {
198        Self {
199            driver: "log".to_string(),
200            from: "Laterite <no-reply@localhost>".to_string(),
201            to: None,
202            smtp: SmtpConfig::default(),
203        }
204    }
205}
206
207/// The SMTP server (`[mail.smtp]`).
208#[derive(Debug, Clone, Deserialize, serde::Serialize, PartialEq, Eq)]
209#[serde(default)]
210pub struct SmtpConfig {
211    pub host: String,
212    pub port: u16,
213    /// `starttls`, `tls` or `none`.
214    pub encryption: String,
215    pub username: String,
216    pub password: String,
217    /// Seconds to wait for the server.
218    pub timeout_seconds: u64,
219}
220
221impl Default for SmtpConfig {
222    fn default() -> Self {
223        Self {
224            host: "localhost".to_string(),
225            port: 587,
226            encryption: "starttls".to_string(),
227            username: String::new(),
228            password: String::new(),
229            timeout_seconds: 30,
230        }
231    }
232}
233
234/// The cache (`[cache]`): where values are kept and how the processes of a
235/// deployment learn of one another's invalidations.
236#[derive(Debug, Clone, Deserialize)]
237#[serde(default)]
238pub struct CacheConfig {
239    /// `auto`, `null`, `memory` or `redis`. `auto` is `null` while `app.debug`
240    /// is on and `memory` otherwise.
241    pub store: String,
242    /// The Redis URL (`redis://host:6379/0`) for the `redis` store.
243    pub url: Option<String>,
244    /// The prefix of every Redis key, `laterite` by default.
245    pub prefix: String,
246    /// The `memory` store's capacity in MiB.
247    pub memory_mib: u64,
248    /// How often, in seconds, a process reads the generations the others
249    /// bumped; `0` disables it for a single-process deployment.
250    pub poll_seconds: u64,
251}
252
253impl Default for CacheConfig {
254    fn default() -> Self {
255        Self {
256            store: "auto".to_string(),
257            url: None,
258            prefix: "laterite".to_string(),
259            memory_mib: 64,
260            poll_seconds: 2,
261        }
262    }
263}
264
265/// Deployment-level backend settings. Per-install brand and per-operator preferences
266/// live in the settings and preferences stores, not here.
267///
268/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
269/// in a later release is additive.
270#[derive(Debug, Clone, Deserialize)]
271#[serde(default)]
272#[non_exhaustive]
273pub struct BackendConfig {
274    /// Set the `Secure` attribute on the admin session cookie. Enable behind HTTPS
275    /// in production; leave off for plain-HTTP local development.
276    pub secure_cookie: bool,
277    /// The key the admin signs its tokens with (a private file's URL). Unset,
278    /// a key is generated at the first boot and kept in the settings table, so
279    /// set it when several instances share a database behind one hostname.
280    pub secret: Option<String>,
281    /// The default display timezone for the admin (an IANA name like
282    /// `Asia/Kolkata`). Storage is always UTC; this only affects how dates render.
283    /// An operator's own preference overrides it (later); it falls back to UTC.
284    pub timezone: String,
285    /// Admin path overrides, keyed by module id (`"rainmill.location"`) or by a
286    /// module id and one contribution's declared path
287    /// (`"rainmill.location/nodes"`), the more specific winning. Values are
288    /// absolute under the admin mount, so they survive a relocated panel. Lets a
289    /// deployment move a plugin's screens without forking it.
290    pub paths: std::collections::BTreeMap<String, String>,
291    /// Arrangement overrides, keyed by hierarchy (`"backend.nav"`,
292    /// `"backend.settings"`, a plugin's own): the keys in the wanted order.
293    /// Keys left out keep their arrangement after the listed ones; a fixed
294    /// hierarchy ignores its entry.
295    pub order: std::collections::BTreeMap<String, Vec<String>>,
296    /// Entries moved from the home their author gave them, keyed by the
297    /// entry (`"backend.access_log"`, `"resource:/orders"`); see
298    /// [`crate::arrange::Move`].
299    pub menu: std::collections::BTreeMap<String, crate::arrange::Move>,
300    /// The URL path the admin panel is mounted under, without a trailing slash
301    /// (default `/admin`). Change it to move or obscure the panel (`/manage`,
302    /// `/backend`). A leading slash is added if missing and a trailing slash is
303    /// stripped; an empty value falls back to `/admin`.
304    pub path: String,
305    /// Networks whose `X-Forwarded-For` header may be believed, as CIDR ranges
306    /// (`"10.0.0.0/8"`). The header is caller-controlled, so it is read only when
307    /// the connecting peer falls inside one of these; the client is then the
308    /// rightmost address in the chain that is not itself a trusted proxy.
309    ///
310    /// Empty (the default) trusts nothing and records the peer address, which is
311    /// right for a directly bound server. Set it to the load balancer's network
312    /// when there is one: leaving it empty behind a proxy records the proxy on
313    /// every row, and filling it in for a network that is not a proxy lets anyone
314    /// on it forge the address in the audit log.
315    pub trusted_proxies: Vec<String>,
316    /// The directory files the admin keeps are stored under (default
317    /// `storage`), relative to the working directory unless absolute. Uploaded
318    /// import files go to `private` beneath it.
319    pub storage: String,
320    /// The admin skin by code: `standard` (the default), `compact`, or one a
321    /// module registered. An unknown code aborts the boot.
322    pub skin: String,
323    /// The file extensions an upload may carry (`["jpg", "png", "pdf"]`).
324    /// Empty, the admin's default list applies: images, documents, audio,
325    /// video, fonts and archives, with no scripts, markup or stylesheets.
326    pub upload_extensions: Vec<String>,
327}
328
329impl Default for BackendConfig {
330    fn default() -> Self {
331        Self {
332            secure_cookie: false,
333            secret: None,
334            timezone: "UTC".to_string(),
335            paths: std::collections::BTreeMap::new(),
336            order: std::collections::BTreeMap::new(),
337            menu: std::collections::BTreeMap::new(),
338            path: "/admin".to_string(),
339            trusted_proxies: Vec::new(),
340            storage: "storage".to_string(),
341            skin: "standard".to_string(),
342            upload_extensions: Vec::new(),
343        }
344    }
345}
346
347fn default_max_connections() -> u32 {
348    10
349}
350
351fn default_acquire_timeout_secs() -> u64 {
352    5
353}
354
355fn default_listen() -> String {
356    "127.0.0.1:8080".to_string()
357}
358
359/// Loads a layered configuration into any deserializable type.
360///
361/// Layers, later overriding earlier:
362/// 1. `<dir>/default.toml` (required)
363/// 2. `<dir>/<APP_ENV>.toml` (optional; `APP_ENV` defaults to `development`)
364/// 3. `<dir>/local.toml` (optional, git-ignored developer overrides)
365/// 4. Environment variables `<PREFIX>__SECTION__KEY` (e.g. `LAT__DATABASE__URL`);
366///    the prefix an application declares as `app.env_prefix` is read with
367///    [`declared_env_prefix`] and passed here.
368pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
369    file_layers(dir)
370        .add_source(
371            Environment::with_prefix(env_prefix)
372                .prefix_separator("__")
373                .separator("__"),
374        )
375        .build()
376        .and_then(Config::try_deserialize)
377        .map_err(|e| CoreError::Config(e.to_string()))
378}
379
380/// The environment-variable prefix a configuration declares as `app.env_prefix`,
381/// read from the files alone: it selects the environment layer, so it cannot come
382/// from there. `None` when undeclared or blank, meaning the framework default.
383pub fn declared_env_prefix(dir: &Path) -> CoreResult<Option<String>> {
384    #[derive(Deserialize)]
385    struct Declared {
386        #[serde(default)]
387        app: DeclaredApp,
388    }
389    #[derive(Deserialize, Default)]
390    struct DeclaredApp {
391        env_prefix: Option<String>,
392    }
393    let declared: Declared = file_layers(dir)
394        .build()
395        .and_then(Config::try_deserialize)
396        .map_err(|e| CoreError::Config(e.to_string()))?;
397    Ok(declared
398        .app
399        .env_prefix
400        .map(|prefix| prefix.trim().to_string())
401        .filter(|prefix| !prefix.is_empty()))
402}
403
404/// The file layers of a config directory, in override order: `default.toml`
405/// (required), `<APP_ENV>.toml`, then `local.toml`.
406fn file_layers(dir: &Path) -> config::ConfigBuilder<config::builder::DefaultState> {
407    let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
408    Config::builder()
409        .add_source(File::from(dir.join("default.toml")).required(true))
410        .add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
411        .add_source(File::from(dir.join("local.toml")).required(false))
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[derive(Deserialize)]
419    struct TestConfig {
420        server: ServerConfig,
421        database: DatabaseConfig,
422    }
423
424    #[test]
425    fn loads_defaults_env_overrides_and_serde_defaults() {
426        let dir = tempfile::tempdir().unwrap();
427        std::fs::write(
428            dir.path().join("default.toml"),
429            "[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
430        )
431        .unwrap();
432        std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
433        let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
434        std::env::remove_var("LATERITE_TEST__DATABASE__URL");
435        assert_eq!(cfg.server.listen, "0.0.0.0:9999");
436        assert_eq!(cfg.database.url, "postgres://from-env");
437        assert_eq!(cfg.database.max_connections, 10);
438        assert_eq!(cfg.database.acquire_timeout_secs, 5);
439    }
440
441    #[test]
442    fn backend_config_defaults_and_loads() {
443        #[derive(Deserialize)]
444        struct C {
445            #[serde(default)]
446            backend: BackendConfig,
447        }
448        let dir = tempfile::tempdir().unwrap();
449        std::fs::write(dir.path().join("default.toml"), "").unwrap();
450        let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
451        assert!(!c.backend.secure_cookie);
452        assert_eq!(c.backend.timezone, "UTC");
453
454        std::fs::write(
455            dir.path().join("default.toml"),
456            "[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
457        )
458        .unwrap();
459        let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
460        assert!(c.backend.secure_cookie);
461        assert_eq!(c.backend.timezone, "Asia/Kolkata");
462    }
463
464    #[test]
465    fn declared_env_prefix_comes_from_the_files_alone() {
466        let dir = tempfile::tempdir().unwrap();
467        std::fs::write(dir.path().join("default.toml"), "[app]\nname = \"Acme\"\n").unwrap();
468        assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
469        std::fs::write(
470            dir.path().join("default.toml"),
471            "[app]\nenv_prefix = \"ACME\"\n",
472        )
473        .unwrap();
474        assert_eq!(
475            declared_env_prefix(dir.path()).unwrap().as_deref(),
476            Some("ACME")
477        );
478        // local.toml overrides it, and a blank value counts as undeclared.
479        std::fs::write(
480            dir.path().join("local.toml"),
481            "[app]\nenv_prefix = \"  \"\n",
482        )
483        .unwrap();
484        assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
485    }
486
487    #[test]
488    fn base_url_prefers_config_then_derives_from_listen() {
489        // Configured URL wins and loses any trailing slash.
490        assert_eq!(
491            base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
492            "https://acme.example"
493        );
494        // Blank configured URL falls through to the derived form.
495        assert_eq!(
496            base_url(Some("  "), "127.0.0.1:9000"),
497            "http://127.0.0.1:9000"
498        );
499        // No URL: derive from the bind address.
500        assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
501        // A wildcard bind host is shown via loopback so the URL is browsable.
502        assert_eq!(base_url(None, "0.0.0.0:8080"), "http://127.0.0.1:8080");
503        assert_eq!(base_url(None, "[::]:8080"), "http://[::1]:8080");
504    }
505
506    #[test]
507    fn missing_default_file_is_a_config_error() {
508        let dir = tempfile::tempdir().unwrap();
509        let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
510        assert!(matches!(result, Err(CoreError::Config(_))));
511    }
512}