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