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, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum StorageBackend {
79 #[default]
81 Lmdb,
82 Fs,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct StorageConfig {
89 #[serde(default)]
91 pub backend: StorageBackend,
92 #[serde(default = "default_data_dir")]
93 pub data_dir: String,
94 #[serde(default = "default_max_size_gb")]
95 pub max_size_gb: u64,
96 #[serde(default)]
97 pub s3: Option<S3Config>,
98}
99
100impl Default for StorageConfig {
101 fn default() -> Self {
102 Self {
103 backend: StorageBackend::default(),
104 data_dir: default_data_dir(),
105 max_size_gb: default_max_size_gb(),
106 s3: None,
107 }
108 }
109}
110
111fn default_data_dir() -> String {
112 get_hashtree_dir()
113 .join("data")
114 .to_string_lossy()
115 .to_string()
116}
117
118fn default_max_size_gb() -> u64 {
119 10
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct S3Config {
125 pub endpoint: String,
126 pub bucket: String,
127 pub region: String,
128 #[serde(default)]
129 pub prefix: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct NostrConfig {
135 #[serde(default = "default_relays")]
136 pub relays: Vec<String>,
137 #[serde(default)]
138 pub allowed_npubs: Vec<String>,
139 #[serde(default = "default_nostr_db_max_size_gb")]
141 pub db_max_size_gb: u64,
142 #[serde(default = "default_nostr_spambox_max_size_gb")]
145 pub spambox_max_size_gb: u64,
146}
147
148impl Default for NostrConfig {
149 fn default() -> Self {
150 Self {
151 relays: default_relays(),
152 allowed_npubs: vec![],
153 db_max_size_gb: default_nostr_db_max_size_gb(),
154 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
155 }
156 }
157}
158
159fn default_nostr_db_max_size_gb() -> u64 {
160 10
161}
162
163fn default_nostr_spambox_max_size_gb() -> u64 {
164 1
165}
166
167fn default_relays() -> Vec<String> {
168 DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct BlossomConfig {
174 #[serde(default)]
176 pub servers: Vec<String>,
177 #[serde(default = "default_read_servers")]
179 pub read_servers: Vec<String>,
180 #[serde(default = "default_write_servers")]
182 pub write_servers: Vec<String>,
183 #[serde(default = "default_max_upload_mb")]
185 pub max_upload_mb: u64,
186 #[serde(default)]
188 pub force_upload: bool,
189}
190
191impl Default for BlossomConfig {
192 fn default() -> Self {
193 Self {
194 servers: vec![],
195 read_servers: default_read_servers(),
196 write_servers: default_write_servers(),
197 max_upload_mb: default_max_upload_mb(),
198 force_upload: false,
199 }
200 }
201}
202
203fn default_read_servers() -> Vec<String> {
204 DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect()
205}
206
207fn default_write_servers() -> Vec<String> {
208 DEFAULT_WRITE_SERVERS
209 .iter()
210 .map(|s| s.to_string())
211 .collect()
212}
213
214fn default_max_upload_mb() -> u64 {
215 100
216}
217
218impl BlossomConfig {
219 pub fn all_read_servers(&self) -> Vec<String> {
221 let mut servers = self.servers.clone();
222 servers.extend(self.read_servers.clone());
223 servers.sort();
224 servers.dedup();
225 servers
226 }
227
228 pub fn all_write_servers(&self) -> Vec<String> {
230 let mut servers = self.servers.clone();
231 servers.extend(self.write_servers.clone());
232 servers.sort();
233 servers.dedup();
234 servers
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SyncConfig {
241 #[serde(default)]
242 pub enabled: bool,
243 #[serde(default = "default_true")]
244 pub sync_own: bool,
245 #[serde(default)]
246 pub sync_followed: bool,
247 #[serde(default = "default_max_concurrent")]
248 pub max_concurrent: usize,
249 #[serde(default = "default_webrtc_timeout_ms")]
250 pub webrtc_timeout_ms: u64,
251 #[serde(default = "default_blossom_timeout_ms")]
252 pub blossom_timeout_ms: u64,
253}
254
255impl Default for SyncConfig {
256 fn default() -> Self {
257 Self {
258 enabled: false,
259 sync_own: true,
260 sync_followed: false,
261 max_concurrent: default_max_concurrent(),
262 webrtc_timeout_ms: default_webrtc_timeout_ms(),
263 blossom_timeout_ms: default_blossom_timeout_ms(),
264 }
265 }
266}
267
268fn default_max_concurrent() -> usize {
269 4
270}
271
272fn default_webrtc_timeout_ms() -> u64 {
273 5000
274}
275
276fn default_blossom_timeout_ms() -> u64 {
277 10000
278}
279
280impl Config {
281 pub fn load() -> Result<Self> {
283 let config_path = get_config_path();
284
285 if config_path.exists() {
286 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
287 toml::from_str(&content).context("Failed to parse config file")
288 } else {
289 let config = Config::default();
290 config.save()?;
291 Ok(config)
292 }
293 }
294
295 pub fn load_or_default() -> Self {
297 Self::load().unwrap_or_default()
298 }
299
300 pub fn save(&self) -> Result<()> {
302 let config_path = get_config_path();
303
304 if let Some(parent) = config_path.parent() {
305 fs::create_dir_all(parent)?;
306 }
307
308 let content = toml::to_string_pretty(self)?;
309 fs::write(&config_path, content)?;
310
311 Ok(())
312 }
313}
314
315pub fn get_hashtree_dir() -> PathBuf {
317 if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
318 return PathBuf::from(dir);
319 }
320 dirs::home_dir()
321 .unwrap_or_else(|| PathBuf::from("."))
322 .join(".hashtree")
323}
324
325pub fn get_config_path() -> PathBuf {
327 get_hashtree_dir().join("config.toml")
328}
329
330pub fn get_keys_path() -> PathBuf {
332 get_hashtree_dir().join("keys")
333}
334
335pub fn get_aliases_path() -> PathBuf {
337 get_hashtree_dir().join("aliases")
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 if !prefer_local_daemon() {
397 return None;
398 }
399
400 let port = local_daemon_port(bind_address);
401 if port == 0 {
402 return None;
403 }
404
405 let addr = SocketAddr::from(([127, 0, 0, 1], port));
406 let timeout = Duration::from_millis(100);
407 TcpStream::connect_timeout(&addr, timeout).ok()?;
408 Some(format!("http://127.0.0.1:{}", port))
409}
410
411pub fn detect_local_relay_urls(bind_address: Option<&str>) -> Vec<String> {
413 let mut relays = Vec::new();
414
415 if let Some(list) =
416 parse_env_list("NOSTR_LOCAL_RELAY").or_else(|| parse_env_list("HTREE_LOCAL_RELAY"))
417 {
418 for raw in list {
419 if let Some(url) = normalize_relay_url(&raw) {
420 relays.push(url);
421 }
422 }
423 }
424
425 if let Some(base) = detect_local_daemon_url(bind_address) {
426 if let Some(ws) = normalize_relay_url(&base) {
427 let ws = ws.trim_end_matches('/');
428 let ws = if ws.contains("/ws") {
429 ws.to_string()
430 } else {
431 format!("{}/ws", ws)
432 };
433 relays.push(ws);
434 }
435 }
436
437 let mut ports = parse_env_ports("NOSTR_LOCAL_RELAY_PORTS");
438 if ports.is_empty() {
439 ports.push(4869);
440 }
441
442 let daemon_port = local_daemon_port(bind_address);
443 for port in ports {
444 if port == 0 || port == daemon_port {
445 continue;
446 }
447 if local_port_open(port) {
448 relays.push(format!("ws://127.0.0.1:{port}"));
449 }
450 }
451
452 dedupe_relays(relays)
453}
454
455pub fn resolve_relays(config_relays: &[String], bind_address: Option<&str>) -> Vec<String> {
457 let mut base = match parse_env_list("NOSTR_RELAYS") {
458 Some(list) => list,
459 None => config_relays.to_vec(),
460 };
461
462 base = base
463 .into_iter()
464 .filter_map(|r| normalize_relay_url(&r))
465 .collect();
466
467 if !prefer_local_relay() {
468 return dedupe_relays(base);
469 }
470
471 let mut combined = detect_local_relay_urls(bind_address);
472 combined.extend(base);
473 dedupe_relays(combined)
474}
475
476fn local_daemon_port(bind_address: Option<&str>) -> u16 {
477 let default_port = 8080;
478 let Some(addr) = bind_address else {
479 return default_port;
480 };
481 if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
482 return sock.port();
483 }
484 if let Some((_, port_str)) = addr.rsplit_once(':') {
485 if let Ok(port) = port_str.parse::<u16>() {
486 return port;
487 }
488 }
489 default_port
490}
491
492fn prefer_local_relay() -> bool {
493 for key in ["NOSTR_PREFER_LOCAL", "HTREE_PREFER_LOCAL_RELAY"] {
494 if let Ok(val) = std::env::var(key) {
495 let val = val.trim().to_lowercase();
496 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
497 }
498 }
499 true
500}
501
502fn prefer_local_daemon() -> bool {
503 for key in [
504 "HTREE_PREFER_LOCAL_DAEMON",
505 "NOSTR_PREFER_LOCAL",
506 "HTREE_PREFER_LOCAL_RELAY",
507 ] {
508 if let Ok(val) = std::env::var(key) {
509 let val = val.trim().to_lowercase();
510 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
511 }
512 }
513 false
514}
515
516fn parse_env_list(var: &str) -> Option<Vec<String>> {
517 let value = std::env::var(var).ok()?;
518 let mut items = Vec::new();
519 for part in value.split([',', ';', '\n', '\t', ' ']) {
520 let trimmed = part.trim();
521 if !trimmed.is_empty() {
522 items.push(trimmed.to_string());
523 }
524 }
525 if items.is_empty() {
526 None
527 } else {
528 Some(items)
529 }
530}
531
532fn parse_env_ports(var: &str) -> Vec<u16> {
533 let Some(list) = parse_env_list(var) else {
534 return Vec::new();
535 };
536 list.into_iter()
537 .filter_map(|item| item.parse::<u16>().ok())
538 .collect()
539}
540
541fn normalize_relay_url(raw: &str) -> Option<String> {
542 let trimmed = raw.trim();
543 if trimmed.is_empty() {
544 return None;
545 }
546 let trimmed = trimmed.trim_end_matches('/');
547 let lower = trimmed.to_lowercase();
548 if lower.starts_with("ws://") || lower.starts_with("wss://") {
549 return Some(trimmed.to_string());
550 }
551 if lower.starts_with("http://") {
552 return Some(format!("ws://{}", &trimmed[7..]));
553 }
554 if lower.starts_with("https://") {
555 return Some(format!("wss://{}", &trimmed[8..]));
556 }
557 Some(format!("ws://{}", trimmed))
558}
559
560fn local_port_open(port: u16) -> bool {
561 use std::net::{SocketAddr, TcpStream};
562 use std::time::Duration;
563
564 let addr = SocketAddr::from(([127, 0, 0, 1], port));
565 let timeout = Duration::from_millis(100);
566 TcpStream::connect_timeout(&addr, timeout).is_ok()
567}
568
569fn dedupe_relays(relays: Vec<String>) -> Vec<String> {
570 use std::collections::HashSet;
571 let mut seen = HashSet::new();
572 let mut out = Vec::new();
573 for relay in relays {
574 let key = relay.trim_end_matches('/').to_lowercase();
575 if seen.insert(key) {
576 out.push(relay);
577 }
578 }
579 out
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::net::TcpListener;
586 use std::sync::Mutex;
587
588 static ENV_LOCK: Mutex<()> = Mutex::new(());
589
590 struct EnvGuard {
591 key: &'static str,
592 prev: Option<String>,
593 }
594
595 impl EnvGuard {
596 fn set(key: &'static str, value: &str) -> Self {
597 let prev = std::env::var(key).ok();
598 std::env::set_var(key, value);
599 Self { key, prev }
600 }
601
602 fn clear(key: &'static str) -> Self {
603 let prev = std::env::var(key).ok();
604 std::env::remove_var(key);
605 Self { key, prev }
606 }
607 }
608
609 impl Drop for EnvGuard {
610 fn drop(&mut self) {
611 if let Some(prev) = &self.prev {
612 std::env::set_var(self.key, prev);
613 } else {
614 std::env::remove_var(self.key);
615 }
616 }
617 }
618
619 #[test]
620 fn test_default_config() {
621 let config = Config::default();
622 assert!(!config.blossom.read_servers.is_empty());
623 assert!(!config.blossom.write_servers.is_empty());
624 assert!(!config.nostr.relays.is_empty());
625 }
626
627 #[test]
628 fn test_parse_empty_config() {
629 let config: Config = toml::from_str("").unwrap();
630 assert!(!config.blossom.read_servers.is_empty());
631 }
632
633 #[test]
634 fn test_parse_partial_config() {
635 let toml = r#"
636[blossom]
637write_servers = ["https://custom.server"]
638"#;
639 let config: Config = toml::from_str(toml).unwrap();
640 assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
641 assert!(!config.blossom.read_servers.is_empty());
642 }
643
644 #[test]
645 fn test_all_servers() {
646 let mut config = BlossomConfig::default();
647 config.servers = vec!["https://legacy.server".to_string()];
648
649 let read = config.all_read_servers();
650 assert!(read.contains(&"https://legacy.server".to_string()));
651 assert!(read.contains(&"https://cdn.iris.to".to_string()));
652
653 let write = config.all_write_servers();
654 assert!(write.contains(&"https://legacy.server".to_string()));
655 assert!(write.contains(&"https://upload.iris.to".to_string()));
656 }
657
658 #[test]
659 fn test_storage_backend_default() {
660 let config = Config::default();
661 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
662 }
663
664 #[test]
665 fn test_storage_backend_lmdb() {
666 let toml = r#"
667[storage]
668backend = "lmdb"
669"#;
670 let config: Config = toml::from_str(toml).unwrap();
671 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
672 }
673
674 #[test]
675 fn test_storage_backend_fs_explicit() {
676 let toml = r#"
677[storage]
678backend = "fs"
679"#;
680 let config: Config = toml::from_str(toml).unwrap();
681 assert_eq!(config.storage.backend, StorageBackend::Fs);
682 }
683
684 #[test]
685 fn test_parse_keys_file() {
686 let content = r#"
687nsec1abc123 self
688# comment line
689nsec1def456 work
690
691nsec1ghi789
692"#;
693 let entries = parse_keys_file(content);
694 assert_eq!(entries.len(), 3);
695 assert_eq!(entries[0].secret, "nsec1abc123");
696 assert_eq!(entries[0].alias, Some("self".to_string()));
697 assert_eq!(entries[1].secret, "nsec1def456");
698 assert_eq!(entries[1].alias, Some("work".to_string()));
699 assert_eq!(entries[2].secret, "nsec1ghi789");
700 assert_eq!(entries[2].alias, None);
701 }
702
703 #[test]
704 fn test_local_daemon_port_default() {
705 assert_eq!(local_daemon_port(None), 8080);
706 }
707
708 #[test]
709 fn test_local_daemon_port_parses_ipv4() {
710 assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
711 }
712
713 #[test]
714 fn test_local_daemon_port_parses_anyhost() {
715 assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
716 }
717
718 #[test]
719 fn test_local_daemon_port_parses_ipv6() {
720 assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
721 }
722
723 #[test]
724 fn test_local_daemon_port_parses_hostname() {
725 assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
726 }
727
728 #[test]
729 fn test_local_daemon_port_invalid() {
730 assert_eq!(local_daemon_port(Some("localhost")), 8080);
731 }
732
733 #[test]
734 fn test_detect_local_daemon_url_respects_prefer_local_flag() {
735 let _lock = ENV_LOCK.lock().unwrap();
736 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
737 let port = listener.local_addr().unwrap().port();
738 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
739
740 assert_eq!(
741 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
742 None
743 );
744 }
745
746 #[test]
747 fn test_detect_local_daemon_url_requires_opt_in() {
748 let _lock = ENV_LOCK.lock().unwrap();
749 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
750 let port = listener.local_addr().unwrap().port();
751 let _prefer = EnvGuard::clear("HTREE_PREFER_LOCAL_DAEMON");
752 let _prefer_nostr = EnvGuard::clear("NOSTR_PREFER_LOCAL");
753 let _prefer_relay = EnvGuard::clear("HTREE_PREFER_LOCAL_RELAY");
754
755 assert_eq!(
756 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
757 None
758 );
759 }
760
761 #[test]
762 fn test_detect_local_daemon_url_uses_opt_in_flag() {
763 let _lock = ENV_LOCK.lock().unwrap();
764 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
765 let port = listener.local_addr().unwrap().port();
766 let _prefer = EnvGuard::set("HTREE_PREFER_LOCAL_DAEMON", "1");
767
768 assert_eq!(
769 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
770 Some(format!("http://127.0.0.1:{port}"))
771 );
772 }
773
774 #[test]
775 fn test_resolve_relays_prefers_local() {
776 let _lock = ENV_LOCK.lock().unwrap();
777 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
778 let port = listener.local_addr().unwrap().port();
779
780 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "1");
781 let _ports = EnvGuard::set("NOSTR_LOCAL_RELAY_PORTS", &port.to_string());
782 let _relays = EnvGuard::clear("NOSTR_RELAYS");
783
784 let base = vec!["wss://relay.example".to_string()];
785 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
786
787 assert!(!resolved.is_empty());
788 assert_eq!(resolved[0], format!("ws://127.0.0.1:{port}"));
789 assert!(resolved.contains(&"wss://relay.example".to_string()));
790 }
791
792 #[test]
793 fn test_resolve_relays_env_override() {
794 let _lock = ENV_LOCK.lock().unwrap();
795 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
796 let _relays = EnvGuard::set("NOSTR_RELAYS", "wss://relay.one,wss://relay.two");
797
798 let base = vec!["wss://relay.example".to_string()];
799 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
800
801 assert_eq!(
802 resolved,
803 vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()]
804 );
805 }
806}