1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10pub const DEFAULT_READ_SERVERS: &[&str] = &["https://cdn.iris.to", "https://hashtree.iris.to"];
12
13pub const DEFAULT_WRITE_SERVERS: &[&str] = &["https://upload.iris.to"];
15
16pub const DEFAULT_RELAYS: &[&str] = &[
18 "wss://temp.iris.to",
19 "wss://relay.damus.io",
20 "wss://nos.lol",
21 "wss://relay.primal.net",
22 "wss://offchain.pub",
23];
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct Config {
28 #[serde(default)]
29 pub server: ServerConfig,
30 #[serde(default)]
31 pub storage: StorageConfig,
32 #[serde(default)]
33 pub nostr: NostrConfig,
34 #[serde(default)]
35 pub blossom: BlossomConfig,
36 #[serde(default)]
37 pub sync: SyncConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ServerConfig {
43 #[serde(default = "default_bind_address")]
44 pub bind_address: String,
45 #[serde(default = "default_true")]
46 pub enable_auth: bool,
47 #[serde(default)]
48 pub public_writes: bool,
49 #[serde(default)]
50 pub enable_webrtc: bool,
51 #[serde(default)]
52 pub stun_port: u16,
53}
54
55impl Default for ServerConfig {
56 fn default() -> Self {
57 Self {
58 bind_address: default_bind_address(),
59 enable_auth: true,
60 public_writes: false,
61 enable_webrtc: false,
62 stun_port: 0,
63 }
64 }
65}
66
67fn default_bind_address() -> String {
68 "127.0.0.1:8080".to_string()
69}
70
71fn default_true() -> bool {
72 true
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum StorageBackend {
79 Fs,
81 Lmdb,
83}
84
85impl Default for StorageBackend {
86 fn default() -> Self {
87 Self::Fs
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct StorageConfig {
94 #[serde(default)]
96 pub backend: StorageBackend,
97 #[serde(default = "default_data_dir")]
98 pub data_dir: String,
99 #[serde(default = "default_max_size_gb")]
100 pub max_size_gb: u64,
101 #[serde(default)]
102 pub s3: Option<S3Config>,
103}
104
105impl Default for StorageConfig {
106 fn default() -> Self {
107 Self {
108 backend: StorageBackend::default(),
109 data_dir: default_data_dir(),
110 max_size_gb: default_max_size_gb(),
111 s3: None,
112 }
113 }
114}
115
116fn default_data_dir() -> String {
117 get_hashtree_dir()
118 .join("data")
119 .to_string_lossy()
120 .to_string()
121}
122
123fn default_max_size_gb() -> u64 {
124 10
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct S3Config {
130 pub endpoint: String,
131 pub bucket: String,
132 pub region: String,
133 #[serde(default)]
134 pub prefix: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct NostrConfig {
140 #[serde(default = "default_relays")]
141 pub relays: Vec<String>,
142 #[serde(default)]
143 pub allowed_npubs: Vec<String>,
144 #[serde(default = "default_nostr_db_max_size_gb")]
146 pub db_max_size_gb: u64,
147 #[serde(default = "default_nostr_spambox_max_size_gb")]
150 pub spambox_max_size_gb: u64,
151}
152
153impl Default for NostrConfig {
154 fn default() -> Self {
155 Self {
156 relays: default_relays(),
157 allowed_npubs: vec![],
158 db_max_size_gb: default_nostr_db_max_size_gb(),
159 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
160 }
161 }
162}
163
164fn default_nostr_db_max_size_gb() -> u64 {
165 10
166}
167
168fn default_nostr_spambox_max_size_gb() -> u64 {
169 1
170}
171
172fn default_relays() -> Vec<String> {
173 DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct BlossomConfig {
179 #[serde(default)]
181 pub servers: Vec<String>,
182 #[serde(default = "default_read_servers")]
184 pub read_servers: Vec<String>,
185 #[serde(default = "default_write_servers")]
187 pub write_servers: Vec<String>,
188 #[serde(default = "default_max_upload_mb")]
190 pub max_upload_mb: u64,
191 #[serde(default)]
193 pub force_upload: bool,
194}
195
196impl Default for BlossomConfig {
197 fn default() -> Self {
198 Self {
199 servers: vec![],
200 read_servers: default_read_servers(),
201 write_servers: default_write_servers(),
202 max_upload_mb: default_max_upload_mb(),
203 force_upload: false,
204 }
205 }
206}
207
208fn default_read_servers() -> Vec<String> {
209 DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect()
210}
211
212fn default_write_servers() -> Vec<String> {
213 DEFAULT_WRITE_SERVERS
214 .iter()
215 .map(|s| s.to_string())
216 .collect()
217}
218
219fn default_max_upload_mb() -> u64 {
220 100
221}
222
223impl BlossomConfig {
224 pub fn all_read_servers(&self) -> Vec<String> {
226 let mut servers = self.servers.clone();
227 servers.extend(self.read_servers.clone());
228 servers.sort();
229 servers.dedup();
230 servers
231 }
232
233 pub fn all_write_servers(&self) -> Vec<String> {
235 let mut servers = self.servers.clone();
236 servers.extend(self.write_servers.clone());
237 servers.sort();
238 servers.dedup();
239 servers
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct SyncConfig {
246 #[serde(default)]
247 pub enabled: bool,
248 #[serde(default = "default_true")]
249 pub sync_own: bool,
250 #[serde(default)]
251 pub sync_followed: bool,
252 #[serde(default = "default_max_concurrent")]
253 pub max_concurrent: usize,
254 #[serde(default = "default_webrtc_timeout_ms")]
255 pub webrtc_timeout_ms: u64,
256 #[serde(default = "default_blossom_timeout_ms")]
257 pub blossom_timeout_ms: u64,
258}
259
260impl Default for SyncConfig {
261 fn default() -> Self {
262 Self {
263 enabled: false,
264 sync_own: true,
265 sync_followed: false,
266 max_concurrent: default_max_concurrent(),
267 webrtc_timeout_ms: default_webrtc_timeout_ms(),
268 blossom_timeout_ms: default_blossom_timeout_ms(),
269 }
270 }
271}
272
273fn default_max_concurrent() -> usize {
274 4
275}
276
277fn default_webrtc_timeout_ms() -> u64 {
278 5000
279}
280
281fn default_blossom_timeout_ms() -> u64 {
282 10000
283}
284
285impl Config {
286 pub fn load() -> Result<Self> {
288 let config_path = get_config_path();
289
290 if config_path.exists() {
291 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
292 toml::from_str(&content).context("Failed to parse config file")
293 } else {
294 let config = Config::default();
295 config.save()?;
296 Ok(config)
297 }
298 }
299
300 pub fn load_or_default() -> Self {
302 Self::load().unwrap_or_default()
303 }
304
305 pub fn save(&self) -> Result<()> {
307 let config_path = get_config_path();
308
309 if let Some(parent) = config_path.parent() {
310 fs::create_dir_all(parent)?;
311 }
312
313 let content = toml::to_string_pretty(self)?;
314 fs::write(&config_path, content)?;
315
316 Ok(())
317 }
318}
319
320pub fn get_hashtree_dir() -> PathBuf {
322 if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
323 return PathBuf::from(dir);
324 }
325 dirs::home_dir()
326 .unwrap_or_else(|| PathBuf::from("."))
327 .join(".hashtree")
328}
329
330pub fn get_config_path() -> PathBuf {
332 get_hashtree_dir().join("config.toml")
333}
334
335pub fn get_keys_path() -> PathBuf {
337 get_hashtree_dir().join("keys")
338}
339
340#[derive(Debug, Clone)]
342pub struct KeyEntry {
343 pub secret: String,
345 pub alias: Option<String>,
347}
348
349pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
353 let mut entries = Vec::new();
354 for line in content.lines() {
355 let line = line.trim();
356 if line.is_empty() || line.starts_with('#') {
357 continue;
358 }
359 let parts: Vec<&str> = line.splitn(2, ' ').collect();
360 let secret = parts[0].to_string();
361 let alias = parts.get(1).map(|s| s.trim().to_string());
362 entries.push(KeyEntry { secret, alias });
363 }
364 entries
365}
366
367pub fn read_first_key() -> Option<String> {
370 let keys_path = get_keys_path();
371 let content = std::fs::read_to_string(&keys_path).ok()?;
372 let entries = parse_keys_file(&content);
373 entries.into_iter().next().map(|e| e.secret)
374}
375
376pub fn get_auth_cookie_path() -> PathBuf {
378 get_hashtree_dir().join("auth.cookie")
379}
380
381pub fn get_data_dir() -> PathBuf {
384 if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
385 return PathBuf::from(dir);
386 }
387 let config = Config::load_or_default();
388 PathBuf::from(&config.storage.data_dir)
389}
390
391pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
393 use std::net::{SocketAddr, TcpStream};
394 use std::time::Duration;
395
396 let port = local_daemon_port(bind_address);
397 if port == 0 {
398 return None;
399 }
400
401 let addr = SocketAddr::from(([127, 0, 0, 1], port));
402 let timeout = Duration::from_millis(100);
403 TcpStream::connect_timeout(&addr, timeout).ok()?;
404 Some(format!("http://127.0.0.1:{}", port))
405}
406
407pub fn detect_local_relay_urls(bind_address: Option<&str>) -> Vec<String> {
409 let mut relays = Vec::new();
410
411 if let Some(list) =
412 parse_env_list("NOSTR_LOCAL_RELAY").or_else(|| parse_env_list("HTREE_LOCAL_RELAY"))
413 {
414 for raw in list {
415 if let Some(url) = normalize_relay_url(&raw) {
416 relays.push(url);
417 }
418 }
419 }
420
421 if let Some(base) = detect_local_daemon_url(bind_address) {
422 if let Some(ws) = normalize_relay_url(&base) {
423 let ws = ws.trim_end_matches('/');
424 let ws = if ws.contains("/ws") {
425 ws.to_string()
426 } else {
427 format!("{}/ws", ws)
428 };
429 relays.push(ws);
430 }
431 }
432
433 let mut ports = parse_env_ports("NOSTR_LOCAL_RELAY_PORTS");
434 if ports.is_empty() {
435 ports.push(4869);
436 }
437
438 let daemon_port = local_daemon_port(bind_address);
439 for port in ports {
440 if port == 0 || port == daemon_port {
441 continue;
442 }
443 if local_port_open(port) {
444 relays.push(format!("ws://127.0.0.1:{port}"));
445 }
446 }
447
448 dedupe_relays(relays)
449}
450
451pub fn resolve_relays(config_relays: &[String], bind_address: Option<&str>) -> Vec<String> {
453 let mut base = match parse_env_list("NOSTR_RELAYS") {
454 Some(list) => list,
455 None => config_relays.to_vec(),
456 };
457
458 base = base
459 .into_iter()
460 .filter_map(|r| normalize_relay_url(&r))
461 .collect();
462
463 if !prefer_local_relay() {
464 return dedupe_relays(base);
465 }
466
467 let mut combined = detect_local_relay_urls(bind_address);
468 combined.extend(base);
469 dedupe_relays(combined)
470}
471
472fn local_daemon_port(bind_address: Option<&str>) -> u16 {
473 let default_port = 8080;
474 let Some(addr) = bind_address else {
475 return default_port;
476 };
477 if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
478 return sock.port();
479 }
480 if let Some((_, port_str)) = addr.rsplit_once(':') {
481 if let Ok(port) = port_str.parse::<u16>() {
482 return port;
483 }
484 }
485 default_port
486}
487
488fn prefer_local_relay() -> bool {
489 for key in ["NOSTR_PREFER_LOCAL", "HTREE_PREFER_LOCAL_RELAY"] {
490 if let Ok(val) = std::env::var(key) {
491 let val = val.trim().to_lowercase();
492 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
493 }
494 }
495 true
496}
497
498fn parse_env_list(var: &str) -> Option<Vec<String>> {
499 let value = std::env::var(var).ok()?;
500 let mut items = Vec::new();
501 for part in value.split(|c| c == ',' || c == ';' || c == '\n' || c == '\t' || c == ' ') {
502 let trimmed = part.trim();
503 if !trimmed.is_empty() {
504 items.push(trimmed.to_string());
505 }
506 }
507 if items.is_empty() {
508 None
509 } else {
510 Some(items)
511 }
512}
513
514fn parse_env_ports(var: &str) -> Vec<u16> {
515 let Some(list) = parse_env_list(var) else {
516 return Vec::new();
517 };
518 list.into_iter()
519 .filter_map(|item| item.parse::<u16>().ok())
520 .collect()
521}
522
523fn normalize_relay_url(raw: &str) -> Option<String> {
524 let trimmed = raw.trim();
525 if trimmed.is_empty() {
526 return None;
527 }
528 let trimmed = trimmed.trim_end_matches('/');
529 let lower = trimmed.to_lowercase();
530 if lower.starts_with("ws://") || lower.starts_with("wss://") {
531 return Some(trimmed.to_string());
532 }
533 if lower.starts_with("http://") {
534 return Some(format!("ws://{}", &trimmed[7..]));
535 }
536 if lower.starts_with("https://") {
537 return Some(format!("wss://{}", &trimmed[8..]));
538 }
539 Some(format!("ws://{}", trimmed))
540}
541
542fn local_port_open(port: u16) -> bool {
543 use std::net::{SocketAddr, TcpStream};
544 use std::time::Duration;
545
546 let addr = SocketAddr::from(([127, 0, 0, 1], port));
547 let timeout = Duration::from_millis(100);
548 TcpStream::connect_timeout(&addr, timeout).is_ok()
549}
550
551fn dedupe_relays(relays: Vec<String>) -> Vec<String> {
552 use std::collections::HashSet;
553 let mut seen = HashSet::new();
554 let mut out = Vec::new();
555 for relay in relays {
556 let key = relay.trim_end_matches('/').to_lowercase();
557 if seen.insert(key) {
558 out.push(relay);
559 }
560 }
561 out
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use std::net::TcpListener;
568 use std::sync::Mutex;
569
570 static ENV_LOCK: Mutex<()> = Mutex::new(());
571
572 struct EnvGuard {
573 key: &'static str,
574 prev: Option<String>,
575 }
576
577 impl EnvGuard {
578 fn set(key: &'static str, value: &str) -> Self {
579 let prev = std::env::var(key).ok();
580 std::env::set_var(key, value);
581 Self { key, prev }
582 }
583
584 fn clear(key: &'static str) -> Self {
585 let prev = std::env::var(key).ok();
586 std::env::remove_var(key);
587 Self { key, prev }
588 }
589 }
590
591 impl Drop for EnvGuard {
592 fn drop(&mut self) {
593 if let Some(prev) = &self.prev {
594 std::env::set_var(self.key, prev);
595 } else {
596 std::env::remove_var(self.key);
597 }
598 }
599 }
600
601 #[test]
602 fn test_default_config() {
603 let config = Config::default();
604 assert!(!config.blossom.read_servers.is_empty());
605 assert!(!config.blossom.write_servers.is_empty());
606 assert!(!config.nostr.relays.is_empty());
607 }
608
609 #[test]
610 fn test_parse_empty_config() {
611 let config: Config = toml::from_str("").unwrap();
612 assert!(!config.blossom.read_servers.is_empty());
613 }
614
615 #[test]
616 fn test_parse_partial_config() {
617 let toml = r#"
618[blossom]
619write_servers = ["https://custom.server"]
620"#;
621 let config: Config = toml::from_str(toml).unwrap();
622 assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
623 assert!(!config.blossom.read_servers.is_empty());
624 }
625
626 #[test]
627 fn test_all_servers() {
628 let mut config = BlossomConfig::default();
629 config.servers = vec!["https://legacy.server".to_string()];
630
631 let read = config.all_read_servers();
632 assert!(read.contains(&"https://legacy.server".to_string()));
633 assert!(read.contains(&"https://cdn.iris.to".to_string()));
634
635 let write = config.all_write_servers();
636 assert!(write.contains(&"https://legacy.server".to_string()));
637 assert!(write.contains(&"https://upload.iris.to".to_string()));
638 }
639
640 #[test]
641 fn test_storage_backend_default() {
642 let config = Config::default();
643 assert_eq!(config.storage.backend, StorageBackend::Fs);
644 }
645
646 #[test]
647 fn test_storage_backend_lmdb() {
648 let toml = r#"
649[storage]
650backend = "lmdb"
651"#;
652 let config: Config = toml::from_str(toml).unwrap();
653 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
654 }
655
656 #[test]
657 fn test_storage_backend_fs_explicit() {
658 let toml = r#"
659[storage]
660backend = "fs"
661"#;
662 let config: Config = toml::from_str(toml).unwrap();
663 assert_eq!(config.storage.backend, StorageBackend::Fs);
664 }
665
666 #[test]
667 fn test_parse_keys_file() {
668 let content = r#"
669nsec1abc123 self
670# comment line
671nsec1def456 work
672
673nsec1ghi789
674"#;
675 let entries = parse_keys_file(content);
676 assert_eq!(entries.len(), 3);
677 assert_eq!(entries[0].secret, "nsec1abc123");
678 assert_eq!(entries[0].alias, Some("self".to_string()));
679 assert_eq!(entries[1].secret, "nsec1def456");
680 assert_eq!(entries[1].alias, Some("work".to_string()));
681 assert_eq!(entries[2].secret, "nsec1ghi789");
682 assert_eq!(entries[2].alias, None);
683 }
684
685 #[test]
686 fn test_local_daemon_port_default() {
687 assert_eq!(local_daemon_port(None), 8080);
688 }
689
690 #[test]
691 fn test_local_daemon_port_parses_ipv4() {
692 assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
693 }
694
695 #[test]
696 fn test_local_daemon_port_parses_anyhost() {
697 assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
698 }
699
700 #[test]
701 fn test_local_daemon_port_parses_ipv6() {
702 assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
703 }
704
705 #[test]
706 fn test_local_daemon_port_parses_hostname() {
707 assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
708 }
709
710 #[test]
711 fn test_local_daemon_port_invalid() {
712 assert_eq!(local_daemon_port(Some("localhost")), 8080);
713 }
714
715 #[test]
716 fn test_resolve_relays_prefers_local() {
717 let _lock = ENV_LOCK.lock().unwrap();
718 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
719 let port = listener.local_addr().unwrap().port();
720
721 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "1");
722 let _ports = EnvGuard::set("NOSTR_LOCAL_RELAY_PORTS", &port.to_string());
723 let _relays = EnvGuard::clear("NOSTR_RELAYS");
724
725 let base = vec!["wss://relay.example".to_string()];
726 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
727
728 assert!(!resolved.is_empty());
729 assert_eq!(resolved[0], format!("ws://127.0.0.1:{port}"));
730 assert!(resolved.contains(&"wss://relay.example".to_string()));
731 }
732
733 #[test]
734 fn test_resolve_relays_env_override() {
735 let _lock = ENV_LOCK.lock().unwrap();
736 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
737 let _relays = EnvGuard::set("NOSTR_RELAYS", "wss://relay.one,wss://relay.two");
738
739 let base = vec!["wss://relay.example".to_string()];
740 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
741
742 assert_eq!(
743 resolved,
744 vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()]
745 );
746 }
747}