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