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
107// Keep in sync with hashtree-config/src/lib.rs
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://upload.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    hashtree_config::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// Re-export path functions from hashtree_config
303pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
304
305/// Generate and save auth cookie if it doesn't exist
306pub fn ensure_auth_cookie() -> Result<(String, String)> {
307    let cookie_path = get_auth_cookie_path();
308
309    if cookie_path.exists() {
310        read_auth_cookie()
311    } else {
312        generate_auth_cookie()
313    }
314}
315
316/// Read existing auth cookie
317pub fn read_auth_cookie() -> Result<(String, String)> {
318    let cookie_path = get_auth_cookie_path();
319    let content = fs::read_to_string(&cookie_path)
320        .context("Failed to read auth cookie")?;
321
322    let parts: Vec<&str> = content.trim().split(':').collect();
323    if parts.len() != 2 {
324        anyhow::bail!("Invalid auth cookie format");
325    }
326
327    Ok((parts[0].to_string(), parts[1].to_string()))
328}
329
330/// Ensure keys file exists, generating one if not present
331/// Returns (Keys, was_generated)
332pub fn ensure_keys() -> Result<(Keys, bool)> {
333    let keys_path = get_keys_path();
334
335    if keys_path.exists() {
336        let nsec_str = fs::read_to_string(&keys_path)
337            .context("Failed to read keys file")?;
338        let nsec_str = nsec_str.trim();
339        let secret_key = SecretKey::from_bech32(nsec_str)
340            .context("Invalid nsec format")?;
341        let keys = Keys::new(secret_key);
342        Ok((keys, false))
343    } else {
344        let keys = generate_keys()?;
345        Ok((keys, true))
346    }
347}
348
349/// Read existing keys
350pub fn read_keys() -> Result<Keys> {
351    let keys_path = get_keys_path();
352    let nsec_str = fs::read_to_string(&keys_path)
353        .context("Failed to read keys file")?;
354    let nsec_str = nsec_str.trim();
355    let secret_key = SecretKey::from_bech32(nsec_str)
356        .context("Invalid nsec format")?;
357    Ok(Keys::new(secret_key))
358}
359
360/// Get nsec string, ensuring keys file exists (generate if needed)
361/// Returns (nsec_string, was_generated)
362pub fn ensure_keys_string() -> Result<(String, bool)> {
363    let keys_path = get_keys_path();
364
365    if keys_path.exists() {
366        let nsec_str = fs::read_to_string(&keys_path)
367            .context("Failed to read keys file")?;
368        Ok((nsec_str.trim().to_string(), false))
369    } else {
370        let keys = generate_keys()?;
371        let nsec = keys.secret_key().to_bech32()
372            .context("Failed to encode nsec")?;
373        Ok((nsec, true))
374    }
375}
376
377/// Generate new keys and save to file
378pub fn generate_keys() -> Result<Keys> {
379    let keys_path = get_keys_path();
380
381    // Ensure parent directory exists
382    if let Some(parent) = keys_path.parent() {
383        fs::create_dir_all(parent)?;
384    }
385
386    // Generate new keys
387    let keys = Keys::generate();
388    let nsec = keys.secret_key().to_bech32()
389        .context("Failed to encode nsec")?;
390
391    // Save to file
392    fs::write(&keys_path, &nsec)?;
393
394    // Set permissions to 0600 (owner read/write only)
395    #[cfg(unix)]
396    {
397        use std::os::unix::fs::PermissionsExt;
398        let perms = fs::Permissions::from_mode(0o600);
399        fs::set_permissions(&keys_path, perms)?;
400    }
401
402    Ok(keys)
403}
404
405/// Get 32-byte pubkey bytes from Keys (for nostrdb)
406pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
407    keys.public_key().to_bytes()
408}
409
410/// Parse npub to 32-byte pubkey
411pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
412    use nostr::PublicKey;
413    let pk = PublicKey::from_bech32(npub)
414        .context("Invalid npub format")?;
415    Ok(pk.to_bytes())
416}
417
418/// Generate new random auth cookie
419pub fn generate_auth_cookie() -> Result<(String, String)> {
420    use rand::Rng;
421
422    let cookie_path = get_auth_cookie_path();
423
424    // Ensure parent directory exists
425    if let Some(parent) = cookie_path.parent() {
426        fs::create_dir_all(parent)?;
427    }
428
429    // Generate random credentials
430    let mut rng = rand::thread_rng();
431    let username = format!("htree_{}", rng.gen::<u32>());
432    let password: String = (0..32)
433        .map(|_| {
434            let idx = rng.gen_range(0..62);
435            match idx {
436                0..=25 => (b'a' + idx) as char,
437                26..=51 => (b'A' + (idx - 26)) as char,
438                _ => (b'0' + (idx - 52)) as char,
439            }
440        })
441        .collect();
442
443    // Save to file
444    let content = format!("{}:{}", username, password);
445    fs::write(&cookie_path, content)?;
446
447    // Set permissions to 0600 (owner read/write only)
448    #[cfg(unix)]
449    {
450        use std::os::unix::fs::PermissionsExt;
451        let perms = fs::Permissions::from_mode(0o600);
452        fs::set_permissions(&cookie_path, perms)?;
453    }
454
455    Ok((username, password))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use tempfile::TempDir;
462
463    #[test]
464    fn test_config_default() {
465        let config = Config::default();
466        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
467        assert_eq!(config.server.enable_auth, true);
468        assert_eq!(config.storage.max_size_gb, 10);
469    }
470
471    #[test]
472    fn test_auth_cookie_generation() -> Result<()> {
473        let temp_dir = TempDir::new()?;
474
475        // Mock the cookie path
476        std::env::set_var("HOME", temp_dir.path());
477
478        let (username, password) = generate_auth_cookie()?;
479
480        assert!(username.starts_with("htree_"));
481        assert_eq!(password.len(), 32);
482
483        // Verify cookie file exists
484        let cookie_path = get_auth_cookie_path();
485        assert!(cookie_path.exists());
486
487        // Verify reading works
488        let (u2, p2) = read_auth_cookie()?;
489        assert_eq!(username, u2);
490        assert_eq!(password, p2);
491
492        Ok(())
493    }
494}