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];
28
29/// Top-level config structure
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct Config {
32    #[serde(default)]
33    pub server: ServerConfig,
34    #[serde(default)]
35    pub storage: StorageConfig,
36    #[serde(default)]
37    pub nostr: NostrConfig,
38    #[serde(default)]
39    pub blossom: BlossomConfig,
40    #[serde(default)]
41    pub sync: SyncConfig,
42}
43
44/// Server configuration
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ServerConfig {
47    #[serde(default = "default_bind_address")]
48    pub bind_address: String,
49    #[serde(default = "default_true")]
50    pub enable_auth: bool,
51    #[serde(default)]
52    pub public_writes: bool,
53    #[serde(default)]
54    pub enable_webrtc: bool,
55    #[serde(default)]
56    pub stun_port: u16,
57}
58
59impl Default for ServerConfig {
60    fn default() -> Self {
61        Self {
62            bind_address: default_bind_address(),
63            enable_auth: true,
64            public_writes: false,
65            enable_webrtc: false,
66            stun_port: 0,
67        }
68    }
69}
70
71fn default_bind_address() -> String {
72    "127.0.0.1:8080".to_string()
73}
74
75fn default_true() -> bool {
76    true
77}
78
79/// Storage backend type
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum StorageBackend {
83    /// Filesystem storage (default) - stores in ~/.hashtree/blobs/{prefix}/{hash}
84    Fs,
85    /// LMDB storage - requires lmdb feature
86    Lmdb,
87}
88
89impl Default for StorageBackend {
90    fn default() -> Self {
91        Self::Fs
92    }
93}
94
95/// Storage configuration
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct StorageConfig {
98    /// Storage backend: "fs" (default) or "lmdb"
99    #[serde(default)]
100    pub backend: StorageBackend,
101    #[serde(default = "default_data_dir")]
102    pub data_dir: String,
103    #[serde(default = "default_max_size_gb")]
104    pub max_size_gb: u64,
105    #[serde(default)]
106    pub s3: Option<S3Config>,
107}
108
109impl Default for StorageConfig {
110    fn default() -> Self {
111        Self {
112            backend: StorageBackend::default(),
113            data_dir: default_data_dir(),
114            max_size_gb: default_max_size_gb(),
115            s3: None,
116        }
117    }
118}
119
120fn default_data_dir() -> String {
121    get_hashtree_dir()
122        .join("data")
123        .to_string_lossy()
124        .to_string()
125}
126
127fn default_max_size_gb() -> u64 {
128    10
129}
130
131/// S3-compatible storage configuration
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct S3Config {
134    pub endpoint: String,
135    pub bucket: String,
136    pub region: String,
137    #[serde(default)]
138    pub prefix: Option<String>,
139}
140
141/// Nostr relay configuration
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct NostrConfig {
144    #[serde(default = "default_relays")]
145    pub relays: Vec<String>,
146    #[serde(default)]
147    pub allowed_npubs: Vec<String>,
148}
149
150impl Default for NostrConfig {
151    fn default() -> Self {
152        Self {
153            relays: default_relays(),
154            allowed_npubs: vec![],
155        }
156    }
157}
158
159fn default_relays() -> Vec<String> {
160    DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
161}
162
163/// File server (blossom) configuration
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct BlossomConfig {
166    /// Legacy servers field (both read and write)
167    #[serde(default)]
168    pub servers: Vec<String>,
169    /// Read-only file servers
170    #[serde(default = "default_read_servers")]
171    pub read_servers: Vec<String>,
172    /// Write-enabled file servers
173    #[serde(default = "default_write_servers")]
174    pub write_servers: Vec<String>,
175    /// Max upload size in MB
176    #[serde(default = "default_max_upload_mb")]
177    pub max_upload_mb: u64,
178    /// Force upload all blobs, skipping "server already has" check
179    #[serde(default)]
180    pub force_upload: bool,
181}
182
183impl Default for BlossomConfig {
184    fn default() -> Self {
185        Self {
186            servers: vec![],
187            read_servers: default_read_servers(),
188            write_servers: default_write_servers(),
189            max_upload_mb: default_max_upload_mb(),
190            force_upload: false,
191        }
192    }
193}
194
195fn default_read_servers() -> Vec<String> {
196    DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect()
197}
198
199fn default_write_servers() -> Vec<String> {
200    DEFAULT_WRITE_SERVERS.iter().map(|s| s.to_string()).collect()
201}
202
203fn default_max_upload_mb() -> u64 {
204    100
205}
206
207impl BlossomConfig {
208    /// Get all read servers (legacy + read_servers)
209    pub fn all_read_servers(&self) -> Vec<String> {
210        let mut servers = self.servers.clone();
211        servers.extend(self.read_servers.clone());
212        servers.sort();
213        servers.dedup();
214        servers
215    }
216
217    /// Get all write servers (legacy + write_servers)
218    pub fn all_write_servers(&self) -> Vec<String> {
219        let mut servers = self.servers.clone();
220        servers.extend(self.write_servers.clone());
221        servers.sort();
222        servers.dedup();
223        servers
224    }
225}
226
227/// Background sync configuration
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct SyncConfig {
230    #[serde(default)]
231    pub enabled: bool,
232    #[serde(default = "default_true")]
233    pub sync_own: bool,
234    #[serde(default)]
235    pub sync_followed: bool,
236    #[serde(default = "default_max_concurrent")]
237    pub max_concurrent: usize,
238    #[serde(default = "default_webrtc_timeout_ms")]
239    pub webrtc_timeout_ms: u64,
240    #[serde(default = "default_blossom_timeout_ms")]
241    pub blossom_timeout_ms: u64,
242}
243
244impl Default for SyncConfig {
245    fn default() -> Self {
246        Self {
247            enabled: false,
248            sync_own: true,
249            sync_followed: false,
250            max_concurrent: default_max_concurrent(),
251            webrtc_timeout_ms: default_webrtc_timeout_ms(),
252            blossom_timeout_ms: default_blossom_timeout_ms(),
253        }
254    }
255}
256
257fn default_max_concurrent() -> usize {
258    4
259}
260
261fn default_webrtc_timeout_ms() -> u64 {
262    5000
263}
264
265fn default_blossom_timeout_ms() -> u64 {
266    10000
267}
268
269impl Config {
270    /// Load config from file, or create default if doesn't exist
271    pub fn load() -> Result<Self> {
272        let config_path = get_config_path();
273
274        if config_path.exists() {
275            let content = fs::read_to_string(&config_path)
276                .context("Failed to read config file")?;
277            toml::from_str(&content).context("Failed to parse config file")
278        } else {
279            let config = Config::default();
280            config.save()?;
281            Ok(config)
282        }
283    }
284
285    /// Load config, returning default on any error (no panic)
286    pub fn load_or_default() -> Self {
287        Self::load().unwrap_or_default()
288    }
289
290    /// Save config to file
291    pub fn save(&self) -> Result<()> {
292        let config_path = get_config_path();
293
294        if let Some(parent) = config_path.parent() {
295            fs::create_dir_all(parent)?;
296        }
297
298        let content = toml::to_string_pretty(self)?;
299        fs::write(&config_path, content)?;
300
301        Ok(())
302    }
303}
304
305/// Get the hashtree directory (~/.hashtree)
306pub fn get_hashtree_dir() -> PathBuf {
307    if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
308        return PathBuf::from(dir);
309    }
310    dirs::home_dir()
311        .unwrap_or_else(|| PathBuf::from("."))
312        .join(".hashtree")
313}
314
315/// Get the config file path (~/.hashtree/config.toml)
316pub fn get_config_path() -> PathBuf {
317    get_hashtree_dir().join("config.toml")
318}
319
320/// Get the keys file path (~/.hashtree/keys)
321pub fn get_keys_path() -> PathBuf {
322    get_hashtree_dir().join("keys")
323}
324
325/// A stored key entry from the keys file
326#[derive(Debug, Clone)]
327pub struct KeyEntry {
328    /// The nsec or hex secret key
329    pub secret: String,
330    /// Optional alias/petname
331    pub alias: Option<String>,
332}
333
334/// Parse the keys file content into key entries
335/// Format: `nsec1... [alias]` or `hex... [alias]` per line
336/// Lines starting with # are comments
337pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
338    let mut entries = Vec::new();
339    for line in content.lines() {
340        let line = line.trim();
341        if line.is_empty() || line.starts_with('#') {
342            continue;
343        }
344        let parts: Vec<&str> = line.splitn(2, ' ').collect();
345        let secret = parts[0].to_string();
346        let alias = parts.get(1).map(|s| s.trim().to_string());
347        entries.push(KeyEntry { secret, alias });
348    }
349    entries
350}
351
352/// Read and parse keys file, returning the first key's secret
353/// Returns None if file doesn't exist or is empty
354pub fn read_first_key() -> Option<String> {
355    let keys_path = get_keys_path();
356    let content = std::fs::read_to_string(&keys_path).ok()?;
357    let entries = parse_keys_file(&content);
358    entries.into_iter().next().map(|e| e.secret)
359}
360
361/// Get the auth cookie path (~/.hashtree/auth.cookie)
362pub fn get_auth_cookie_path() -> PathBuf {
363    get_hashtree_dir().join("auth.cookie")
364}
365
366/// Get the data directory from config (defaults to ~/.hashtree/data)
367/// Can be overridden with HTREE_DATA_DIR environment variable
368pub fn get_data_dir() -> PathBuf {
369    if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
370        return PathBuf::from(dir);
371    }
372    let config = Config::load_or_default();
373    PathBuf::from(&config.storage.data_dir)
374}
375
376/// Detect a local hashtree daemon on localhost and return its Blossom base URL.
377pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
378    use std::net::{SocketAddr, TcpStream};
379    use std::time::Duration;
380
381    let port = local_daemon_port(bind_address);
382    if port == 0 {
383        return None;
384    }
385
386    let addr = SocketAddr::from(([127, 0, 0, 1], port));
387    let timeout = Duration::from_millis(100);
388    TcpStream::connect_timeout(&addr, timeout).ok()?;
389    Some(format!("http://127.0.0.1:{}", port))
390}
391
392fn local_daemon_port(bind_address: Option<&str>) -> u16 {
393    let default_port = 8080;
394    let Some(addr) = bind_address else {
395        return default_port;
396    };
397    if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
398        return sock.port();
399    }
400    if let Some((_, port_str)) = addr.rsplit_once(':') {
401        if let Ok(port) = port_str.parse::<u16>() {
402            return port;
403        }
404    }
405    default_port
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_default_config() {
414        let config = Config::default();
415        assert!(!config.blossom.read_servers.is_empty());
416        assert!(!config.blossom.write_servers.is_empty());
417        assert!(!config.nostr.relays.is_empty());
418    }
419
420    #[test]
421    fn test_parse_empty_config() {
422        let config: Config = toml::from_str("").unwrap();
423        assert!(!config.blossom.read_servers.is_empty());
424    }
425
426    #[test]
427    fn test_parse_partial_config() {
428        let toml = r#"
429[blossom]
430write_servers = ["https://custom.server"]
431"#;
432        let config: Config = toml::from_str(toml).unwrap();
433        assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
434        assert!(!config.blossom.read_servers.is_empty());
435    }
436
437    #[test]
438    fn test_all_servers() {
439        let mut config = BlossomConfig::default();
440        config.servers = vec!["https://legacy.server".to_string()];
441
442        let read = config.all_read_servers();
443        assert!(read.contains(&"https://legacy.server".to_string()));
444        assert!(read.contains(&"https://cdn.iris.to".to_string()));
445
446        let write = config.all_write_servers();
447        assert!(write.contains(&"https://legacy.server".to_string()));
448        assert!(write.contains(&"https://upload.iris.to".to_string()));
449    }
450
451    #[test]
452    fn test_storage_backend_default() {
453        let config = Config::default();
454        assert_eq!(config.storage.backend, StorageBackend::Fs);
455    }
456
457    #[test]
458    fn test_storage_backend_lmdb() {
459        let toml = r#"
460[storage]
461backend = "lmdb"
462"#;
463        let config: Config = toml::from_str(toml).unwrap();
464        assert_eq!(config.storage.backend, StorageBackend::Lmdb);
465    }
466
467    #[test]
468    fn test_storage_backend_fs_explicit() {
469        let toml = r#"
470[storage]
471backend = "fs"
472"#;
473        let config: Config = toml::from_str(toml).unwrap();
474        assert_eq!(config.storage.backend, StorageBackend::Fs);
475    }
476
477    #[test]
478    fn test_parse_keys_file() {
479        let content = r#"
480nsec1abc123 self
481# comment line
482nsec1def456 work
483
484nsec1ghi789
485"#;
486        let entries = parse_keys_file(content);
487        assert_eq!(entries.len(), 3);
488        assert_eq!(entries[0].secret, "nsec1abc123");
489        assert_eq!(entries[0].alias, Some("self".to_string()));
490        assert_eq!(entries[1].secret, "nsec1def456");
491        assert_eq!(entries[1].alias, Some("work".to_string()));
492        assert_eq!(entries[2].secret, "nsec1ghi789");
493        assert_eq!(entries[2].alias, None);
494    }
495
496    #[test]
497    fn test_local_daemon_port_default() {
498        assert_eq!(local_daemon_port(None), 8080);
499    }
500
501    #[test]
502    fn test_local_daemon_port_parses_ipv4() {
503        assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
504    }
505
506    #[test]
507    fn test_local_daemon_port_parses_anyhost() {
508        assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
509    }
510
511    #[test]
512    fn test_local_daemon_port_parses_ipv6() {
513        assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
514    }
515
516    #[test]
517    fn test_local_daemon_port_parses_hostname() {
518        assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
519    }
520
521    #[test]
522    fn test_local_daemon_port_invalid() {
523        assert_eq!(local_daemon_port(Some("localhost")), 8080);
524    }
525}