1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10pub const DEFAULT_READ_SERVERS: &[&str] = &[
12 "https://cdn.iris.to",
13 "https://hashtree.iris.to",
14];
15
16pub const DEFAULT_WRITE_SERVERS: &[&str] = &[
18 "https://upload.iris.to",
19];
20
21pub const DEFAULT_RELAYS: &[&str] = &[
23 "wss://temp.iris.to",
24 "wss://relay.damus.io",
25 "wss://nos.lol",
26 "wss://relay.primal.net",
27];
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct Config {
32 #[serde(default)]
33 pub server: ServerConfig,
34 #[serde(default)]
35 pub storage: StorageConfig,
36 #[serde(default)]
37 pub nostr: NostrConfig,
38 #[serde(default)]
39 pub blossom: BlossomConfig,
40 #[serde(default)]
41 pub sync: SyncConfig,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ServerConfig {
47 #[serde(default = "default_bind_address")]
48 pub bind_address: String,
49 #[serde(default = "default_true")]
50 pub enable_auth: bool,
51 #[serde(default)]
52 pub public_writes: bool,
53 #[serde(default)]
54 pub enable_webrtc: bool,
55 #[serde(default)]
56 pub stun_port: u16,
57}
58
59impl Default for ServerConfig {
60 fn default() -> Self {
61 Self {
62 bind_address: default_bind_address(),
63 enable_auth: true,
64 public_writes: false,
65 enable_webrtc: false,
66 stun_port: 0,
67 }
68 }
69}
70
71fn default_bind_address() -> String {
72 "127.0.0.1:8080".to_string()
73}
74
75fn default_true() -> bool {
76 true
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum StorageBackend {
83 Fs,
85 Lmdb,
87}
88
89impl Default for StorageBackend {
90 fn default() -> Self {
91 Self::Fs
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct StorageConfig {
98 #[serde(default)]
100 pub backend: StorageBackend,
101 #[serde(default = "default_data_dir")]
102 pub data_dir: String,
103 #[serde(default = "default_max_size_gb")]
104 pub max_size_gb: u64,
105 #[serde(default)]
106 pub s3: Option<S3Config>,
107}
108
109impl Default for StorageConfig {
110 fn default() -> Self {
111 Self {
112 backend: StorageBackend::default(),
113 data_dir: default_data_dir(),
114 max_size_gb: default_max_size_gb(),
115 s3: None,
116 }
117 }
118}
119
120fn default_data_dir() -> String {
121 get_hashtree_dir()
122 .join("data")
123 .to_string_lossy()
124 .to_string()
125}
126
127fn default_max_size_gb() -> u64 {
128 10
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct S3Config {
134 pub endpoint: String,
135 pub bucket: String,
136 pub region: String,
137 #[serde(default)]
138 pub prefix: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct NostrConfig {
144 #[serde(default = "default_relays")]
145 pub relays: Vec<String>,
146 #[serde(default)]
147 pub allowed_npubs: Vec<String>,
148}
149
150impl Default for NostrConfig {
151 fn default() -> Self {
152 Self {
153 relays: default_relays(),
154 allowed_npubs: vec![],
155 }
156 }
157}
158
159fn default_relays() -> Vec<String> {
160 DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct BlossomConfig {
166 #[serde(default)]
168 pub servers: Vec<String>,
169 #[serde(default = "default_read_servers")]
171 pub read_servers: Vec<String>,
172 #[serde(default = "default_write_servers")]
174 pub write_servers: Vec<String>,
175 #[serde(default = "default_max_upload_mb")]
177 pub max_upload_mb: u64,
178 #[serde(default)]
180 pub force_upload: bool,
181}
182
183impl Default for BlossomConfig {
184 fn default() -> Self {
185 Self {
186 servers: vec![],
187 read_servers: default_read_servers(),
188 write_servers: default_write_servers(),
189 max_upload_mb: default_max_upload_mb(),
190 force_upload: false,
191 }
192 }
193}
194
195fn default_read_servers() -> Vec<String> {
196 DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect()
197}
198
199fn default_write_servers() -> Vec<String> {
200 DEFAULT_WRITE_SERVERS.iter().map(|s| s.to_string()).collect()
201}
202
203fn default_max_upload_mb() -> u64 {
204 100
205}
206
207impl BlossomConfig {
208 pub fn all_read_servers(&self) -> Vec<String> {
210 let mut servers = self.servers.clone();
211 servers.extend(self.read_servers.clone());
212 servers.sort();
213 servers.dedup();
214 servers
215 }
216
217 pub fn all_write_servers(&self) -> Vec<String> {
219 let mut servers = self.servers.clone();
220 servers.extend(self.write_servers.clone());
221 servers.sort();
222 servers.dedup();
223 servers
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct SyncConfig {
230 #[serde(default)]
231 pub enabled: bool,
232 #[serde(default = "default_true")]
233 pub sync_own: bool,
234 #[serde(default)]
235 pub sync_followed: bool,
236 #[serde(default = "default_max_concurrent")]
237 pub max_concurrent: usize,
238 #[serde(default = "default_webrtc_timeout_ms")]
239 pub webrtc_timeout_ms: u64,
240 #[serde(default = "default_blossom_timeout_ms")]
241 pub blossom_timeout_ms: u64,
242}
243
244impl Default for SyncConfig {
245 fn default() -> Self {
246 Self {
247 enabled: false,
248 sync_own: true,
249 sync_followed: false,
250 max_concurrent: default_max_concurrent(),
251 webrtc_timeout_ms: default_webrtc_timeout_ms(),
252 blossom_timeout_ms: default_blossom_timeout_ms(),
253 }
254 }
255}
256
257fn default_max_concurrent() -> usize {
258 4
259}
260
261fn default_webrtc_timeout_ms() -> u64 {
262 5000
263}
264
265fn default_blossom_timeout_ms() -> u64 {
266 10000
267}
268
269impl Config {
270 pub fn load() -> Result<Self> {
272 let config_path = get_config_path();
273
274 if config_path.exists() {
275 let content = fs::read_to_string(&config_path)
276 .context("Failed to read config file")?;
277 toml::from_str(&content).context("Failed to parse config file")
278 } else {
279 let config = Config::default();
280 config.save()?;
281 Ok(config)
282 }
283 }
284
285 pub fn load_or_default() -> Self {
287 Self::load().unwrap_or_default()
288 }
289
290 pub fn save(&self) -> Result<()> {
292 let config_path = get_config_path();
293
294 if let Some(parent) = config_path.parent() {
295 fs::create_dir_all(parent)?;
296 }
297
298 let content = toml::to_string_pretty(self)?;
299 fs::write(&config_path, content)?;
300
301 Ok(())
302 }
303}
304
305pub fn get_hashtree_dir() -> PathBuf {
307 if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
308 return PathBuf::from(dir);
309 }
310 dirs::home_dir()
311 .unwrap_or_else(|| PathBuf::from("."))
312 .join(".hashtree")
313}
314
315pub fn get_config_path() -> PathBuf {
317 get_hashtree_dir().join("config.toml")
318}
319
320pub fn get_keys_path() -> PathBuf {
322 get_hashtree_dir().join("keys")
323}
324
325#[derive(Debug, Clone)]
327pub struct KeyEntry {
328 pub secret: String,
330 pub alias: Option<String>,
332}
333
334pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
338 let mut entries = Vec::new();
339 for line in content.lines() {
340 let line = line.trim();
341 if line.is_empty() || line.starts_with('#') {
342 continue;
343 }
344 let parts: Vec<&str> = line.splitn(2, ' ').collect();
345 let secret = parts[0].to_string();
346 let alias = parts.get(1).map(|s| s.trim().to_string());
347 entries.push(KeyEntry { secret, alias });
348 }
349 entries
350}
351
352pub fn read_first_key() -> Option<String> {
355 let keys_path = get_keys_path();
356 let content = std::fs::read_to_string(&keys_path).ok()?;
357 let entries = parse_keys_file(&content);
358 entries.into_iter().next().map(|e| e.secret)
359}
360
361pub fn get_auth_cookie_path() -> PathBuf {
363 get_hashtree_dir().join("auth.cookie")
364}
365
366pub fn get_data_dir() -> PathBuf {
369 if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
370 return PathBuf::from(dir);
371 }
372 let config = Config::load_or_default();
373 PathBuf::from(&config.storage.data_dir)
374}
375
376pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
378 use std::net::{SocketAddr, TcpStream};
379 use std::time::Duration;
380
381 let port = local_daemon_port(bind_address);
382 if port == 0 {
383 return None;
384 }
385
386 let addr = SocketAddr::from(([127, 0, 0, 1], port));
387 let timeout = Duration::from_millis(100);
388 TcpStream::connect_timeout(&addr, timeout).ok()?;
389 Some(format!("http://127.0.0.1:{}", port))
390}
391
392fn local_daemon_port(bind_address: Option<&str>) -> u16 {
393 let default_port = 8080;
394 let Some(addr) = bind_address else {
395 return default_port;
396 };
397 if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
398 return sock.port();
399 }
400 if let Some((_, port_str)) = addr.rsplit_once(':') {
401 if let Ok(port) = port_str.parse::<u16>() {
402 return port;
403 }
404 }
405 default_port
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_default_config() {
414 let config = Config::default();
415 assert!(!config.blossom.read_servers.is_empty());
416 assert!(!config.blossom.write_servers.is_empty());
417 assert!(!config.nostr.relays.is_empty());
418 }
419
420 #[test]
421 fn test_parse_empty_config() {
422 let config: Config = toml::from_str("").unwrap();
423 assert!(!config.blossom.read_servers.is_empty());
424 }
425
426 #[test]
427 fn test_parse_partial_config() {
428 let toml = r#"
429[blossom]
430write_servers = ["https://custom.server"]
431"#;
432 let config: Config = toml::from_str(toml).unwrap();
433 assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
434 assert!(!config.blossom.read_servers.is_empty());
435 }
436
437 #[test]
438 fn test_all_servers() {
439 let mut config = BlossomConfig::default();
440 config.servers = vec!["https://legacy.server".to_string()];
441
442 let read = config.all_read_servers();
443 assert!(read.contains(&"https://legacy.server".to_string()));
444 assert!(read.contains(&"https://cdn.iris.to".to_string()));
445
446 let write = config.all_write_servers();
447 assert!(write.contains(&"https://legacy.server".to_string()));
448 assert!(write.contains(&"https://upload.iris.to".to_string()));
449 }
450
451 #[test]
452 fn test_storage_backend_default() {
453 let config = Config::default();
454 assert_eq!(config.storage.backend, StorageBackend::Fs);
455 }
456
457 #[test]
458 fn test_storage_backend_lmdb() {
459 let toml = r#"
460[storage]
461backend = "lmdb"
462"#;
463 let config: Config = toml::from_str(toml).unwrap();
464 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
465 }
466
467 #[test]
468 fn test_storage_backend_fs_explicit() {
469 let toml = r#"
470[storage]
471backend = "fs"
472"#;
473 let config: Config = toml::from_str(toml).unwrap();
474 assert_eq!(config.storage.backend, StorageBackend::Fs);
475 }
476
477 #[test]
478 fn test_parse_keys_file() {
479 let content = r#"
480nsec1abc123 self
481# comment line
482nsec1def456 work
483
484nsec1ghi789
485"#;
486 let entries = parse_keys_file(content);
487 assert_eq!(entries.len(), 3);
488 assert_eq!(entries[0].secret, "nsec1abc123");
489 assert_eq!(entries[0].alias, Some("self".to_string()));
490 assert_eq!(entries[1].secret, "nsec1def456");
491 assert_eq!(entries[1].alias, Some("work".to_string()));
492 assert_eq!(entries[2].secret, "nsec1ghi789");
493 assert_eq!(entries[2].alias, None);
494 }
495
496 #[test]
497 fn test_local_daemon_port_default() {
498 assert_eq!(local_daemon_port(None), 8080);
499 }
500
501 #[test]
502 fn test_local_daemon_port_parses_ipv4() {
503 assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
504 }
505
506 #[test]
507 fn test_local_daemon_port_parses_anyhost() {
508 assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
509 }
510
511 #[test]
512 fn test_local_daemon_port_parses_ipv6() {
513 assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
514 }
515
516 #[test]
517 fn test_local_daemon_port_parses_hostname() {
518 assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
519 }
520
521 #[test]
522 fn test_local_daemon_port_invalid() {
523 assert_eq!(local_daemon_port(Some("localhost")), 8080);
524 }
525}