1use anyhow::{Context, Result};
2use nostr::nips::nip19::{FromBech32, ToBech32};
3use nostr::{Keys, SecretKey};
4use serde::{Deserialize, Serialize};
5use std::fs;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct Config {
9 #[serde(default)]
10 pub server: ServerConfig,
11 #[serde(default)]
12 pub storage: StorageConfig,
13 #[serde(default)]
14 pub nostr: NostrConfig,
15 #[serde(default)]
16 pub blossom: BlossomConfig,
17 #[serde(default)]
18 pub sync: SyncConfig,
19 #[serde(default)]
20 pub cashu: CashuConfig,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ServerConfig {
25 #[serde(default = "default_bind_address")]
26 pub bind_address: String,
27 #[serde(default = "default_enable_auth")]
28 pub enable_auth: bool,
29 #[serde(default = "default_stun_port")]
31 pub stun_port: u16,
32 #[serde(default = "default_enable_webrtc")]
34 pub enable_webrtc: bool,
35 #[serde(default = "default_public_writes")]
38 pub public_writes: bool,
39 #[serde(default = "default_socialgraph_snapshot_public")]
41 pub socialgraph_snapshot_public: bool,
42}
43
44fn default_public_writes() -> bool {
45 true
46}
47
48fn default_socialgraph_snapshot_public() -> bool {
49 false
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct StorageConfig {
54 #[serde(default = "default_data_dir")]
55 pub data_dir: String,
56 #[serde(default = "default_max_size_gb")]
57 pub max_size_gb: u64,
58 #[serde(default)]
60 pub s3: Option<S3Config>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct S3Config {
66 pub endpoint: String,
68 pub bucket: String,
70 #[serde(default)]
72 pub prefix: Option<String>,
73 #[serde(default = "default_s3_region")]
75 pub region: String,
76 #[serde(default)]
78 pub access_key: Option<String>,
79 #[serde(default)]
81 pub secret_key: Option<String>,
82 #[serde(default)]
84 pub public_url: Option<String>,
85}
86
87fn default_s3_region() -> String {
88 "auto".to_string()
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct NostrConfig {
93 #[serde(default = "default_relays")]
94 pub relays: Vec<String>,
95 #[serde(default)]
97 pub allowed_npubs: Vec<String>,
98 #[serde(default)]
100 pub socialgraph_root: Option<String>,
101 #[serde(default = "default_crawl_depth")]
103 pub crawl_depth: u32,
104 #[serde(default = "default_max_write_distance")]
106 pub max_write_distance: u32,
107 #[serde(default = "default_nostr_db_max_size_gb")]
109 pub db_max_size_gb: u64,
110 #[serde(default = "default_nostr_spambox_max_size_gb")]
113 pub spambox_max_size_gb: u64,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct BlossomConfig {
118 #[serde(default)]
120 pub servers: Vec<String>,
121 #[serde(default = "default_read_servers")]
123 pub read_servers: Vec<String>,
124 #[serde(default = "default_write_servers")]
126 pub write_servers: Vec<String>,
127 #[serde(default = "default_max_upload_mb")]
129 pub max_upload_mb: u64,
130}
131
132fn default_read_servers() -> Vec<String> {
134 vec![
135 "https://cdn.iris.to".to_string(),
136 "https://hashtree.iris.to".to_string(),
137 ]
138}
139
140fn default_write_servers() -> Vec<String> {
141 vec!["https://upload.iris.to".to_string()]
142}
143
144fn default_max_upload_mb() -> u64 {
145 5
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SyncConfig {
150 #[serde(default = "default_sync_enabled")]
152 pub enabled: bool,
153 #[serde(default = "default_sync_own")]
155 pub sync_own: bool,
156 #[serde(default = "default_sync_followed")]
158 pub sync_followed: bool,
159 #[serde(default = "default_max_concurrent")]
161 pub max_concurrent: usize,
162 #[serde(default = "default_webrtc_timeout_ms")]
164 pub webrtc_timeout_ms: u64,
165 #[serde(default = "default_blossom_timeout_ms")]
167 pub blossom_timeout_ms: u64,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CashuConfig {
172 #[serde(default)]
174 pub accepted_mints: Vec<String>,
175 #[serde(default)]
177 pub default_mint: Option<String>,
178 #[serde(default = "default_cashu_quote_payment_offer_sat")]
180 pub quote_payment_offer_sat: u64,
181 #[serde(default = "default_cashu_quote_ttl_ms")]
183 pub quote_ttl_ms: u32,
184 #[serde(default = "default_cashu_settlement_timeout_ms")]
186 pub settlement_timeout_ms: u64,
187 #[serde(default = "default_cashu_mint_failure_block_threshold")]
189 pub mint_failure_block_threshold: u64,
190 #[serde(default = "default_cashu_peer_suggested_mint_base_cap_sat")]
192 pub peer_suggested_mint_base_cap_sat: u64,
193 #[serde(default = "default_cashu_peer_suggested_mint_success_step_sat")]
195 pub peer_suggested_mint_success_step_sat: u64,
196 #[serde(default = "default_cashu_peer_suggested_mint_receipt_step_sat")]
198 pub peer_suggested_mint_receipt_step_sat: u64,
199 #[serde(default = "default_cashu_peer_suggested_mint_max_cap_sat")]
201 pub peer_suggested_mint_max_cap_sat: u64,
202 #[serde(default)]
204 pub payment_default_block_threshold: u64,
205 #[serde(default = "default_cashu_chunk_target_bytes")]
207 pub chunk_target_bytes: usize,
208}
209
210impl Default for CashuConfig {
211 fn default() -> Self {
212 Self {
213 accepted_mints: Vec::new(),
214 default_mint: None,
215 quote_payment_offer_sat: default_cashu_quote_payment_offer_sat(),
216 quote_ttl_ms: default_cashu_quote_ttl_ms(),
217 settlement_timeout_ms: default_cashu_settlement_timeout_ms(),
218 mint_failure_block_threshold: default_cashu_mint_failure_block_threshold(),
219 peer_suggested_mint_base_cap_sat: default_cashu_peer_suggested_mint_base_cap_sat(),
220 peer_suggested_mint_success_step_sat:
221 default_cashu_peer_suggested_mint_success_step_sat(),
222 peer_suggested_mint_receipt_step_sat:
223 default_cashu_peer_suggested_mint_receipt_step_sat(),
224 peer_suggested_mint_max_cap_sat: default_cashu_peer_suggested_mint_max_cap_sat(),
225 payment_default_block_threshold: 0,
226 chunk_target_bytes: default_cashu_chunk_target_bytes(),
227 }
228 }
229}
230
231fn default_cashu_quote_payment_offer_sat() -> u64 {
232 3
233}
234
235fn default_cashu_quote_ttl_ms() -> u32 {
236 1_500
237}
238
239fn default_cashu_settlement_timeout_ms() -> u64 {
240 5_000
241}
242
243fn default_cashu_mint_failure_block_threshold() -> u64 {
244 2
245}
246
247fn default_cashu_peer_suggested_mint_base_cap_sat() -> u64 {
248 3
249}
250
251fn default_cashu_peer_suggested_mint_success_step_sat() -> u64 {
252 1
253}
254
255fn default_cashu_peer_suggested_mint_receipt_step_sat() -> u64 {
256 2
257}
258
259fn default_cashu_peer_suggested_mint_max_cap_sat() -> u64 {
260 21
261}
262
263fn default_cashu_chunk_target_bytes() -> usize {
264 32 * 1024
265}
266
267fn default_sync_enabled() -> bool {
268 true
269}
270
271fn default_sync_own() -> bool {
272 true
273}
274
275fn default_sync_followed() -> bool {
276 true
277}
278
279fn default_max_concurrent() -> usize {
280 3
281}
282
283fn default_webrtc_timeout_ms() -> u64 {
284 2000
285}
286
287fn default_blossom_timeout_ms() -> u64 {
288 10000
289}
290
291fn default_crawl_depth() -> u32 {
292 2
293}
294
295fn default_max_write_distance() -> u32 {
296 3
297}
298
299fn default_nostr_db_max_size_gb() -> u64 {
300 10
301}
302
303fn default_nostr_spambox_max_size_gb() -> u64 {
304 1
305}
306
307fn default_relays() -> Vec<String> {
308 vec![
309 "wss://relay.damus.io".to_string(),
310 "wss://relay.snort.social".to_string(),
311 "wss://nos.lol".to_string(),
312 "wss://temp.iris.to".to_string(),
313 ]
314}
315
316fn default_bind_address() -> String {
317 "127.0.0.1:8080".to_string()
318}
319
320fn default_enable_auth() -> bool {
321 true
322}
323
324fn default_stun_port() -> u16 {
325 3478 }
327
328fn default_enable_webrtc() -> bool {
329 true
330}
331
332fn default_data_dir() -> String {
333 hashtree_config::get_hashtree_dir()
334 .join("data")
335 .to_string_lossy()
336 .to_string()
337}
338
339fn default_max_size_gb() -> u64 {
340 10
341}
342
343impl Default for ServerConfig {
344 fn default() -> Self {
345 Self {
346 bind_address: default_bind_address(),
347 enable_auth: default_enable_auth(),
348 stun_port: default_stun_port(),
349 enable_webrtc: default_enable_webrtc(),
350 public_writes: default_public_writes(),
351 socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
352 }
353 }
354}
355
356impl Default for StorageConfig {
357 fn default() -> Self {
358 Self {
359 data_dir: default_data_dir(),
360 max_size_gb: default_max_size_gb(),
361 s3: None,
362 }
363 }
364}
365
366impl Default for NostrConfig {
367 fn default() -> Self {
368 Self {
369 relays: default_relays(),
370 allowed_npubs: Vec::new(),
371 socialgraph_root: None,
372 crawl_depth: default_crawl_depth(),
373 max_write_distance: default_max_write_distance(),
374 db_max_size_gb: default_nostr_db_max_size_gb(),
375 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
376 }
377 }
378}
379
380impl Default for BlossomConfig {
381 fn default() -> Self {
382 Self {
383 servers: Vec::new(),
384 read_servers: default_read_servers(),
385 write_servers: default_write_servers(),
386 max_upload_mb: default_max_upload_mb(),
387 }
388 }
389}
390
391impl Default for SyncConfig {
392 fn default() -> Self {
393 Self {
394 enabled: default_sync_enabled(),
395 sync_own: default_sync_own(),
396 sync_followed: default_sync_followed(),
397 max_concurrent: default_max_concurrent(),
398 webrtc_timeout_ms: default_webrtc_timeout_ms(),
399 blossom_timeout_ms: default_blossom_timeout_ms(),
400 }
401 }
402}
403
404impl Config {
405 pub fn load() -> Result<Self> {
407 let config_path = get_config_path();
408
409 if config_path.exists() {
410 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
411 toml::from_str(&content).context("Failed to parse config file")
412 } else {
413 let config = Config::default();
414 config.save()?;
415 Ok(config)
416 }
417 }
418
419 pub fn save(&self) -> Result<()> {
421 let config_path = get_config_path();
422
423 if let Some(parent) = config_path.parent() {
425 fs::create_dir_all(parent)?;
426 }
427
428 let content = toml::to_string_pretty(self)?;
429 fs::write(&config_path, content)?;
430
431 Ok(())
432 }
433}
434
435pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
437
438pub fn ensure_auth_cookie() -> Result<(String, String)> {
440 let cookie_path = get_auth_cookie_path();
441
442 if cookie_path.exists() {
443 read_auth_cookie()
444 } else {
445 generate_auth_cookie()
446 }
447}
448
449pub fn read_auth_cookie() -> Result<(String, String)> {
451 let cookie_path = get_auth_cookie_path();
452 let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
453
454 let parts: Vec<&str> = content.trim().split(':').collect();
455 if parts.len() != 2 {
456 anyhow::bail!("Invalid auth cookie format");
457 }
458
459 Ok((parts[0].to_string(), parts[1].to_string()))
460}
461
462pub fn ensure_keys() -> Result<(Keys, bool)> {
465 let keys_path = get_keys_path();
466
467 if keys_path.exists() {
468 let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
469 let entries = hashtree_config::parse_keys_file(&content);
470 let nsec_str = entries
471 .into_iter()
472 .next()
473 .map(|e| e.secret)
474 .context("Keys file is empty")?;
475 let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
476 let keys = Keys::new(secret_key);
477 Ok((keys, false))
478 } else {
479 let keys = generate_keys()?;
480 Ok((keys, true))
481 }
482}
483
484pub fn read_keys() -> Result<Keys> {
486 let keys_path = get_keys_path();
487 let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
488 let entries = hashtree_config::parse_keys_file(&content);
489 let nsec_str = entries
490 .into_iter()
491 .next()
492 .map(|e| e.secret)
493 .context("Keys file is empty")?;
494 let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
495 Ok(Keys::new(secret_key))
496}
497
498pub fn ensure_keys_string() -> Result<(String, bool)> {
501 let keys_path = get_keys_path();
502
503 if keys_path.exists() {
504 let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
505 let entries = hashtree_config::parse_keys_file(&content);
506 let nsec_str = entries
507 .into_iter()
508 .next()
509 .map(|e| e.secret)
510 .context("Keys file is empty")?;
511 Ok((nsec_str, false))
512 } else {
513 let keys = generate_keys()?;
514 let nsec = keys
515 .secret_key()
516 .to_bech32()
517 .context("Failed to encode nsec")?;
518 Ok((nsec, true))
519 }
520}
521
522pub fn generate_keys() -> Result<Keys> {
524 let keys_path = get_keys_path();
525
526 if let Some(parent) = keys_path.parent() {
528 fs::create_dir_all(parent)?;
529 }
530
531 let keys = Keys::generate();
533 let nsec = keys
534 .secret_key()
535 .to_bech32()
536 .context("Failed to encode nsec")?;
537
538 fs::write(&keys_path, &nsec)?;
540
541 #[cfg(unix)]
543 {
544 use std::os::unix::fs::PermissionsExt;
545 let perms = fs::Permissions::from_mode(0o600);
546 fs::set_permissions(&keys_path, perms)?;
547 }
548
549 Ok(keys)
550}
551
552pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
554 keys.public_key().to_bytes()
555}
556
557pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
559 use nostr::PublicKey;
560 let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
561 Ok(pk.to_bytes())
562}
563
564pub fn generate_auth_cookie() -> Result<(String, String)> {
566 use rand::Rng;
567
568 let cookie_path = get_auth_cookie_path();
569
570 if let Some(parent) = cookie_path.parent() {
572 fs::create_dir_all(parent)?;
573 }
574
575 let mut rng = rand::thread_rng();
577 let username = format!("htree_{}", rng.gen::<u32>());
578 let password: String = (0..32)
579 .map(|_| {
580 let idx = rng.gen_range(0..62);
581 match idx {
582 0..=25 => (b'a' + idx) as char,
583 26..=51 => (b'A' + (idx - 26)) as char,
584 _ => (b'0' + (idx - 52)) as char,
585 }
586 })
587 .collect();
588
589 let content = format!("{}:{}", username, password);
591 fs::write(&cookie_path, content)?;
592
593 #[cfg(unix)]
595 {
596 use std::os::unix::fs::PermissionsExt;
597 let perms = fs::Permissions::from_mode(0o600);
598 fs::set_permissions(&cookie_path, perms)?;
599 }
600
601 Ok((username, password))
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use tempfile::TempDir;
608
609 #[test]
610 fn test_config_default() {
611 let config = Config::default();
612 assert_eq!(config.server.bind_address, "127.0.0.1:8080");
613 assert!(config.server.enable_auth);
614 assert_eq!(config.storage.max_size_gb, 10);
615 assert_eq!(config.nostr.crawl_depth, 2);
616 assert_eq!(config.nostr.max_write_distance, 3);
617 assert_eq!(config.nostr.db_max_size_gb, 10);
618 assert_eq!(config.nostr.spambox_max_size_gb, 1);
619 assert!(config.nostr.socialgraph_root.is_none());
620 assert!(!config.server.socialgraph_snapshot_public);
621 assert!(config.cashu.accepted_mints.is_empty());
622 assert!(config.cashu.default_mint.is_none());
623 assert_eq!(config.cashu.quote_payment_offer_sat, 3);
624 assert_eq!(config.cashu.quote_ttl_ms, 1_500);
625 assert_eq!(config.cashu.settlement_timeout_ms, 5_000);
626 assert_eq!(config.cashu.mint_failure_block_threshold, 2);
627 assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 3);
628 assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 1);
629 assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 2);
630 assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 21);
631 assert_eq!(config.cashu.payment_default_block_threshold, 0);
632 assert_eq!(config.cashu.chunk_target_bytes, 32 * 1024);
633 }
634
635 #[test]
636 fn test_nostr_config_deserialize_with_defaults() {
637 let toml_str = r#"
638[nostr]
639relays = ["wss://relay.damus.io"]
640"#;
641 let config: Config = toml::from_str(toml_str).unwrap();
642 assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
643 assert_eq!(config.nostr.crawl_depth, 2);
644 assert_eq!(config.nostr.max_write_distance, 3);
645 assert_eq!(config.nostr.db_max_size_gb, 10);
646 assert_eq!(config.nostr.spambox_max_size_gb, 1);
647 assert!(config.nostr.socialgraph_root.is_none());
648 }
649
650 #[test]
651 fn test_nostr_config_deserialize_with_socialgraph() {
652 let toml_str = r#"
653[nostr]
654relays = ["wss://relay.damus.io"]
655socialgraph_root = "npub1test"
656crawl_depth = 3
657max_write_distance = 5
658"#;
659 let config: Config = toml::from_str(toml_str).unwrap();
660 assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
661 assert_eq!(config.nostr.crawl_depth, 3);
662 assert_eq!(config.nostr.max_write_distance, 5);
663 assert_eq!(config.nostr.db_max_size_gb, 10);
664 assert_eq!(config.nostr.spambox_max_size_gb, 1);
665 }
666
667 #[test]
668 fn test_cashu_config_deserialize_with_accepted_mints() {
669 let toml_str = r#"
670[cashu]
671accepted_mints = ["https://mint1.example", "http://127.0.0.1:3338"]
672default_mint = "https://mint1.example"
673quote_payment_offer_sat = 5
674quote_ttl_ms = 2500
675settlement_timeout_ms = 7000
676mint_failure_block_threshold = 3
677peer_suggested_mint_base_cap_sat = 4
678peer_suggested_mint_success_step_sat = 2
679peer_suggested_mint_receipt_step_sat = 3
680peer_suggested_mint_max_cap_sat = 34
681payment_default_block_threshold = 2
682chunk_target_bytes = 65536
683"#;
684 let config: Config = toml::from_str(toml_str).unwrap();
685 assert_eq!(
686 config.cashu.accepted_mints,
687 vec![
688 "https://mint1.example".to_string(),
689 "http://127.0.0.1:3338".to_string()
690 ]
691 );
692 assert_eq!(
693 config.cashu.default_mint,
694 Some("https://mint1.example".to_string())
695 );
696 assert_eq!(config.cashu.quote_payment_offer_sat, 5);
697 assert_eq!(config.cashu.quote_ttl_ms, 2500);
698 assert_eq!(config.cashu.settlement_timeout_ms, 7_000);
699 assert_eq!(config.cashu.mint_failure_block_threshold, 3);
700 assert_eq!(config.cashu.peer_suggested_mint_base_cap_sat, 4);
701 assert_eq!(config.cashu.peer_suggested_mint_success_step_sat, 2);
702 assert_eq!(config.cashu.peer_suggested_mint_receipt_step_sat, 3);
703 assert_eq!(config.cashu.peer_suggested_mint_max_cap_sat, 34);
704 assert_eq!(config.cashu.payment_default_block_threshold, 2);
705 assert_eq!(config.cashu.chunk_target_bytes, 65_536);
706 }
707
708 #[test]
709 fn test_auth_cookie_generation() -> Result<()> {
710 let temp_dir = TempDir::new()?;
711
712 std::env::set_var("HOME", temp_dir.path());
714
715 let (username, password) = generate_auth_cookie()?;
716
717 assert!(username.starts_with("htree_"));
718 assert_eq!(password.len(), 32);
719
720 let cookie_path = get_auth_cookie_path();
722 assert!(cookie_path.exists());
723
724 let (u2, p2) = read_auth_cookie()?;
726 assert_eq!(username, u2);
727 assert_eq!(password, p2);
728
729 Ok(())
730 }
731}