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] = &[
12    "https://cdn.iris.to",
13    "https://hashtree.iris.to",
14];
15
16/// Default write-enabled file servers
17pub const DEFAULT_WRITE_SERVERS: &[&str] = &[
18    "https://upload.iris.to",
19];
20
21/// Default nostr relays
22pub 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/// Top-level config structure
31#[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/// Server configuration
46#[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/// Storage backend type
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum StorageBackend {
84    /// Filesystem storage (default) - stores in ~/.hashtree/blobs/{prefix}/{hash}
85    Fs,
86    /// LMDB storage - requires lmdb feature
87    Lmdb,
88}
89
90impl Default for StorageBackend {
91    fn default() -> Self {
92        Self::Fs
93    }
94}
95
96/// Storage configuration
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct StorageConfig {
99    /// Storage backend: "fs" (default) or "lmdb"
100    #[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/// S3-compatible storage configuration
133#[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/// Nostr relay configuration
143#[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    /// Max size for trusted nostrdb in GB (default: 10)
150    #[serde(default = "default_nostr_db_max_size_gb")]
151    pub db_max_size_gb: u64,
152    /// Max size for spambox nostrdb in GB (default: 1)
153    /// Set to 0 for memory-only spambox (no on-disk DB)
154    #[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/// File server (blossom) configuration
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct BlossomConfig {
184    /// Legacy servers field (both read and write)
185    #[serde(default)]
186    pub servers: Vec<String>,
187    /// Read-only file servers
188    #[serde(default = "default_read_servers")]
189    pub read_servers: Vec<String>,
190    /// Write-enabled file servers
191    #[serde(default = "default_write_servers")]
192    pub write_servers: Vec<String>,
193    /// Max upload size in MB
194    #[serde(default = "default_max_upload_mb")]
195    pub max_upload_mb: u64,
196    /// Force upload all blobs, skipping "server already has" check
197    #[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    /// Get all read servers (legacy + read_servers)
227    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    /// Get all write servers (legacy + write_servers)
236    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/// Background sync configuration
246#[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    /// Load config from file, or create default if doesn't exist
289    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    /// Load config, returning default on any error (no panic)
304    pub fn load_or_default() -> Self {
305        Self::load().unwrap_or_default()
306    }
307
308    /// Save config to file
309    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
323/// Get the hashtree directory (~/.hashtree)
324pub 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
333/// Get the config file path (~/.hashtree/config.toml)
334pub fn get_config_path() -> PathBuf {
335    get_hashtree_dir().join("config.toml")
336}
337
338/// Get the keys file path (~/.hashtree/keys)
339pub fn get_keys_path() -> PathBuf {
340    get_hashtree_dir().join("keys")
341}
342
343/// A stored key entry from the keys file
344#[derive(Debug, Clone)]
345pub struct KeyEntry {
346    /// The nsec or hex secret key
347    pub secret: String,
348    /// Optional alias/petname
349    pub alias: Option<String>,
350}
351
352/// Parse the keys file content into key entries
353/// Format: `nsec1... [alias]` or `hex... [alias]` per line
354/// Lines starting with # are comments
355pub 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
370/// Read and parse keys file, returning the first key's secret
371/// Returns None if file doesn't exist or is empty
372pub 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
379/// Get the auth cookie path (~/.hashtree/auth.cookie)
380pub fn get_auth_cookie_path() -> PathBuf {
381    get_hashtree_dir().join("auth.cookie")
382}
383
384/// Get the data directory from config (defaults to ~/.hashtree/data)
385/// Can be overridden with HTREE_DATA_DIR environment variable
386pub 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
394/// Detect a local hashtree daemon on localhost and return its Blossom base URL.
395pub 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
410/// Detect local Nostr relay URLs (e.g., hashtree daemon or local relay on common ports).
411pub 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
454/// Resolve relays using environment overrides and optional local relay discovery.
455pub 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}