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