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