Skip to main content

hashtree_cli/
config.rs

1use anyhow::{Context, Result};
2use nostr::nips::nip19::{FromBech32, ToBech32};
3use nostr::{Keys, SecretKey};
4use serde::{Deserialize, Serialize};
5use std::fs;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Config {
9    #[serde(default)]
10    pub server: ServerConfig,
11    #[serde(default)]
12    pub storage: StorageConfig,
13    #[serde(default)]
14    pub nostr: NostrConfig,
15    #[serde(default)]
16    pub blossom: BlossomConfig,
17    #[serde(default)]
18    pub sync: SyncConfig,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ServerConfig {
23    #[serde(default = "default_bind_address")]
24    pub bind_address: String,
25    #[serde(default = "default_enable_auth")]
26    pub enable_auth: bool,
27    /// Port for the built-in STUN server (0 = disabled)
28    #[serde(default = "default_stun_port")]
29    pub stun_port: u16,
30    /// Enable WebRTC P2P connections
31    #[serde(default = "default_enable_webrtc")]
32    pub enable_webrtc: bool,
33    /// Allow anyone with valid Nostr auth to write (default: true)
34    /// When false, only social graph members can write
35    #[serde(default = "default_public_writes")]
36    pub public_writes: bool,
37    /// Allow public access to social graph snapshot endpoint (default: false)
38    #[serde(default = "default_socialgraph_snapshot_public")]
39    pub socialgraph_snapshot_public: bool,
40}
41
42fn default_public_writes() -> bool {
43    true
44}
45
46fn default_socialgraph_snapshot_public() -> bool {
47    false
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct StorageConfig {
52    #[serde(default = "default_data_dir")]
53    pub data_dir: String,
54    #[serde(default = "default_max_size_gb")]
55    pub max_size_gb: u64,
56    /// Optional S3/R2 backend for blob storage
57    #[serde(default)]
58    pub s3: Option<S3Config>,
59}
60
61/// S3-compatible storage configuration (works with AWS S3, Cloudflare R2, MinIO, etc.)
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct S3Config {
64    /// S3 endpoint URL (e.g., "https://<account_id>.r2.cloudflarestorage.com" for R2)
65    pub endpoint: String,
66    /// S3 bucket name
67    pub bucket: String,
68    /// Optional key prefix for all blobs (e.g., "blobs/")
69    #[serde(default)]
70    pub prefix: Option<String>,
71    /// AWS region (use "auto" for R2)
72    #[serde(default = "default_s3_region")]
73    pub region: String,
74    /// Access key ID (can also be set via AWS_ACCESS_KEY_ID env var)
75    #[serde(default)]
76    pub access_key: Option<String>,
77    /// Secret access key (can also be set via AWS_SECRET_ACCESS_KEY env var)
78    #[serde(default)]
79    pub secret_key: Option<String>,
80    /// Public URL for serving blobs (optional, for generating public URLs)
81    #[serde(default)]
82    pub public_url: Option<String>,
83}
84
85fn default_s3_region() -> String {
86    "auto".to_string()
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct NostrConfig {
91    #[serde(default = "default_relays")]
92    pub relays: Vec<String>,
93    /// List of npubs allowed to write (blossom uploads). If empty, uses public_writes setting.
94    #[serde(default)]
95    pub allowed_npubs: Vec<String>,
96    /// Social graph root pubkey (npub). Defaults to own key if not set.
97    #[serde(default)]
98    pub socialgraph_root: Option<String>,
99    /// How many hops to crawl the follow graph (default: 2)
100    #[serde(default = "default_crawl_depth")]
101    pub crawl_depth: u32,
102    /// Max follow distance for write access (default: 3)
103    #[serde(default = "default_max_write_distance")]
104    pub max_write_distance: u32,
105    /// Max size for trusted nostrdb in GB (default: 10)
106    #[serde(default = "default_nostr_db_max_size_gb")]
107    pub db_max_size_gb: u64,
108    /// Max size for spambox nostrdb in GB (default: 1)
109    /// Set to 0 for memory-only spambox (no on-disk DB)
110    #[serde(default = "default_nostr_spambox_max_size_gb")]
111    pub spambox_max_size_gb: u64,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct BlossomConfig {
116    /// File servers for push/pull (legacy, both read and write)
117    #[serde(default)]
118    pub servers: Vec<String>,
119    /// Read-only file servers (fallback for fetching content)
120    #[serde(default = "default_read_servers")]
121    pub read_servers: Vec<String>,
122    /// Write-enabled file servers (for uploading)
123    #[serde(default = "default_write_servers")]
124    pub write_servers: Vec<String>,
125    /// Maximum upload size in MB (default: 5)
126    #[serde(default = "default_max_upload_mb")]
127    pub max_upload_mb: u64,
128}
129
130// Keep in sync with hashtree-config/src/lib.rs
131fn default_read_servers() -> Vec<String> {
132    vec![
133        "https://cdn.iris.to".to_string(),
134        "https://hashtree.iris.to".to_string(),
135    ]
136}
137
138fn default_write_servers() -> Vec<String> {
139    vec!["https://upload.iris.to".to_string()]
140}
141
142fn default_max_upload_mb() -> u64 {
143    5
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct SyncConfig {
148    /// Enable background sync (auto-pull trees)
149    #[serde(default = "default_sync_enabled")]
150    pub enabled: bool,
151    /// Sync own trees (subscribed via Nostr)
152    #[serde(default = "default_sync_own")]
153    pub sync_own: bool,
154    /// Sync followed users' public trees
155    #[serde(default = "default_sync_followed")]
156    pub sync_followed: bool,
157    /// Max concurrent sync tasks
158    #[serde(default = "default_max_concurrent")]
159    pub max_concurrent: usize,
160    /// WebRTC request timeout in milliseconds
161    #[serde(default = "default_webrtc_timeout_ms")]
162    pub webrtc_timeout_ms: u64,
163    /// Blossom request timeout in milliseconds
164    #[serde(default = "default_blossom_timeout_ms")]
165    pub blossom_timeout_ms: u64,
166}
167
168fn default_sync_enabled() -> bool {
169    true
170}
171
172fn default_sync_own() -> bool {
173    true
174}
175
176fn default_sync_followed() -> bool {
177    true
178}
179
180fn default_max_concurrent() -> usize {
181    3
182}
183
184fn default_webrtc_timeout_ms() -> u64 {
185    2000
186}
187
188fn default_blossom_timeout_ms() -> u64 {
189    10000
190}
191
192fn default_crawl_depth() -> u32 {
193    2
194}
195
196fn default_max_write_distance() -> u32 {
197    3
198}
199
200fn default_nostr_db_max_size_gb() -> u64 {
201    10
202}
203
204fn default_nostr_spambox_max_size_gb() -> u64 {
205    1
206}
207
208fn default_relays() -> Vec<String> {
209    vec![
210        "wss://relay.damus.io".to_string(),
211        "wss://relay.snort.social".to_string(),
212        "wss://nos.lol".to_string(),
213        "wss://temp.iris.to".to_string(),
214    ]
215}
216
217fn default_bind_address() -> String {
218    "127.0.0.1:8080".to_string()
219}
220
221fn default_enable_auth() -> bool {
222    true
223}
224
225fn default_stun_port() -> u16 {
226    3478 // Standard STUN port (RFC 5389)
227}
228
229fn default_enable_webrtc() -> bool {
230    true
231}
232
233fn default_data_dir() -> String {
234    hashtree_config::get_hashtree_dir()
235        .join("data")
236        .to_string_lossy()
237        .to_string()
238}
239
240fn default_max_size_gb() -> u64 {
241    10
242}
243
244impl Default for ServerConfig {
245    fn default() -> Self {
246        Self {
247            bind_address: default_bind_address(),
248            enable_auth: default_enable_auth(),
249            stun_port: default_stun_port(),
250            enable_webrtc: default_enable_webrtc(),
251            public_writes: default_public_writes(),
252            socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
253        }
254    }
255}
256
257impl Default for StorageConfig {
258    fn default() -> Self {
259        Self {
260            data_dir: default_data_dir(),
261            max_size_gb: default_max_size_gb(),
262            s3: None,
263        }
264    }
265}
266
267impl Default for NostrConfig {
268    fn default() -> Self {
269        Self {
270            relays: default_relays(),
271            allowed_npubs: Vec::new(),
272            socialgraph_root: None,
273            crawl_depth: default_crawl_depth(),
274            max_write_distance: default_max_write_distance(),
275            db_max_size_gb: default_nostr_db_max_size_gb(),
276            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
277        }
278    }
279}
280
281impl Default for BlossomConfig {
282    fn default() -> Self {
283        Self {
284            servers: Vec::new(),
285            read_servers: default_read_servers(),
286            write_servers: default_write_servers(),
287            max_upload_mb: default_max_upload_mb(),
288        }
289    }
290}
291
292impl Default for SyncConfig {
293    fn default() -> Self {
294        Self {
295            enabled: default_sync_enabled(),
296            sync_own: default_sync_own(),
297            sync_followed: default_sync_followed(),
298            max_concurrent: default_max_concurrent(),
299            webrtc_timeout_ms: default_webrtc_timeout_ms(),
300            blossom_timeout_ms: default_blossom_timeout_ms(),
301        }
302    }
303}
304
305impl Default for Config {
306    fn default() -> Self {
307        Self {
308            server: ServerConfig::default(),
309            storage: StorageConfig::default(),
310            nostr: NostrConfig::default(),
311            blossom: BlossomConfig::default(),
312            sync: SyncConfig::default(),
313        }
314    }
315}
316
317impl Config {
318    /// Load config from file, or create default if doesn't exist
319    pub fn load() -> Result<Self> {
320        let config_path = get_config_path();
321
322        if config_path.exists() {
323            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
324            toml::from_str(&content).context("Failed to parse config file")
325        } else {
326            let config = Config::default();
327            config.save()?;
328            Ok(config)
329        }
330    }
331
332    /// Save config to file
333    pub fn save(&self) -> Result<()> {
334        let config_path = get_config_path();
335
336        // Ensure parent directory exists
337        if let Some(parent) = config_path.parent() {
338            fs::create_dir_all(parent)?;
339        }
340
341        let content = toml::to_string_pretty(self)?;
342        fs::write(&config_path, content)?;
343
344        Ok(())
345    }
346}
347
348// Re-export path functions from hashtree_config
349pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
350
351/// Generate and save auth cookie if it doesn't exist
352pub fn ensure_auth_cookie() -> Result<(String, String)> {
353    let cookie_path = get_auth_cookie_path();
354
355    if cookie_path.exists() {
356        read_auth_cookie()
357    } else {
358        generate_auth_cookie()
359    }
360}
361
362/// Read existing auth cookie
363pub fn read_auth_cookie() -> Result<(String, String)> {
364    let cookie_path = get_auth_cookie_path();
365    let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
366
367    let parts: Vec<&str> = content.trim().split(':').collect();
368    if parts.len() != 2 {
369        anyhow::bail!("Invalid auth cookie format");
370    }
371
372    Ok((parts[0].to_string(), parts[1].to_string()))
373}
374
375/// Ensure keys file exists, generating one if not present
376/// Returns (Keys, was_generated)
377pub fn ensure_keys() -> Result<(Keys, bool)> {
378    let keys_path = get_keys_path();
379
380    if keys_path.exists() {
381        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
382        let entries = hashtree_config::parse_keys_file(&content);
383        let nsec_str = entries
384            .into_iter()
385            .next()
386            .map(|e| e.secret)
387            .context("Keys file is empty")?;
388        let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
389        let keys = Keys::new(secret_key);
390        Ok((keys, false))
391    } else {
392        let keys = generate_keys()?;
393        Ok((keys, true))
394    }
395}
396
397/// Read existing keys
398pub fn read_keys() -> Result<Keys> {
399    let keys_path = get_keys_path();
400    let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
401    let entries = hashtree_config::parse_keys_file(&content);
402    let nsec_str = entries
403        .into_iter()
404        .next()
405        .map(|e| e.secret)
406        .context("Keys file is empty")?;
407    let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
408    Ok(Keys::new(secret_key))
409}
410
411/// Get nsec string, ensuring keys file exists (generate if needed)
412/// Returns (nsec_string, was_generated)
413pub fn ensure_keys_string() -> Result<(String, bool)> {
414    let keys_path = get_keys_path();
415
416    if keys_path.exists() {
417        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
418        let entries = hashtree_config::parse_keys_file(&content);
419        let nsec_str = entries
420            .into_iter()
421            .next()
422            .map(|e| e.secret)
423            .context("Keys file is empty")?;
424        Ok((nsec_str, false))
425    } else {
426        let keys = generate_keys()?;
427        let nsec = keys
428            .secret_key()
429            .to_bech32()
430            .context("Failed to encode nsec")?;
431        Ok((nsec, true))
432    }
433}
434
435/// Generate new keys and save to file
436pub fn generate_keys() -> Result<Keys> {
437    let keys_path = get_keys_path();
438
439    // Ensure parent directory exists
440    if let Some(parent) = keys_path.parent() {
441        fs::create_dir_all(parent)?;
442    }
443
444    // Generate new keys
445    let keys = Keys::generate();
446    let nsec = keys
447        .secret_key()
448        .to_bech32()
449        .context("Failed to encode nsec")?;
450
451    // Save to file
452    fs::write(&keys_path, &nsec)?;
453
454    // Set permissions to 0600 (owner read/write only)
455    #[cfg(unix)]
456    {
457        use std::os::unix::fs::PermissionsExt;
458        let perms = fs::Permissions::from_mode(0o600);
459        fs::set_permissions(&keys_path, perms)?;
460    }
461
462    Ok(keys)
463}
464
465/// Get 32-byte pubkey bytes from Keys (for nostrdb)
466pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
467    keys.public_key().to_bytes()
468}
469
470/// Parse npub to 32-byte pubkey
471pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
472    use nostr::PublicKey;
473    let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
474    Ok(pk.to_bytes())
475}
476
477/// Generate new random auth cookie
478pub fn generate_auth_cookie() -> Result<(String, String)> {
479    use rand::Rng;
480
481    let cookie_path = get_auth_cookie_path();
482
483    // Ensure parent directory exists
484    if let Some(parent) = cookie_path.parent() {
485        fs::create_dir_all(parent)?;
486    }
487
488    // Generate random credentials
489    let mut rng = rand::thread_rng();
490    let username = format!("htree_{}", rng.gen::<u32>());
491    let password: String = (0..32)
492        .map(|_| {
493            let idx = rng.gen_range(0..62);
494            match idx {
495                0..=25 => (b'a' + idx) as char,
496                26..=51 => (b'A' + (idx - 26)) as char,
497                _ => (b'0' + (idx - 52)) as char,
498            }
499        })
500        .collect();
501
502    // Save to file
503    let content = format!("{}:{}", username, password);
504    fs::write(&cookie_path, content)?;
505
506    // Set permissions to 0600 (owner read/write only)
507    #[cfg(unix)]
508    {
509        use std::os::unix::fs::PermissionsExt;
510        let perms = fs::Permissions::from_mode(0o600);
511        fs::set_permissions(&cookie_path, perms)?;
512    }
513
514    Ok((username, password))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use tempfile::TempDir;
521
522    #[test]
523    fn test_config_default() {
524        let config = Config::default();
525        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
526        assert_eq!(config.server.enable_auth, true);
527        assert_eq!(config.storage.max_size_gb, 10);
528        assert_eq!(config.nostr.crawl_depth, 2);
529        assert_eq!(config.nostr.max_write_distance, 3);
530        assert_eq!(config.nostr.db_max_size_gb, 10);
531        assert_eq!(config.nostr.spambox_max_size_gb, 1);
532        assert!(config.nostr.socialgraph_root.is_none());
533        assert_eq!(config.server.socialgraph_snapshot_public, false);
534    }
535
536    #[test]
537    fn test_nostr_config_deserialize_with_defaults() {
538        let toml_str = r#"
539[nostr]
540relays = ["wss://relay.damus.io"]
541"#;
542        let config: Config = toml::from_str(toml_str).unwrap();
543        assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
544        assert_eq!(config.nostr.crawl_depth, 2);
545        assert_eq!(config.nostr.max_write_distance, 3);
546        assert_eq!(config.nostr.db_max_size_gb, 10);
547        assert_eq!(config.nostr.spambox_max_size_gb, 1);
548        assert!(config.nostr.socialgraph_root.is_none());
549    }
550
551    #[test]
552    fn test_nostr_config_deserialize_with_socialgraph() {
553        let toml_str = r#"
554[nostr]
555relays = ["wss://relay.damus.io"]
556socialgraph_root = "npub1test"
557crawl_depth = 3
558max_write_distance = 5
559"#;
560        let config: Config = toml::from_str(toml_str).unwrap();
561        assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
562        assert_eq!(config.nostr.crawl_depth, 3);
563        assert_eq!(config.nostr.max_write_distance, 5);
564        assert_eq!(config.nostr.db_max_size_gb, 10);
565        assert_eq!(config.nostr.spambox_max_size_gb, 1);
566    }
567
568    #[test]
569    fn test_auth_cookie_generation() -> Result<()> {
570        let temp_dir = TempDir::new()?;
571
572        // Mock the cookie path
573        std::env::set_var("HOME", temp_dir.path());
574
575        let (username, password) = generate_auth_cookie()?;
576
577        assert!(username.starts_with("htree_"));
578        assert_eq!(password.len(), 32);
579
580        // Verify cookie file exists
581        let cookie_path = get_auth_cookie_path();
582        assert!(cookie_path.exists());
583
584        // Verify reading works
585        let (u2, p2) = read_auth_cookie()?;
586        assert_eq!(username, u2);
587        assert_eq!(password, p2);
588
589        Ok(())
590    }
591}