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