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