1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::logging::DEFAULT_LOG_LEVEL;
6use crate::util::parse_hex_16;
7use crate::Result;
8
9#[derive(Debug, Clone)]
10pub struct ServerConfig {
11 pub dir: PathBuf,
12 pub reticulum_dir: Option<PathBuf>,
13 pub repositories_dir: PathBuf,
14 pub identity_path: PathBuf,
15 pub client_identity_path: PathBuf,
16 pub node_name: String,
17 pub announce_interval_secs: u64,
18 pub serve_nomadnet: bool,
19 pub templates_dir: PathBuf,
20 pub unicode_icons: bool,
21 pub record_stats: bool,
22 pub stats_ignore_identities: Vec<[u8; 16]>,
23 pub allow_read: Vec<String>,
24 pub allow_write: Vec<String>,
25 pub allow_create: Vec<String>,
26 pub allow_stats: Vec<String>,
27 pub allow_release: Vec<String>,
28 pub allow_interact: Vec<String>,
29 pub allow_admin: Vec<String>,
30 pub log_level: u8,
31}
32
33#[derive(Debug, Clone)]
34pub struct ClientConfig {
35 pub dir: PathBuf,
36 pub reticulum_dir: Option<PathBuf>,
37 pub identity_path: PathBuf,
38 pub connect_timeout_secs: u64,
39 pub request_timeout_secs: u64,
40 pub log_level: u8,
41}
42
43impl ServerConfig {
44 pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
45 fs::create_dir_all(&dir)?;
46 let path = dir.join("server_config");
47 if !path.exists() {
48 fs::write(&path, default_server_config())?;
49 return Ok((Self::defaults(dir, reticulum_dir), true));
50 }
51 let ini = parse_ini(&fs::read_to_string(&path)?)?;
52 let mut cfg = Self::defaults(dir, reticulum_dir);
53 if let Some(v) = get(&ini, "repositories", "path") {
54 cfg.repositories_dir = resolve_path(&cfg.dir, v);
55 }
56 if let Some(v) = get(&ini, "rngit", "identity") {
57 cfg.identity_path = resolve_path(&cfg.dir, v);
58 }
59 if let Some(v) = get(&ini, "rngit", "client_identity") {
60 cfg.client_identity_path = resolve_path(&cfg.dir, v);
61 }
62 if let Some(v) = get(&ini, "rngit", "node_name") {
63 cfg.node_name = v.to_string();
64 }
65 if let Some(v) = get(&ini, "rngit", "announce_interval") {
66 cfg.announce_interval_secs = v.parse().unwrap_or(cfg.announce_interval_secs);
67 }
68 if let Some(v) = get(&ini, "rngit", "record_stats") {
69 cfg.record_stats = parse_bool(v, cfg.record_stats);
70 }
71 if let Some(v) = get(&ini, "rngit", "stats_ignore_identities") {
72 cfg.stats_ignore_identities = split_list(v)
73 .into_iter()
74 .filter_map(|value| parse_hex_16(&value).ok())
75 .collect();
76 }
77 if let Some(v) = get(&ini, "pages", "serve_nomadnet") {
78 cfg.serve_nomadnet = parse_bool(v, cfg.serve_nomadnet);
79 }
80 if let Some(v) = get(&ini, "pages", "templates_dir") {
81 cfg.templates_dir = resolve_path(&cfg.dir, v);
82 }
83 if let Some(v) = get(&ini, "pages", "unicode_icons") {
84 cfg.unicode_icons = parse_bool(v, cfg.unicode_icons);
85 }
86 if let Some(v) = get(&ini, "logging", "loglevel") {
87 cfg.log_level = parse_log_level(v, cfg.log_level);
88 }
89 cfg.allow_read = split_list(get(&ini, "access", "read").unwrap_or("all"));
90 cfg.allow_write = split_list(get(&ini, "access", "write").unwrap_or("none"));
91 cfg.allow_create = split_list(get(&ini, "access", "create").unwrap_or("none"));
92 cfg.allow_stats = split_list(get(&ini, "access", "stats").unwrap_or("none"));
93 cfg.allow_release = split_list(get(&ini, "access", "release").unwrap_or("none"));
94 cfg.allow_interact = split_list(get(&ini, "access", "interact").unwrap_or("none"));
95 cfg.allow_admin = split_list(get(&ini, "access", "admin").unwrap_or("none"));
96 Ok((cfg, false))
97 }
98
99 fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
100 Self {
101 repositories_dir: dir.join("repositories"),
102 identity_path: dir.join("repositories_identity"),
103 client_identity_path: dir.join("client_identity"),
104 node_name: "Anonymous Git Node".to_string(),
105 dir: dir.clone(),
106 reticulum_dir,
107 announce_interval_secs: 300,
108 serve_nomadnet: false,
109 templates_dir: dir.join("templates"),
110 unicode_icons: false,
111 record_stats: false,
112 stats_ignore_identities: Vec::new(),
113 allow_read: vec!["all".to_string()],
114 allow_write: vec!["none".to_string()],
115 allow_create: vec!["none".to_string()],
116 allow_stats: vec!["none".to_string()],
117 allow_release: vec!["none".to_string()],
118 allow_interact: vec!["none".to_string()],
119 allow_admin: vec!["none".to_string()],
120 log_level: DEFAULT_LOG_LEVEL,
121 }
122 }
123}
124
125impl ClientConfig {
126 pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
127 fs::create_dir_all(&dir)?;
128 let path = dir.join("client_config");
129 if !path.exists() {
130 fs::write(&path, default_client_config())?;
131 return Ok((Self::defaults(dir, reticulum_dir), true));
132 }
133 let ini = parse_ini(&fs::read_to_string(&path)?)?;
134 let mut cfg = Self::defaults(dir, reticulum_dir);
135 if let Some(v) = get(&ini, "client", "identity") {
136 cfg.identity_path = resolve_path(&cfg.dir, v);
137 }
138 if let Some(v) = get(&ini, "client", "connect_timeout") {
139 cfg.connect_timeout_secs = v.parse().unwrap_or(cfg.connect_timeout_secs);
140 }
141 if let Some(v) = get(&ini, "client", "request_timeout") {
142 cfg.request_timeout_secs = v.parse().unwrap_or(cfg.request_timeout_secs);
143 }
144 if let Some(v) = get(&ini, "logging", "loglevel") {
145 cfg.log_level = parse_log_level(v, cfg.log_level);
146 }
147 Ok((cfg, false))
148 }
149
150 fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
151 Self {
152 identity_path: dir.join("client_identity"),
153 dir,
154 reticulum_dir,
155 connect_timeout_secs: 30,
156 request_timeout_secs: 300,
157 log_level: DEFAULT_LOG_LEVEL,
158 }
159 }
160}
161
162type Ini = BTreeMap<String, BTreeMap<String, String>>;
163
164fn parse_ini(input: &str) -> Result<Ini> {
165 let mut section = "rngit".to_string();
166 let mut out = Ini::new();
167 for raw in input.lines() {
168 let line = raw.split('#').next().unwrap_or("").trim();
169 if line.is_empty() {
170 continue;
171 }
172 if line.starts_with('[') && line.ends_with(']') {
173 section = line[1..line.len() - 1].trim().to_string();
174 continue;
175 }
176 if let Some((key, value)) = line.split_once('=') {
177 out.entry(section.clone())
178 .or_default()
179 .insert(key.trim().to_string(), value.trim().to_string());
180 }
181 }
182 Ok(out)
183}
184
185fn get<'a>(ini: &'a Ini, section: &str, key: &str) -> Option<&'a str> {
186 ini.get(section)?.get(key).map(String::as_str)
187}
188
189fn split_list(value: &str) -> Vec<String> {
190 value
191 .split(',')
192 .map(str::trim)
193 .filter(|v| !v.is_empty())
194 .map(ToOwned::to_owned)
195 .collect()
196}
197
198fn parse_log_level(value: &str, fallback: u8) -> u8 {
199 value
200 .parse::<u8>()
201 .map(|level| level.min(7))
202 .unwrap_or(fallback)
203}
204
205fn parse_bool(value: &str, fallback: bool) -> bool {
206 match value.trim().to_ascii_lowercase().as_str() {
207 "1" | "true" | "yes" | "on" => true,
208 "0" | "false" | "no" | "off" => false,
209 _ => fallback,
210 }
211}
212
213fn expand_home(value: &str) -> PathBuf {
214 if let Some(rest) = value.strip_prefix("~/") {
215 if let Some(home) = std::env::var_os("HOME") {
216 return PathBuf::from(home).join(rest);
217 }
218 }
219 PathBuf::from(value)
220}
221
222fn resolve_path(base: &Path, value: &str) -> PathBuf {
223 let path = expand_home(value);
224 if path.is_absolute() {
225 path
226 } else {
227 base.join(path)
228 }
229}
230
231fn default_server_config() -> &'static str {
232 "[rngit]\nannounce_interval = 300\nidentity = repositories_identity\nclient_identity = client_identity\n# node_name = Anonymous Git Node\n# record_stats = no\n# stats_ignore_identities = 00112233445566778899aabbccddeeff\n\n[repositories]\npath = repositories\n\n[access]\nread = all\nwrite = none\ncreate = none\nstats = none\nrelease = none\ninteract = none\nadmin = none\n\n[pages]\n# serve_nomadnet = no\n# templates_dir = templates\n# unicode_icons = no\n\n[logging]\nloglevel = 4\n"
233}
234
235fn default_client_config() -> &'static str {
236 "[client]\nidentity = client_identity\nconnect_timeout = 30\nrequest_timeout = 300\n\n[logging]\nloglevel = 4\n"
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn parses_sections_and_lists() {
245 let ini = parse_ini("[access]\nread = all, 0011\nwrite = none\ncreate = all\n").unwrap();
246 assert_eq!(get(&ini, "access", "write"), Some("none"));
247 assert_eq!(get(&ini, "access", "create"), Some("all"));
248 assert_eq!(split_list(get(&ini, "access", "read").unwrap()).len(), 2);
249 }
250
251 #[test]
252 fn parses_and_clamps_log_level() {
253 let tmp = tempfile::tempdir().unwrap();
254 fs::write(
255 tmp.path().join("client_config"),
256 "[client]\nrequest_timeout = 5\n[logging]\nloglevel = 99\n",
257 )
258 .unwrap();
259 let (cfg, created) = ClientConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
260 assert!(!created);
261 assert_eq!(cfg.log_level, 7);
262 }
263
264 #[test]
265 fn creates_default_server_config_once() {
266 let tmp = tempfile::tempdir().unwrap();
267 let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
268 assert!(created);
269 let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
270 assert!(!created);
271 }
272
273 #[test]
274 fn parses_nomadnet_page_config() {
275 let tmp = tempfile::tempdir().unwrap();
276 fs::write(
277 tmp.path().join("server_config"),
278 "[rngit]\nnode_name = Public Git Node\n[pages]\nserve_nomadnet = yes\ntemplates_dir = custom_templates\nunicode_icons = yes\n",
279 )
280 .unwrap();
281 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
282 assert!(!created);
283 assert_eq!(cfg.node_name, "Public Git Node");
284 assert!(cfg.serve_nomadnet);
285 assert_eq!(cfg.templates_dir, tmp.path().join("custom_templates"));
286 assert!(cfg.unicode_icons);
287 }
288
289 #[test]
290 fn parses_stats_config_and_permissions() {
291 let tmp = tempfile::tempdir().unwrap();
292 fs::write(
293 tmp.path().join("server_config"),
294 "[rngit]\nrecord_stats = yes\nstats_ignore_identities = 00112233445566778899aabbccddeeff\n[access]\nstats = all, 0102030405060708090a0b0c0d0e0f10\n",
295 )
296 .unwrap();
297
298 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
299 assert!(!created);
300 assert!(cfg.record_stats);
301 assert_eq!(
302 cfg.stats_ignore_identities,
303 vec![[
304 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
305 0xee, 0xff
306 ]]
307 );
308 assert_eq!(
309 cfg.allow_stats,
310 vec![
311 "all".to_string(),
312 "0102030405060708090a0b0c0d0e0f10".to_string()
313 ]
314 );
315 }
316
317 #[test]
318 fn parses_release_permission_config() {
319 let tmp = tempfile::tempdir().unwrap();
320 fs::write(
321 tmp.path().join("server_config"),
322 "[access]\nrelease = all, 0102030405060708090a0b0c0d0e0f10\ninteract = all\nadmin = 00112233445566778899aabbccddeeff\n",
323 )
324 .unwrap();
325
326 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
327 assert!(!created);
328 assert_eq!(
329 cfg.allow_release,
330 vec![
331 "all".to_string(),
332 "0102030405060708090a0b0c0d0e0f10".to_string()
333 ]
334 );
335 assert_eq!(cfg.allow_interact, vec!["all".to_string()]);
336 assert_eq!(
337 cfg.allow_admin,
338 vec!["00112233445566778899aabbccddeeff".to_string()]
339 );
340 }
341
342 #[test]
343 fn missing_pages_section_keeps_nomadnet_disabled() {
344 let tmp = tempfile::tempdir().unwrap();
345 fs::write(tmp.path().join("server_config"), "[rngit]\n").unwrap();
346 let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
347 assert!(!created);
348 assert_eq!(cfg.node_name, "Anonymous Git Node");
349 assert!(!cfg.serve_nomadnet);
350 assert_eq!(cfg.templates_dir, tmp.path().join("templates"));
351 assert!(!cfg.unicode_icons);
352 assert!(!cfg.record_stats);
353 assert!(cfg.stats_ignore_identities.is_empty());
354 assert_eq!(cfg.allow_stats, vec!["none".to_string()]);
355 assert_eq!(cfg.allow_release, vec!["none".to_string()]);
356 assert_eq!(cfg.allow_interact, vec!["none".to_string()]);
357 assert_eq!(cfg.allow_admin, vec!["none".to_string()]);
358 }
359}