Skip to main content

hashtree_config/
lib.rs

1//! Shared configuration for hashtree tools
2//!
3//! Reads from ~/.hashtree/config.toml
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10/// Default read-only file servers
11pub const DEFAULT_READ_SERVERS: &[&str] = &["https://cdn.iris.to", "https://hashtree.iris.to"];
12
13/// Default write-enabled file servers
14pub const DEFAULT_WRITE_SERVERS: &[&str] = &["https://upload.iris.to"];
15
16/// Default nostr relays
17pub 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/// Top-level config structure
26#[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/// Server configuration
41#[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/// Storage backend type
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum StorageBackend {
79    /// Filesystem storage (default) - stores in ~/.hashtree/blobs/{prefix}/{hash}
80    Fs,
81    /// LMDB storage - requires lmdb feature
82    Lmdb,
83}
84
85impl Default for StorageBackend {
86    fn default() -> Self {
87        Self::Fs
88    }
89}
90
91/// Storage configuration
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct StorageConfig {
94    /// Storage backend: "fs" (default) or "lmdb"
95    #[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/// S3-compatible storage configuration
128#[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/// Nostr relay configuration
138#[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    /// Max size for trusted nostrdb in GB (default: 10)
145    #[serde(default = "default_nostr_db_max_size_gb")]
146    pub db_max_size_gb: u64,
147    /// Max size for spambox nostrdb in GB (default: 1)
148    /// Set to 0 for memory-only spambox (no on-disk DB)
149    #[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/// File server (blossom) configuration
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct BlossomConfig {
179    /// Legacy servers field (both read and write)
180    #[serde(default)]
181    pub servers: Vec<String>,
182    /// Read-only file servers
183    #[serde(default = "default_read_servers")]
184    pub read_servers: Vec<String>,
185    /// Write-enabled file servers
186    #[serde(default = "default_write_servers")]
187    pub write_servers: Vec<String>,
188    /// Max upload size in MB
189    #[serde(default = "default_max_upload_mb")]
190    pub max_upload_mb: u64,
191    /// Force upload all blobs, skipping "server already has" check
192    #[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    /// Get all read servers (legacy + read_servers)
225    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    /// Get all write servers (legacy + write_servers)
234    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/// Background sync configuration
244#[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    /// Load config from file, or create default if doesn't exist
287    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    /// Load config, returning default on any error (no panic)
301    pub fn load_or_default() -> Self {
302        Self::load().unwrap_or_default()
303    }
304
305    /// Save config to file
306    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
320/// Get the hashtree directory (~/.hashtree)
321pub 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
330/// Get the config file path (~/.hashtree/config.toml)
331pub fn get_config_path() -> PathBuf {
332    get_hashtree_dir().join("config.toml")
333}
334
335/// Get the keys file path (~/.hashtree/keys)
336pub fn get_keys_path() -> PathBuf {
337    get_hashtree_dir().join("keys")
338}
339
340/// A stored key entry from the keys file
341#[derive(Debug, Clone)]
342pub struct KeyEntry {
343    /// The nsec or hex secret key
344    pub secret: String,
345    /// Optional alias/petname
346    pub alias: Option<String>,
347}
348
349/// Parse the keys file content into key entries
350/// Format: `nsec1... [alias]` or `hex... [alias]` per line
351/// Lines starting with # are comments
352pub 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
367/// Read and parse keys file, returning the first key's secret
368/// Returns None if file doesn't exist or is empty
369pub 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
376/// Get the auth cookie path (~/.hashtree/auth.cookie)
377pub fn get_auth_cookie_path() -> PathBuf {
378    get_hashtree_dir().join("auth.cookie")
379}
380
381/// Get the data directory from config (defaults to ~/.hashtree/data)
382/// Can be overridden with HTREE_DATA_DIR environment variable
383pub 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
391/// Detect a local hashtree daemon on localhost and return its Blossom base URL.
392pub 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
407/// Detect local Nostr relay URLs (e.g., hashtree daemon or local relay on common ports).
408pub 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
451/// Resolve relays using environment overrides and optional local relay discovery.
452pub 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}