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