1use std::net::SocketAddr;
2
3use anyhow::{Context, Result};
4
5use crate::webhooks::Webhooks;
6
7const DEFAULT_ADDR: &str = "0.0.0.0:8080";
9
10const DEFAULT_ADMIN_EMAIL: &str = "admin@kasl.local";
14
15const DEFAULT_MAX_BATCH_DAYS: usize = 31;
18
19const DEFAULT_MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
22
23#[derive(Debug, Clone)]
25pub struct Config {
26 pub addr: SocketAddr,
28 pub database_url: String,
30 pub agents: String,
33 pub max_batch_days: usize,
35 pub max_body_bytes: usize,
37 pub admin: String,
39 pub admin_email: String,
46 pub secure_cookies: bool,
53 pub demo: bool,
58 pub webhooks: Webhooks,
63}
64
65impl Config {
66 pub fn from_env() -> Result<Self> {
67 let mut config = Self::from_lookup(|key| std::env::var(key).ok())?;
68 config.webhooks = Webhooks::from_vars(std::env::vars(), std::env::var("KASL_PUBLIC_URL").ok()).map_err(anyhow::Error::msg)?;
71 Ok(config)
72 }
73
74 pub fn defaults_for_database(database_url: String) -> Self {
78 Self {
79 addr: DEFAULT_ADDR.parse().expect("the default address is valid"),
80 database_url,
81 agents: String::new(),
82 max_batch_days: DEFAULT_MAX_BATCH_DAYS,
83 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
84 admin: String::new(),
85 admin_email: DEFAULT_ADMIN_EMAIL.to_string(),
86 secure_cookies: true,
87 demo: false,
88 webhooks: Webhooks::default(),
89 }
90 }
91
92 fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
94 let addr = lookup("KASL_SERVER_ADDR").unwrap_or_else(|| DEFAULT_ADDR.to_string());
95 let addr = addr
96 .parse()
97 .with_context(|| format!("KASL_SERVER_ADDR is not a valid socket address: {addr}"))?;
98 let database_url = lookup("DATABASE_URL").context("DATABASE_URL is not set (e.g. postgres://kasl:kasl@localhost:5432/kasl)")?;
99 let agents = lookup("KASL_AGENTS").unwrap_or_default();
100 let max_batch_days = positive("KASL_MAX_BATCH_DAYS", &lookup, DEFAULT_MAX_BATCH_DAYS)?;
101 let max_body_bytes = positive("KASL_MAX_BODY_BYTES", &lookup, DEFAULT_MAX_BODY_BYTES)?;
102 let admin = lookup("KASL_ADMIN").unwrap_or_default();
103 let admin_email = lookup("KASL_ADMIN_EMAIL").unwrap_or_else(|| DEFAULT_ADMIN_EMAIL.to_string());
104 let secure_cookies = boolean("KASL_SECURE_COOKIES", &lookup, true)?;
105 let demo = boolean("KASL_DEMO", &lookup, false)?;
106 Ok(Self {
107 addr,
108 database_url,
109 agents,
110 max_batch_days,
111 max_body_bytes,
112 admin,
113 admin_email,
114 secure_cookies,
115 demo,
116 webhooks: Webhooks::default(),
117 })
118 }
119}
120
121fn boolean(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: bool) -> Result<bool> {
126 let Some(raw) = lookup(key) else { return Ok(default) };
127 match raw.trim().to_ascii_lowercase().as_str() {
128 "1" | "true" | "yes" | "on" => Ok(true),
129 "0" | "false" | "no" | "off" => Ok(false),
130 other => anyhow::bail!("{key} is not a yes/no value: {other}"),
131 }
132}
133
134fn positive(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: usize) -> Result<usize> {
138 let Some(raw) = lookup(key) else { return Ok(default) };
139 let value: usize = raw.parse().with_context(|| format!("{key} is not a positive whole number: {raw}"))?;
140 if value == 0 {
141 anyhow::bail!("{key} must be greater than zero");
142 }
143 Ok(value)
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
151 move |key| pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
152 }
153
154 #[test]
155 fn defaults_the_bind_address() {
156 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).expect("config should build with only DATABASE_URL set");
157 assert_eq!(config.addr, DEFAULT_ADDR.parse().unwrap());
158 assert_eq!(config.database_url, "postgres://localhost/kasl");
159 }
160
161 #[test]
162 fn reads_the_bind_address_override() {
163 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "127.0.0.1:9090")]))
164 .expect("config should accept a valid override");
165 assert_eq!(config.addr, "127.0.0.1:9090".parse().unwrap());
166 }
167
168 #[test]
169 fn requires_database_url() {
170 let error = Config::from_lookup(env(&[])).unwrap_err();
171 assert!(error.to_string().contains("DATABASE_URL"));
172 }
173
174 #[test]
175 fn limits_have_defaults_and_can_be_overridden() {
176 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
177 assert_eq!(config.max_batch_days, DEFAULT_MAX_BATCH_DAYS);
178 assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
179
180 let config = Config::from_lookup(env(&[
181 ("DATABASE_URL", "postgres://localhost/kasl"),
182 ("KASL_MAX_BATCH_DAYS", "7"),
183 ("KASL_MAX_BODY_BYTES", "1048576"),
184 ]))
185 .unwrap();
186 assert_eq!(config.max_batch_days, 7);
187 assert_eq!(config.max_body_bytes, 1048576);
188 }
189
190 #[test]
191 fn a_limit_of_zero_is_refused() {
192 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BATCH_DAYS", "0")])).unwrap_err();
195 assert!(error.to_string().contains("KASL_MAX_BATCH_DAYS"), "{error}");
196
197 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BODY_BYTES", "nope")])).unwrap_err();
198 assert!(error.to_string().contains("KASL_MAX_BODY_BYTES"), "{error}");
199 }
200
201 #[test]
202 fn secure_cookies_default_on_and_refuse_a_typo() {
203 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
204 assert!(config.secure_cookies, "TLS is the assumption; opting out has to be deliberate");
205
206 for value in ["0", "false", "no", "off", "OFF"] {
207 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", value)])).unwrap();
208 assert!(!config.secure_cookies, "`{value}` should turn it off");
209 }
210
211 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", "nope")])).unwrap_err();
215 assert!(error.to_string().contains("KASL_SECURE_COOKIES"), "{error}");
216 }
217
218 #[test]
219 fn the_demo_is_off_unless_asked_for() {
220 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
223 assert!(!config.demo);
224
225 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_DEMO", "true")])).unwrap();
226 assert!(config.demo);
227
228 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_DEMO", "maybe")])).unwrap_err();
229 assert!(error.to_string().contains("KASL_DEMO"), "{error}");
230 }
231
232 #[test]
233 fn rejects_a_malformed_bind_address() {
234 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "not-an-address")])).unwrap_err();
235 assert!(error.to_string().contains("KASL_SERVER_ADDR"));
236 }
237}