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}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct BlossomConfig {
93    /// File servers for push/pull (legacy, both read and write)
94    #[serde(default)]
95    pub servers: Vec<String>,
96    /// Read-only file servers (fallback for fetching content)
97    #[serde(default = "default_read_servers")]
98    pub read_servers: Vec<String>,
99    /// Write-enabled file servers (for uploading)
100    #[serde(default = "default_write_servers")]
101    pub write_servers: Vec<String>,
102    /// Maximum upload size in MB (default: 5)
103    #[serde(default = "default_max_upload_mb")]
104    pub max_upload_mb: u64,
105}
106
107fn default_read_servers() -> Vec<String> {
108    vec!["https://cdn.iris.to".to_string(), "https://hashtree.iris.to".to_string()]
109}
110
111fn default_write_servers() -> Vec<String> {
112    vec!["https://hashtree.iris.to".to_string()]
113}
114
115fn default_max_upload_mb() -> u64 {
116    5
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SyncConfig {
121    /// Enable background sync (auto-pull trees)
122    #[serde(default = "default_sync_enabled")]
123    pub enabled: bool,
124    /// Sync own trees (subscribed via Nostr)
125    #[serde(default = "default_sync_own")]
126    pub sync_own: bool,
127    /// Sync followed users' public trees
128    #[serde(default = "default_sync_followed")]
129    pub sync_followed: bool,
130    /// Max concurrent sync tasks
131    #[serde(default = "default_max_concurrent")]
132    pub max_concurrent: usize,
133    /// WebRTC request timeout in milliseconds
134    #[serde(default = "default_webrtc_timeout_ms")]
135    pub webrtc_timeout_ms: u64,
136    /// Blossom request timeout in milliseconds
137    #[serde(default = "default_blossom_timeout_ms")]
138    pub blossom_timeout_ms: u64,
139}
140
141
142fn default_sync_enabled() -> bool {
143    true
144}
145
146fn default_sync_own() -> bool {
147    true
148}
149
150fn default_sync_followed() -> bool {
151    true
152}
153
154fn default_max_concurrent() -> usize {
155    3
156}
157
158fn default_webrtc_timeout_ms() -> u64 {
159    2000
160}
161
162fn default_blossom_timeout_ms() -> u64 {
163    10000
164}
165
166fn default_relays() -> Vec<String> {
167    vec![
168        "wss://relay.damus.io".to_string(),
169        "wss://relay.snort.social".to_string(),
170        "wss://nos.lol".to_string(),
171        "wss://temp.iris.to".to_string(),
172    ]
173}
174
175fn default_bind_address() -> String {
176    "127.0.0.1:8080".to_string()
177}
178
179fn default_enable_auth() -> bool {
180    true
181}
182
183fn default_stun_port() -> u16 {
184    3478 // Standard STUN port (RFC 5389)
185}
186
187fn default_enable_webrtc() -> bool {
188    true
189}
190
191fn default_data_dir() -> String {
192    hashtree_config::get_hashtree_dir()
193        .join("data")
194        .to_string_lossy()
195        .to_string()
196}
197
198fn default_max_size_gb() -> u64 {
199    10
200}
201
202impl Default for ServerConfig {
203    fn default() -> Self {
204        Self {
205            bind_address: default_bind_address(),
206            enable_auth: default_enable_auth(),
207            stun_port: default_stun_port(),
208            enable_webrtc: default_enable_webrtc(),
209            public_writes: default_public_writes(),
210        }
211    }
212}
213
214impl Default for StorageConfig {
215    fn default() -> Self {
216        Self {
217            data_dir: default_data_dir(),
218            max_size_gb: default_max_size_gb(),
219            s3: None,
220        }
221    }
222}
223
224impl Default for NostrConfig {
225    fn default() -> Self {
226        Self {
227            relays: default_relays(),
228            allowed_npubs: Vec::new(),
229        }
230    }
231}
232
233impl Default for BlossomConfig {
234    fn default() -> Self {
235        Self {
236            servers: Vec::new(),
237            read_servers: default_read_servers(),
238            write_servers: default_write_servers(),
239            max_upload_mb: default_max_upload_mb(),
240        }
241    }
242}
243
244impl Default for SyncConfig {
245    fn default() -> Self {
246        Self {
247            enabled: default_sync_enabled(),
248            sync_own: default_sync_own(),
249            sync_followed: default_sync_followed(),
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
257impl Default for Config {
258    fn default() -> Self {
259        Self {
260            server: ServerConfig::default(),
261            storage: StorageConfig::default(),
262            nostr: NostrConfig::default(),
263            blossom: BlossomConfig::default(),
264            sync: SyncConfig::default(),
265        }
266    }
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    /// Save config to file
286    pub fn save(&self) -> Result<()> {
287        let config_path = get_config_path();
288
289        // Ensure parent directory exists
290        if let Some(parent) = config_path.parent() {
291            fs::create_dir_all(parent)?;
292        }
293
294        let content = toml::to_string_pretty(self)?;
295        fs::write(&config_path, content)?;
296
297        Ok(())
298    }
299}
300
301// Re-export path functions from hashtree_config
302pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
303
304/// Generate and save auth cookie if it doesn't exist
305pub fn ensure_auth_cookie() -> Result<(String, String)> {
306    let cookie_path = get_auth_cookie_path();
307
308    if cookie_path.exists() {
309        read_auth_cookie()
310    } else {
311        generate_auth_cookie()
312    }
313}
314
315/// Read existing auth cookie
316pub fn read_auth_cookie() -> Result<(String, String)> {
317    let cookie_path = get_auth_cookie_path();
318    let content = fs::read_to_string(&cookie_path)
319        .context("Failed to read auth cookie")?;
320
321    let parts: Vec<&str> = content.trim().split(':').collect();
322    if parts.len() != 2 {
323        anyhow::bail!("Invalid auth cookie format");
324    }
325
326    Ok((parts[0].to_string(), parts[1].to_string()))
327}
328
329/// Ensure keys file exists, generating one if not present
330/// Returns (Keys, was_generated)
331pub fn ensure_keys() -> Result<(Keys, bool)> {
332    let keys_path = get_keys_path();
333
334    if keys_path.exists() {
335        let nsec_str = fs::read_to_string(&keys_path)
336            .context("Failed to read keys file")?;
337        let nsec_str = nsec_str.trim();
338        let secret_key = SecretKey::from_bech32(nsec_str)
339            .context("Invalid nsec format")?;
340        let keys = Keys::new(secret_key);
341        Ok((keys, false))
342    } else {
343        let keys = generate_keys()?;
344        Ok((keys, true))
345    }
346}
347
348/// Read existing keys
349pub fn read_keys() -> Result<Keys> {
350    let keys_path = get_keys_path();
351    let nsec_str = fs::read_to_string(&keys_path)
352        .context("Failed to read keys file")?;
353    let nsec_str = nsec_str.trim();
354    let secret_key = SecretKey::from_bech32(nsec_str)
355        .context("Invalid nsec format")?;
356    Ok(Keys::new(secret_key))
357}
358
359/// Get nsec string, ensuring keys file exists (generate if needed)
360/// Returns (nsec_string, was_generated)
361pub fn ensure_keys_string() -> Result<(String, bool)> {
362    let keys_path = get_keys_path();
363
364    if keys_path.exists() {
365        let nsec_str = fs::read_to_string(&keys_path)
366            .context("Failed to read keys file")?;
367        Ok((nsec_str.trim().to_string(), false))
368    } else {
369        let keys = generate_keys()?;
370        let nsec = keys.secret_key().to_bech32()
371            .context("Failed to encode nsec")?;
372        Ok((nsec, true))
373    }
374}
375
376/// Generate new keys and save to file
377pub fn generate_keys() -> Result<Keys> {
378    let keys_path = get_keys_path();
379
380    // Ensure parent directory exists
381    if let Some(parent) = keys_path.parent() {
382        fs::create_dir_all(parent)?;
383    }
384
385    // Generate new keys
386    let keys = Keys::generate();
387    let nsec = keys.secret_key().to_bech32()
388        .context("Failed to encode nsec")?;
389
390    // Save to file
391    fs::write(&keys_path, &nsec)?;
392
393    // Set permissions to 0600 (owner read/write only)
394    #[cfg(unix)]
395    {
396        use std::os::unix::fs::PermissionsExt;
397        let perms = fs::Permissions::from_mode(0o600);
398        fs::set_permissions(&keys_path, perms)?;
399    }
400
401    Ok(keys)
402}
403
404/// Get 32-byte pubkey bytes from Keys (for nostrdb)
405pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
406    keys.public_key().to_bytes()
407}
408
409/// Parse npub to 32-byte pubkey
410pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
411    use nostr::PublicKey;
412    let pk = PublicKey::from_bech32(npub)
413        .context("Invalid npub format")?;
414    Ok(pk.to_bytes())
415}
416
417/// Generate new random auth cookie
418pub fn generate_auth_cookie() -> Result<(String, String)> {
419    use rand::Rng;
420
421    let cookie_path = get_auth_cookie_path();
422
423    // Ensure parent directory exists
424    if let Some(parent) = cookie_path.parent() {
425        fs::create_dir_all(parent)?;
426    }
427
428    // Generate random credentials
429    let mut rng = rand::thread_rng();
430    let username = format!("htree_{}", rng.gen::<u32>());
431    let password: String = (0..32)
432        .map(|_| {
433            let idx = rng.gen_range(0..62);
434            match idx {
435                0..=25 => (b'a' + idx) as char,
436                26..=51 => (b'A' + (idx - 26)) as char,
437                _ => (b'0' + (idx - 52)) as char,
438            }
439        })
440        .collect();
441
442    // Save to file
443    let content = format!("{}:{}", username, password);
444    fs::write(&cookie_path, content)?;
445
446    // Set permissions to 0600 (owner read/write only)
447    #[cfg(unix)]
448    {
449        use std::os::unix::fs::PermissionsExt;
450        let perms = fs::Permissions::from_mode(0o600);
451        fs::set_permissions(&cookie_path, perms)?;
452    }
453
454    Ok((username, password))
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use tempfile::TempDir;
461
462    #[test]
463    fn test_config_default() {
464        let config = Config::default();
465        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
466        assert_eq!(config.server.enable_auth, true);
467        assert_eq!(config.storage.max_size_gb, 10);
468    }
469
470    #[test]
471    fn test_auth_cookie_generation() -> Result<()> {
472        let temp_dir = TempDir::new()?;
473
474        // Mock the cookie path
475        std::env::set_var("HOME", temp_dir.path());
476
477        let (username, password) = generate_auth_cookie()?;
478
479        assert!(username.starts_with("htree_"));
480        assert_eq!(password.len(), 32);
481
482        // Verify cookie file exists
483        let cookie_path = get_auth_cookie_path();
484        assert!(cookie_path.exists());
485
486        // Verify reading works
487        let (u2, p2) = read_auth_cookie()?;
488        assert_eq!(username, u2);
489        assert_eq!(password, p2);
490
491        Ok(())
492    }
493}