OXYGENHUB
# c40c2d3edb51 oxygenhub / crates / hub / src / config.rs
9.2 KiB · Rust · last changed Milestone 0.4: issues and notifications 1 month ago source blame history raw
1
//! Configuration (hub spec §17.1): `hub.toml` in the data directory, every
2
//! key overridable by `OXYHUB_<SECTION>_<KEY>`.
3
4
use serde::Deserialize;
5
use std::net::SocketAddr;
6
use std::path::{Path, PathBuf};
7
8
#[derive(Clone, Debug, Deserialize)]
9
#[serde(default, deny_unknown_fields)]
10
pub struct Config {
11
    /// Set from the command line, never from the file.
12
    #[serde(skip)]
13
    pub data: PathBuf,
14
    pub site: Site,
15
    pub server: Server,
16
    pub tls: Tls,
17
    pub auth: Auth,
18
    pub registration: Registration,
19
    pub email: Email,
20
    pub limits: Limits,
21
    pub retention: Retention,
22
}
23
24
#[derive(Clone, Debug, Default, Deserialize)]
25
#[serde(default, deny_unknown_fields)]
26
pub struct Email {
27
    /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email.
28
    pub smtp: String,
29
    /// `Oxygenhub <hub@example>`.
30
    pub from: String,
31
}
32
33
#[derive(Clone, Debug, Deserialize)]
34
#[serde(default, deny_unknown_fields)]
35
pub struct Registration {
36
    /// `open`, `invite` or `closed`; the admin page can override it.
37
    pub mode: String,
38
}
39
40
impl Default for Registration {
41
    fn default() -> Self {
42
        Registration { mode: "invite".into() }
43
    }
44
}
45
46
#[derive(Clone, Debug, Deserialize)]
47
#[serde(default, deny_unknown_fields)]
48
pub struct Site {
49
    pub name: String,
50
    pub description: String,
51
}
52
53
#[derive(Clone, Debug, Deserialize)]
54
#[serde(default, deny_unknown_fields)]
55
pub struct Server {
56
    /// Plain HTTP; empty disables it.
57
    pub bind: String,
58
    /// HTTPS; empty disables it. Needs `tls`.
59
    pub tls_bind: String,
60
    /// What links and clone URLs start with.
61
    pub base_url: String,
62
    /// Trust `X-Forwarded-*` from a terminating proxy.
63
    pub behind_proxy: bool,
64
}
65
66
#[derive(Clone, Debug, Deserialize)]
67
#[serde(default, deny_unknown_fields)]
68
pub struct Tls {
69
    /// Generate a certificate at `init` (developer edition).
70
    pub self_signed: bool,
71
    /// PEM files; relative paths are under the data directory.
72
    pub cert: String,
73
    pub key: String,
74
}
75
76
#[derive(Clone, Debug, Default, Deserialize)]
77
#[serde(default, deny_unknown_fields)]
78
pub struct Auth {
79
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
80
    /// means every push is accepted, as `oxy serve` does.
81
    pub push_tokens: Vec<String>,
82
}
83
84
#[derive(Clone, Debug, Deserialize)]
85
#[serde(default, deny_unknown_fields)]
86
pub struct Limits {
87
    /// Largest file rendered in the browser, e.g. `2MiB`.
88
    pub render_limit: ByteSize,
89
    pub asset_limit: ByteSize,
90
    pub push_body_limit: ByteSize,
91
}
92
93
#[derive(Clone, Debug, Deserialize)]
94
#[serde(default, deny_unknown_fields)]
95
pub struct Retention {
96
    pub trash_days: u32,
97
    pub webhook_log_days: u32,
98
}
99
100
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
101
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102
pub struct ByteSize(pub u64);
103
104
impl ByteSize {
105
    pub fn parse(s: &str) -> Option<ByteSize> {
106
        let s = s.trim();
107
        let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
108
        let unit = s[digits.len()..].trim();
109
        let n: u64 = digits.parse().ok()?;
110
        let mult = match unit {
111
            "" | "B" => 1,
112
            "k" | "kB" | "KB" => 1000,
113
            "KiB" | "K" => 1 << 10,
114
            "M" | "MB" => 1_000_000,
115
            "MiB" => 1 << 20,
116
            "G" | "GB" => 1_000_000_000,
117
            "GiB" => 1 << 30,
118
            _ => return None,
119
        };
120
        Some(ByteSize(n.checked_mul(mult)?))
121
    }
122
}
123
124
impl<'de> Deserialize<'de> for ByteSize {
125
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<ByteSize, D::Error> {
126
        #[derive(Deserialize)]
127
        #[serde(untagged)]
128
        enum Raw {
129
            Number(u64),
130
            Text(String),
131
        }
132
        match Raw::deserialize(d)? {
133
            Raw::Number(n) => Ok(ByteSize(n)),
134
            Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))),
135
        }
136
    }
137
}
138
139
impl Default for Site {
140
    fn default() -> Self {
141
        Site { name: "Oxygenhub".into(), description: "Repositories, proposals and issues for Oxygen".into() }
142
    }
143
}
144
145
impl Default for Server {
146
    fn default() -> Self {
147
        Server { bind: "127.0.0.1:7300".into(), tls_bind: String::new(), base_url: "http://127.0.0.1:7300".into(), behind_proxy: false }
148
    }
149
}
150
151
impl Default for Tls {
152
    fn default() -> Self {
153
        Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() }
154
    }
155
}
156
157
impl Default for Limits {
158
    fn default() -> Self {
159
        Limits { render_limit: ByteSize(2 << 20), asset_limit: ByteSize(512 << 20), push_body_limit: ByteSize(4 << 30) }
160
    }
161
}
162
163
impl Default for Retention {
164
    fn default() -> Self {
165
        Retention { trash_days: 30, webhook_log_days: 30 }
166
    }
167
}
168
169
impl Default for Config {
170
    fn default() -> Self {
171
        Config {
172
            data: PathBuf::from("data"),
173
            site: Site::default(),
174
            server: Server::default(),
175
            tls: Tls::default(),
176
            auth: Auth::default(),
177
            registration: Registration::default(),
178
            email: Email::default(),
179
            limits: Limits::default(),
180
            retention: Retention::default(),
181
        }
182
    }
183
}
184
185
impl Config {
186
    /// Read `<data>/hub.toml` if present, then apply environment overrides.
187
    pub fn load(data: &Path) -> anyhow::Result<Config> {
188
        let path = data.join("hub.toml");
189
        let mut cfg = match std::fs::read_to_string(&path) {
190
            Ok(text) => Config::parse(&text)?,
191
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
192
            Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())),
193
        };
194
        cfg.data = data.to_path_buf();
195
        cfg.apply_env(|k| std::env::var(k).ok());
196
        Ok(cfg)
197
    }
198
199
    pub fn parse(text: &str) -> anyhow::Result<Config> {
200
        Ok(toml::from_str(text)?)
201
    }
202
203
    /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file.
204
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
205
        if let Some(v) = get("OXYHUB_SITE_NAME") {
206
            self.site.name = v;
207
        }
208
        if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") {
209
            self.site.description = v;
210
        }
211
        if let Some(v) = get("OXYHUB_SERVER_BIND") {
212
            self.server.bind = v;
213
        }
214
        if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") {
215
            self.server.tls_bind = v;
216
        }
217
        if let Some(v) = get("OXYHUB_SERVER_BASE_URL") {
218
            self.server.base_url = v;
219
        }
220
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
221
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
222
        }
223
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
224
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
225
        }
226
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
227
            self.registration.mode = v;
228
        }
229
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
230
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
231
        }
232
        if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) {
233
            self.limits.render_limit = v;
234
        }
235
    }
236
237
    pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
238
        parse_addr(&self.server.bind)
239
    }
240
241
    pub fn tls_bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> {
242
        parse_addr(&self.server.tls_bind)
243
    }
244
245
    pub fn tls_cert(&self) -> PathBuf {
246
        self.data.join(&self.tls.cert)
247
    }
248
249
    pub fn tls_key(&self) -> PathBuf {
250
        self.data.join(&self.tls.key)
251
    }
252
253
    pub fn repos_dir(&self) -> PathBuf {
254
        self.data.join("repos")
255
    }
256
257
    pub fn keys_dir(&self) -> PathBuf {
258
        self.data.join("keys")
259
    }
260
261
    pub fn db_path(&self) -> PathBuf {
262
        self.data.join("hub.db")
263
    }
264
265
    /// The base URL without a trailing slash.
266
    pub fn base_url(&self) -> &str {
267
        self.server.base_url.trim_end_matches('/')
268
    }
269
}
270
271
fn parse_addr(s: &str) -> anyhow::Result<Option<SocketAddr>> {
272
    if s.trim().is_empty() {
273
        return Ok(None);
274
    }
275
    Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?))
276
}
277
278
#[cfg(test)]
279
mod tests {
280
    use super::*;
281
282
    #[test]
283
    fn byte_sizes() {
284
        assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20)));
285
        assert_eq!(ByteSize::parse("512"), Some(ByteSize(512)));
286
        assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30)));
287
        assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000)));
288
        assert_eq!(ByteSize::parse("lots"), None);
289
    }
290
291
    #[test]
292
    fn file_then_environment() {
293
        let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap();
294
        assert_eq!(cfg.server.bind, "0.0.0.0:7300");
295
        assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20));
296
        assert_eq!(cfg.site.name, "Oxygenhub");
297
        cfg.apply_env(|k| match k {
298
            "OXYHUB_SITE_NAME" => Some("Earth".into()),
299
            "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()),
300
            _ => None,
301
        });
302
        assert_eq!(cfg.site.name, "Earth");
303
        assert_eq!(cfg.auth.push_tokens, ["a", "b"]);
304
        assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence");
305
    }
306
}