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