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