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, Default, 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    #[serde(default)]
20    pub cashu: CashuConfig,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ServerConfig {
25    #[serde(default = "default_bind_address")]
26    pub bind_address: String,
27    #[serde(default = "default_enable_auth")]
28    pub enable_auth: bool,
29    /// Port for the built-in STUN server (0 = disabled)
30    #[serde(default = "default_stun_port")]
31    pub stun_port: u16,
32    /// Enable WebRTC P2P connections
33    #[serde(default = "default_enable_webrtc")]
34    pub enable_webrtc: bool,
35    /// Enable LAN multicast discovery/signaling for native peers.
36    #[serde(default = "default_enable_multicast")]
37    pub enable_multicast: bool,
38    /// IPv4 multicast group used for LAN discovery/signaling.
39    #[serde(default = "default_multicast_group")]
40    pub multicast_group: String,
41    /// UDP port used for LAN multicast discovery/signaling.
42    #[serde(default = "default_multicast_port")]
43    pub multicast_port: u16,
44    /// Maximum peers admitted from LAN multicast discovery.
45    /// Set to 0 to disable multicast even when enable_multicast is true.
46    #[serde(default = "default_max_multicast_peers")]
47    pub max_multicast_peers: usize,
48    /// Enable Android Wi-Fi Aware nearby discovery/signaling for native peers.
49    #[serde(default = "default_enable_wifi_aware")]
50    pub enable_wifi_aware: bool,
51    /// Maximum peers admitted from Wi-Fi Aware discovery.
52    /// Set to 0 to disable Wi-Fi Aware even when enable_wifi_aware is true.
53    #[serde(default = "default_max_wifi_aware_peers")]
54    pub max_wifi_aware_peers: usize,
55    /// Enable native Bluetooth discovery/transport for nearby peers.
56    #[serde(default = "default_enable_bluetooth")]
57    pub enable_bluetooth: bool,
58    /// Maximum peers admitted from Bluetooth discovery.
59    /// Set to 0 to disable Bluetooth even when enable_bluetooth is true.
60    #[serde(default = "default_max_bluetooth_peers")]
61    pub max_bluetooth_peers: usize,
62    /// Allow anyone with valid Nostr auth to write (default: true)
63    /// When false, only social graph members can write
64    #[serde(default = "default_public_writes")]
65    pub public_writes: bool,
66    /// Allow public access to social graph snapshot endpoint (default: false)
67    #[serde(default = "default_socialgraph_snapshot_public")]
68    pub socialgraph_snapshot_public: bool,
69}
70
71fn default_public_writes() -> bool {
72    true
73}
74
75fn default_socialgraph_snapshot_public() -> bool {
76    false
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct StorageConfig {
81    #[serde(default = "default_data_dir")]
82    pub data_dir: String,
83    #[serde(default = "default_max_size_gb")]
84    pub max_size_gb: u64,
85    /// Optional S3/R2 backend for blob storage
86    #[serde(default)]
87    pub s3: Option<S3Config>,
88}
89
90/// S3-compatible storage configuration (works with AWS S3, Cloudflare R2, MinIO, etc.)
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct S3Config {
93    /// S3 endpoint URL (e.g., "https://<account_id>.r2.cloudflarestorage.com" for R2)
94    pub endpoint: String,
95    /// S3 bucket name
96    pub bucket: String,
97    /// Optional key prefix for all blobs (e.g., "blobs/")
98    #[serde(default)]
99    pub prefix: Option<String>,
100    /// AWS region (use "auto" for R2)
101    #[serde(default = "default_s3_region")]
102    pub region: String,
103    /// Access key ID (can also be set via AWS_ACCESS_KEY_ID env var)
104    #[serde(default)]
105    pub access_key: Option<String>,
106    /// Secret access key (can also be set via AWS_SECRET_ACCESS_KEY env var)
107    #[serde(default)]
108    pub secret_key: Option<String>,
109    /// Public URL for serving blobs (optional, for generating public URLs)
110    #[serde(default)]
111    pub public_url: Option<String>,
112}
113
114fn default_s3_region() -> String {
115    "auto".to_string()
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct NostrConfig {
120    #[serde(default = "default_nostr_enabled")]
121    pub enabled: bool,
122    #[serde(default = "default_relays")]
123    pub relays: Vec<String>,
124    /// List of npubs allowed to write (blossom uploads). If empty, uses public_writes setting.
125    #[serde(default)]
126    pub allowed_npubs: Vec<String>,
127    /// Social graph root pubkey (npub). Defaults to own key if not set.
128    #[serde(default)]
129    pub socialgraph_root: Option<String>,
130    /// How many hops to crawl the social graph (default: 2)
131    #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
132    pub social_graph_crawl_depth: u32,
133    /// Max follow distance for write access (default: 3)
134    #[serde(default = "default_max_write_distance")]
135    pub max_write_distance: u32,
136    /// Max size for the trusted social graph store in GB (default: 10)
137    #[serde(default = "default_nostr_db_max_size_gb")]
138    pub db_max_size_gb: u64,
139    /// Max size for the social graph spambox in GB (default: 1)
140    /// Set to 0 for memory-only spambox (no on-disk DB)
141    #[serde(default = "default_nostr_spambox_max_size_gb")]
142    pub spambox_max_size_gb: u64,
143    /// Require relays to support NIP-77 negentropy for mirror history sync.
144    #[serde(default)]
145    pub negentropy_only: bool,
146    /// Kinds mirrored from upstream relays for the trusted hashtree index.
147    #[serde(default = "default_nostr_mirror_kinds")]
148    pub mirror_kinds: Vec<u16>,
149    /// How many graph authors to reconcile before checkpointing the mirror root.
150    #[serde(default = "default_nostr_history_sync_author_chunk_size")]
151    pub history_sync_author_chunk_size: usize,
152    /// Run a catch-up history sync after relay reconnects.
153    #[serde(default = "default_nostr_history_sync_on_reconnect")]
154    pub history_sync_on_reconnect: bool,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct BlossomConfig {
159    #[serde(default = "default_blossom_enabled")]
160    pub enabled: bool,
161    /// File servers for push/pull (legacy, both read and write)
162    #[serde(default)]
163    pub servers: Vec<String>,
164    /// Read-only file servers (fallback for fetching content)
165    #[serde(default = "default_read_servers")]
166    pub read_servers: Vec<String>,
167    /// Write-enabled file servers (for uploading)
168    #[serde(default = "default_write_servers")]
169    pub write_servers: Vec<String>,
170    /// Maximum upload size in MB (default: 5)
171    #[serde(default = "default_max_upload_mb")]
172    pub max_upload_mb: u64,
173}
174
175impl BlossomConfig {
176    pub fn all_read_servers(&self) -> Vec<String> {
177        if !self.enabled {
178            return Vec::new();
179        }
180        let mut servers = self.servers.clone();
181        servers.extend(self.read_servers.clone());
182        if servers.is_empty() {
183            servers = default_read_servers();
184        }
185        servers.sort();
186        servers.dedup();
187        servers
188    }
189
190    pub fn all_write_servers(&self) -> Vec<String> {
191        if !self.enabled {
192            return Vec::new();
193        }
194        let mut servers = self.servers.clone();
195        servers.extend(self.write_servers.clone());
196        if servers.is_empty() {
197            servers = default_write_servers();
198        }
199        servers.sort();
200        servers.dedup();
201        servers
202    }
203}
204
205impl NostrConfig {
206    pub fn active_relays(&self) -> Vec<String> {
207        if self.enabled {
208            self.relays.clone()
209        } else {
210            Vec::new()
211        }
212    }
213}
214
215// Keep in sync with hashtree-config/src/lib.rs
216fn default_read_servers() -> Vec<String> {
217    let mut servers = vec![
218        "https://blossom.primal.net".to_string(),
219        "https://cdn.iris.to".to_string(),
220        "https://hashtree.iris.to".to_string(),
221    ];
222    servers.sort();
223    servers
224}
225
226fn default_write_servers() -> Vec<String> {
227    vec!["https://upload.iris.to".to_string()]
228}
229
230fn default_max_upload_mb() -> u64 {
231    5
232}
233
234fn default_nostr_enabled() -> bool {
235    true
236}
237
238fn default_blossom_enabled() -> bool {
239    true
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct SyncConfig {
244    /// Enable background sync (auto-pull trees)
245    #[serde(default = "default_sync_enabled")]
246    pub enabled: bool,
247    /// Sync own trees (subscribed via Nostr)
248    #[serde(default = "default_sync_own")]
249    pub sync_own: bool,
250    /// Sync followed users' public trees
251    #[serde(default = "default_sync_followed")]
252    pub sync_followed: bool,
253    /// Max concurrent sync tasks
254    #[serde(default = "default_max_concurrent")]
255    pub max_concurrent: usize,
256    /// WebRTC request timeout in milliseconds
257    #[serde(default = "default_webrtc_timeout_ms")]
258    pub webrtc_timeout_ms: u64,
259    /// Blossom request timeout in milliseconds
260    #[serde(default = "default_blossom_timeout_ms")]
261    pub blossom_timeout_ms: u64,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct CashuConfig {
266    /// Cashu mint base URLs we accept for bandwidth incentives.
267    #[serde(default)]
268    pub accepted_mints: Vec<String>,
269    /// Default mint to use for wallet operations.
270    #[serde(default)]
271    pub default_mint: Option<String>,
272    /// Default post-delivery payment offer for quoted retrievals.
273    #[serde(default = "default_cashu_quote_payment_offer_sat")]
274    pub quote_payment_offer_sat: u64,
275    /// Quote validity window in milliseconds.
276    #[serde(default = "default_cashu_quote_ttl_ms")]
277    pub quote_ttl_ms: u32,
278    /// Maximum time to wait for post-delivery settlement before recording a default.
279    #[serde(default = "default_cashu_settlement_timeout_ms")]
280    pub settlement_timeout_ms: u64,
281    /// Block mints whose failed redemptions keep outnumbering successful redemptions.
282    #[serde(default = "default_cashu_mint_failure_block_threshold")]
283    pub mint_failure_block_threshold: u64,
284    /// Base cap for trying a peer-suggested mint we do not already trust.
285    #[serde(default = "default_cashu_peer_suggested_mint_base_cap_sat")]
286    pub peer_suggested_mint_base_cap_sat: u64,
287    /// Additional cap granted per successful delivery from that peer.
288    #[serde(default = "default_cashu_peer_suggested_mint_success_step_sat")]
289    pub peer_suggested_mint_success_step_sat: u64,
290    /// Additional cap granted per settled payment received from that peer.
291    #[serde(default = "default_cashu_peer_suggested_mint_receipt_step_sat")]
292    pub peer_suggested_mint_receipt_step_sat: u64,
293    /// Hard ceiling for untrusted peer-suggested mint exposure.
294    #[serde(default = "default_cashu_peer_suggested_mint_max_cap_sat")]
295    pub peer_suggested_mint_max_cap_sat: u64,
296    /// Block serving peers whose unpaid defaults reach this threshold.
297    #[serde(default)]
298    pub payment_default_block_threshold: u64,
299    /// Target chunk size for quoted paid delivery.
300    #[serde(default = "default_cashu_chunk_target_bytes")]
301    pub chunk_target_bytes: usize,
302}
303
304impl Default for CashuConfig {
305    fn default() -> Self {
306        Self {
307            accepted_mints: Vec::new(),
308            default_mint: None,
309            quote_payment_offer_sat: default_cashu_quote_payment_offer_sat(),
310            quote_ttl_ms: default_cashu_quote_ttl_ms(),
311            settlement_timeout_ms: default_cashu_settlement_timeout_ms(),
312            mint_failure_block_threshold: default_cashu_mint_failure_block_threshold(),
313            peer_suggested_mint_base_cap_sat: default_cashu_peer_suggested_mint_base_cap_sat(),
314            peer_suggested_mint_success_step_sat:
315                default_cashu_peer_suggested_mint_success_step_sat(),
316            peer_suggested_mint_receipt_step_sat:
317                default_cashu_peer_suggested_mint_receipt_step_sat(),
318            peer_suggested_mint_max_cap_sat: default_cashu_peer_suggested_mint_max_cap_sat(),
319            payment_default_block_threshold: 0,
320            chunk_target_bytes: default_cashu_chunk_target_bytes(),
321        }
322    }
323}
324
325fn default_cashu_quote_payment_offer_sat() -> u64 {
326    3
327}
328
329fn default_cashu_quote_ttl_ms() -> u32 {
330    1_500
331}
332
333fn default_cashu_settlement_timeout_ms() -> u64 {
334    5_000
335}
336
337fn default_cashu_mint_failure_block_threshold() -> u64 {
338    2
339}
340
341fn default_cashu_peer_suggested_mint_base_cap_sat() -> u64 {
342    3
343}
344
345fn default_cashu_peer_suggested_mint_success_step_sat() -> u64 {
346    1
347}
348
349fn default_cashu_peer_suggested_mint_receipt_step_sat() -> u64 {
350    2
351}
352
353fn default_cashu_peer_suggested_mint_max_cap_sat() -> u64 {
354    21
355}
356
357fn default_cashu_chunk_target_bytes() -> usize {
358    32 * 1024
359}
360
361fn default_sync_enabled() -> bool {
362    true
363}
364
365fn default_sync_own() -> bool {
366    true
367}
368
369fn default_sync_followed() -> bool {
370    true
371}
372
373fn default_max_concurrent() -> usize {
374    3
375}
376
377fn default_webrtc_timeout_ms() -> u64 {
378    2000
379}
380
381fn default_blossom_timeout_ms() -> u64 {
382    10000
383}
384
385fn default_social_graph_crawl_depth() -> u32 {
386    2
387}
388
389fn default_max_write_distance() -> u32 {
390    3
391}
392
393fn default_nostr_db_max_size_gb() -> u64 {
394    10
395}
396
397fn default_nostr_spambox_max_size_gb() -> u64 {
398    1
399}
400
401fn default_nostr_history_sync_on_reconnect() -> bool {
402    true
403}
404
405fn default_nostr_mirror_kinds() -> Vec<u16> {
406    vec![0]
407}
408
409fn default_nostr_history_sync_author_chunk_size() -> usize {
410    5_000
411}
412
413fn default_relays() -> Vec<String> {
414    vec![
415        "wss://relay.damus.io".to_string(),
416        "wss://relay.snort.social".to_string(),
417        "wss://nos.lol".to_string(),
418        "wss://temp.iris.to".to_string(),
419        "wss://upload.iris.to/nostr".to_string(),
420    ]
421}
422
423fn default_bind_address() -> String {
424    "127.0.0.1:8080".to_string()
425}
426
427fn default_enable_auth() -> bool {
428    true
429}
430
431fn default_stun_port() -> u16 {
432    3478 // Standard STUN port (RFC 5389)
433}
434
435fn default_enable_webrtc() -> bool {
436    true
437}
438
439fn default_enable_multicast() -> bool {
440    false
441}
442
443fn default_multicast_group() -> String {
444    "239.255.42.98".to_string()
445}
446
447fn default_multicast_port() -> u16 {
448    48555
449}
450
451fn default_max_multicast_peers() -> usize {
452    0
453}
454
455fn default_enable_wifi_aware() -> bool {
456    false
457}
458
459fn default_max_wifi_aware_peers() -> usize {
460    0
461}
462
463fn default_enable_bluetooth() -> bool {
464    false
465}
466
467fn default_max_bluetooth_peers() -> usize {
468    0
469}
470
471fn default_data_dir() -> String {
472    hashtree_config::get_hashtree_dir()
473        .join("data")
474        .to_string_lossy()
475        .to_string()
476}
477
478fn default_max_size_gb() -> u64 {
479    10
480}
481
482impl Default for ServerConfig {
483    fn default() -> Self {
484        Self {
485            bind_address: default_bind_address(),
486            enable_auth: default_enable_auth(),
487            stun_port: default_stun_port(),
488            enable_webrtc: default_enable_webrtc(),
489            enable_multicast: default_enable_multicast(),
490            multicast_group: default_multicast_group(),
491            multicast_port: default_multicast_port(),
492            max_multicast_peers: default_max_multicast_peers(),
493            enable_wifi_aware: default_enable_wifi_aware(),
494            max_wifi_aware_peers: default_max_wifi_aware_peers(),
495            enable_bluetooth: default_enable_bluetooth(),
496            max_bluetooth_peers: default_max_bluetooth_peers(),
497            public_writes: default_public_writes(),
498            socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
499        }
500    }
501}
502
503impl Default for StorageConfig {
504    fn default() -> Self {
505        Self {
506            data_dir: default_data_dir(),
507            max_size_gb: default_max_size_gb(),
508            s3: None,
509        }
510    }
511}
512
513impl Default for NostrConfig {
514    fn default() -> Self {
515        Self {
516            enabled: default_nostr_enabled(),
517            relays: default_relays(),
518            allowed_npubs: Vec::new(),
519            socialgraph_root: None,
520            social_graph_crawl_depth: default_social_graph_crawl_depth(),
521            max_write_distance: default_max_write_distance(),
522            db_max_size_gb: default_nostr_db_max_size_gb(),
523            spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
524            negentropy_only: false,
525            mirror_kinds: default_nostr_mirror_kinds(),
526            history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
527            history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
528        }
529    }
530}
531
532impl Default for BlossomConfig {
533    fn default() -> Self {
534        Self {
535            enabled: default_blossom_enabled(),
536            servers: Vec::new(),
537            read_servers: default_read_servers(),
538            write_servers: default_write_servers(),
539            max_upload_mb: default_max_upload_mb(),
540        }
541    }
542}
543
544impl Default for SyncConfig {
545    fn default() -> Self {
546        Self {
547            enabled: default_sync_enabled(),
548            sync_own: default_sync_own(),
549            sync_followed: default_sync_followed(),
550            max_concurrent: default_max_concurrent(),
551            webrtc_timeout_ms: default_webrtc_timeout_ms(),
552            blossom_timeout_ms: default_blossom_timeout_ms(),
553        }
554    }
555}
556
557impl Config {
558    /// Load config from file, or create default if doesn't exist
559    pub fn load() -> Result<Self> {
560        let config_path = get_config_path();
561
562        if config_path.exists() {
563            let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
564            toml::from_str(&content).context("Failed to parse config file")
565        } else {
566            let config = Config::default();
567            config.save()?;
568            Ok(config)
569        }
570    }
571
572    /// Save config to file
573    pub fn save(&self) -> Result<()> {
574        let config_path = get_config_path();
575
576        // Ensure parent directory exists
577        if let Some(parent) = config_path.parent() {
578            fs::create_dir_all(parent)?;
579        }
580
581        let content = toml::to_string_pretty(self)?;
582        fs::write(&config_path, content)?;
583
584        Ok(())
585    }
586}
587
588// Re-export path functions from hashtree_config
589pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
590
591/// Generate and save auth cookie if it doesn't exist
592pub fn ensure_auth_cookie() -> Result<(String, String)> {
593    let cookie_path = get_auth_cookie_path();
594
595    if cookie_path.exists() {
596        read_auth_cookie()
597    } else {
598        generate_auth_cookie()
599    }
600}
601
602/// Read existing auth cookie
603pub fn read_auth_cookie() -> Result<(String, String)> {
604    let cookie_path = get_auth_cookie_path();
605    let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
606
607    let parts: Vec<&str> = content.trim().split(':').collect();
608    if parts.len() != 2 {
609        anyhow::bail!("Invalid auth cookie format");
610    }
611
612    Ok((parts[0].to_string(), parts[1].to_string()))
613}
614
615/// Ensure keys file exists, generating one if not present
616/// Returns (Keys, was_generated)
617pub fn ensure_keys() -> Result<(Keys, bool)> {
618    let keys_path = get_keys_path();
619
620    if keys_path.exists() {
621        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
622        let entries = hashtree_config::parse_keys_file(&content);
623        let nsec_str = entries
624            .into_iter()
625            .next()
626            .map(|e| e.secret)
627            .context("Keys file is empty")?;
628        let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
629        let keys = Keys::new(secret_key);
630        Ok((keys, false))
631    } else {
632        let keys = generate_keys()?;
633        Ok((keys, true))
634    }
635}
636
637/// Read existing keys
638pub fn read_keys() -> Result<Keys> {
639    let keys_path = get_keys_path();
640    let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
641    let entries = hashtree_config::parse_keys_file(&content);
642    let nsec_str = entries
643        .into_iter()
644        .next()
645        .map(|e| e.secret)
646        .context("Keys file is empty")?;
647    let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
648    Ok(Keys::new(secret_key))
649}
650
651/// Get nsec string, ensuring keys file exists (generate if needed)
652/// Returns (nsec_string, was_generated)
653pub fn ensure_keys_string() -> Result<(String, bool)> {
654    let keys_path = get_keys_path();
655
656    if keys_path.exists() {
657        let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
658        let entries = hashtree_config::parse_keys_file(&content);
659        let nsec_str = entries
660            .into_iter()
661            .next()
662            .map(|e| e.secret)
663            .context("Keys file is empty")?;
664        Ok((nsec_str, false))
665    } else {
666        let keys = generate_keys()?;
667        let nsec = keys
668            .secret_key()
669            .to_bech32()
670            .context("Failed to encode nsec")?;
671        Ok((nsec, true))
672    }
673}
674
675/// Generate new keys and save to file
676pub fn generate_keys() -> Result<Keys> {
677    let keys_path = get_keys_path();
678
679    // Ensure parent directory exists
680    if let Some(parent) = keys_path.parent() {
681        fs::create_dir_all(parent)?;
682    }
683
684    // Generate new keys
685    let keys = Keys::generate();
686    let nsec = keys
687        .secret_key()
688        .to_bech32()
689        .context("Failed to encode nsec")?;
690
691    // Save to file
692    fs::write(&keys_path, &nsec)?;
693
694    // Set permissions to 0600 (owner read/write only)
695    #[cfg(unix)]
696    {
697        use std::os::unix::fs::PermissionsExt;
698        let perms = fs::Permissions::from_mode(0o600);
699        fs::set_permissions(&keys_path, perms)?;
700    }
701
702    Ok(keys)
703}
704
705/// Get 32-byte pubkey bytes from Keys.
706pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
707    keys.public_key().to_bytes()
708}
709
710/// Parse npub to 32-byte pubkey
711pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
712    use nostr::PublicKey;
713    let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
714    Ok(pk.to_bytes())
715}
716
717/// Generate new random auth cookie
718pub fn generate_auth_cookie() -> Result<(String, String)> {
719    use rand::Rng;
720
721    let cookie_path = get_auth_cookie_path();
722
723    // Ensure parent directory exists
724    if let Some(parent) = cookie_path.parent() {
725        fs::create_dir_all(parent)?;
726    }
727
728    // Generate random credentials
729    let mut rng = rand::thread_rng();
730    let username = format!("htree_{}", rng.gen::<u32>());
731    let password: String = (0..32)
732        .map(|_| {
733            let idx = rng.gen_range(0..62);
734            match idx {
735                0..=25 => (b'a' + idx) as char,
736                26..=51 => (b'A' + (idx - 26)) as char,
737                _ => (b'0' + (idx - 52)) as char,
738            }
739        })
740        .collect();
741
742    // Save to file
743    let content = format!("{}:{}", username, password);
744    fs::write(&cookie_path, content)?;
745
746    // Set permissions to 0600 (owner read/write only)
747    #[cfg(unix)]
748    {
749        use std::os::unix::fs::PermissionsExt;
750        let perms = fs::Permissions::from_mode(0o600);
751        fs::set_permissions(&cookie_path, perms)?;
752    }
753
754    Ok((username, password))
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use tempfile::TempDir;
761
762    #[test]
763    fn test_config_default() {
764        let config = Config::default();
765        assert_eq!(config.server.bind_address, "127.0.0.1:8080");
766        assert!(config.server.enable_auth);
767        assert!(!config.server.enable_multicast);
768        assert_eq!(config.server.multicast_group, "239.255.42.98");
769        assert_eq!(config.server.multicast_port, 48555);
770        assert_eq!(config.server.max_multicast_peers, 0);
771        assert!(!config.server.enable_wifi_aware);
772        assert_eq!(config.server.max_wifi_aware_peers, 0);
773        assert!(!config.server.enable_bluetooth);
774        assert_eq!(config.server.max_bluetooth_peers, 0);
775        assert_eq!(config.storage.max_size_gb, 10);
776        assert!(config.nostr.enabled);
777        assert!(config
778            .nostr
779            .relays
780            .contains(&"wss://upload.iris.to/nostr".to_string()));
781        assert!(config.blossom.enabled);
782        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
783        assert_eq!(config.nostr.max_write_distance, 3);
784        assert_eq!(config.nostr.db_max_size_gb, 10);
785        assert_eq!(config.nostr.spambox_max_size_gb, 1);
786        assert!(!config.nostr.negentropy_only);
787        assert_eq!(config.nostr.mirror_kinds, vec![0]);
788        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
789        assert!(config.nostr.history_sync_on_reconnect);
790        assert!(config.nostr.socialgraph_root.is_none());
791        assert!(!config.server.socialgraph_snapshot_public);
792        assert!(config.cashu.accepted_mints.is_empty());
793        assert!(config.cashu.default_mint.is_none());
794        assert_eq!(config.cashu.quote_payment_offer_sat, 3);
795        assert_eq!(config.cashu.quote_ttl_ms, 1_500);
796        assert_eq!(config.cashu.settlement_timeout_ms, 5_000);
797        assert_eq!(config.cashu.mint_failure_block_threshold, 2);
798        assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 3);
799        assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 1);
800        assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 2);
801        assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 21);
802        assert_eq!(config.cashu.payment_default_block_threshold, 0);
803        assert_eq!(config.cashu.chunk_target_bytes, 32 * 1024);
804    }
805
806    #[test]
807    fn test_nostr_config_deserialize_with_defaults() {
808        let toml_str = r#"
809[nostr]
810relays = ["wss://relay.damus.io"]
811"#;
812        let config: Config = toml::from_str(toml_str).unwrap();
813        assert!(config.nostr.enabled);
814        assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
815        assert_eq!(config.nostr.social_graph_crawl_depth, 2);
816        assert_eq!(config.nostr.max_write_distance, 3);
817        assert_eq!(config.nostr.db_max_size_gb, 10);
818        assert_eq!(config.nostr.spambox_max_size_gb, 1);
819        assert!(!config.nostr.negentropy_only);
820        assert_eq!(config.nostr.mirror_kinds, vec![0]);
821        assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
822        assert!(config.nostr.history_sync_on_reconnect);
823        assert!(config.nostr.socialgraph_root.is_none());
824    }
825
826    #[test]
827    fn test_nostr_config_deserialize_with_socialgraph() {
828        let toml_str = r#"
829[nostr]
830relays = ["wss://relay.damus.io"]
831socialgraph_root = "npub1test"
832social_graph_crawl_depth = 3
833max_write_distance = 5
834negentropy_only = true
835mirror_kinds = [0, 10000]
836history_sync_author_chunk_size = 250
837history_sync_on_reconnect = false
838"#;
839        let config: Config = toml::from_str(toml_str).unwrap();
840        assert!(config.nostr.enabled);
841        assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
842        assert_eq!(config.nostr.social_graph_crawl_depth, 3);
843        assert_eq!(config.nostr.max_write_distance, 5);
844        assert_eq!(config.nostr.db_max_size_gb, 10);
845        assert_eq!(config.nostr.spambox_max_size_gb, 1);
846        assert!(config.nostr.negentropy_only);
847        assert_eq!(config.nostr.mirror_kinds, vec![0, 10_000]);
848        assert_eq!(config.nostr.history_sync_author_chunk_size, 250);
849        assert!(!config.nostr.history_sync_on_reconnect);
850    }
851
852    #[test]
853    fn test_nostr_config_deserialize_legacy_crawl_depth_alias() {
854        let toml_str = r#"
855[nostr]
856relays = ["wss://relay.damus.io"]
857crawl_depth = 4
858"#;
859        let config: Config = toml::from_str(toml_str).unwrap();
860        assert_eq!(config.nostr.social_graph_crawl_depth, 4);
861    }
862
863    #[test]
864    fn test_server_config_deserialize_with_multicast() {
865        let toml_str = r#"
866[server]
867enable_multicast = true
868multicast_group = "239.255.42.99"
869multicast_port = 49001
870max_multicast_peers = 12
871enable_wifi_aware = true
872max_wifi_aware_peers = 5
873enable_bluetooth = true
874max_bluetooth_peers = 6
875"#;
876        let config: Config = toml::from_str(toml_str).unwrap();
877        assert!(config.server.enable_multicast);
878        assert_eq!(config.server.multicast_group, "239.255.42.99");
879        assert_eq!(config.server.multicast_port, 49_001);
880        assert_eq!(config.server.max_multicast_peers, 12);
881        assert!(config.server.enable_wifi_aware);
882        assert_eq!(config.server.max_wifi_aware_peers, 5);
883        assert!(config.server.enable_bluetooth);
884        assert_eq!(config.server.max_bluetooth_peers, 6);
885    }
886
887    #[test]
888    fn test_cashu_config_deserialize_with_accepted_mints() {
889        let toml_str = r#"
890[cashu]
891accepted_mints = ["https://mint1.example", "http://127.0.0.1:3338"]
892default_mint = "https://mint1.example"
893quote_payment_offer_sat = 5
894quote_ttl_ms = 2500
895settlement_timeout_ms = 7000
896mint_failure_block_threshold = 3
897peer_suggested_mint_base_cap_sat = 4
898peer_suggested_mint_success_step_sat = 2
899peer_suggested_mint_receipt_step_sat = 3
900peer_suggested_mint_max_cap_sat = 34
901payment_default_block_threshold = 2
902chunk_target_bytes = 65536
903"#;
904        let config: Config = toml::from_str(toml_str).unwrap();
905        assert_eq!(
906            config.cashu.accepted_mints,
907            vec![
908                "https://mint1.example".to_string(),
909                "http://127.0.0.1:3338".to_string()
910            ]
911        );
912        assert_eq!(
913            config.cashu.default_mint,
914            Some("https://mint1.example".to_string())
915        );
916        assert_eq!(config.cashu.quote_payment_offer_sat, 5);
917        assert_eq!(config.cashu.quote_ttl_ms, 2500);
918        assert_eq!(config.cashu.settlement_timeout_ms, 7_000);
919        assert_eq!(config.cashu.mint_failure_block_threshold, 3);
920        assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 4);
921        assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 2);
922        assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 3);
923        assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 34);
924        assert_eq!(config.cashu.payment_default_block_threshold, 2);
925        assert_eq!(config.cashu.chunk_target_bytes, 65_536);
926    }
927
928    #[test]
929    fn test_auth_cookie_generation() -> Result<()> {
930        let temp_dir = TempDir::new()?;
931
932        // Mock the cookie path
933        std::env::set_var("HOME", temp_dir.path());
934
935        let (username, password) = generate_auth_cookie()?;
936
937        assert!(username.starts_with("htree_"));
938        assert_eq!(password.len(), 32);
939
940        // Verify cookie file exists
941        let cookie_path = get_auth_cookie_path();
942        assert!(cookie_path.exists());
943
944        // Verify reading works
945        let (u2, p2) = read_auth_cookie()?;
946        assert_eq!(username, u2);
947        assert_eq!(password, p2);
948
949        Ok(())
950    }
951
952    #[test]
953    fn test_blossom_read_servers_exclude_write_only_servers() {
954        let mut config = BlossomConfig::default();
955        config.servers = vec!["https://legacy.server".to_string()];
956
957        let read = config.all_read_servers();
958        assert!(read.contains(&"https://legacy.server".to_string()));
959        assert!(read.contains(&"https://cdn.iris.to".to_string()));
960        assert!(read.contains(&"https://blossom.primal.net".to_string()));
961        assert!(!read.contains(&"https://upload.iris.to".to_string()));
962
963        let write = config.all_write_servers();
964        assert!(write.contains(&"https://legacy.server".to_string()));
965        assert!(write.contains(&"https://upload.iris.to".to_string()));
966    }
967
968    #[test]
969    fn test_blossom_servers_fall_back_to_defaults_when_explicitly_empty() {
970        let config = BlossomConfig {
971            enabled: true,
972            servers: Vec::new(),
973            read_servers: Vec::new(),
974            write_servers: Vec::new(),
975            max_upload_mb: default_max_upload_mb(),
976        };
977
978        let read = config.all_read_servers();
979        assert_eq!(read, default_read_servers());
980
981        let write = config.all_write_servers();
982        assert_eq!(write, default_write_servers());
983    }
984
985    #[test]
986    fn test_disabled_sources_preserve_lists_but_return_no_active_endpoints() {
987        let nostr = NostrConfig {
988            enabled: false,
989            relays: vec!["wss://relay.example".to_string()],
990            ..NostrConfig::default()
991        };
992        assert!(nostr.active_relays().is_empty());
993
994        let blossom = BlossomConfig {
995            enabled: false,
996            servers: vec!["https://legacy.server".to_string()],
997            read_servers: vec!["https://read.example".to_string()],
998            write_servers: vec!["https://write.example".to_string()],
999            max_upload_mb: default_max_upload_mb(),
1000        };
1001        assert!(blossom.all_read_servers().is_empty());
1002        assert!(blossom.all_write_servers().is_empty());
1003    }
1004}