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, 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}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ServerConfig {
23 #[serde(default = "default_bind_address")]
24 pub bind_address: String,
25 #[serde(default = "default_enable_auth")]
26 pub enable_auth: bool,
27 #[serde(default = "default_stun_port")]
29 pub stun_port: u16,
30 #[serde(default = "default_enable_webrtc")]
32 pub enable_webrtc: bool,
33 #[serde(default = "default_public_writes")]
36 pub public_writes: bool,
37}
38
39fn default_public_writes() -> bool {
40 true
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct StorageConfig {
45 #[serde(default = "default_data_dir")]
46 pub data_dir: String,
47 #[serde(default = "default_max_size_gb")]
48 pub max_size_gb: u64,
49 #[serde(default)]
51 pub s3: Option<S3Config>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct S3Config {
57 pub endpoint: String,
59 pub bucket: String,
61 #[serde(default)]
63 pub prefix: Option<String>,
64 #[serde(default = "default_s3_region")]
66 pub region: String,
67 #[serde(default)]
69 pub access_key: Option<String>,
70 #[serde(default)]
72 pub secret_key: Option<String>,
73 #[serde(default)]
75 pub public_url: Option<String>,
76}
77
78fn default_s3_region() -> String {
79 "auto".to_string()
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct NostrConfig {
84 #[serde(default = "default_relays")]
85 pub relays: Vec<String>,
86 #[serde(default)]
88 pub allowed_npubs: Vec<String>,
89 #[serde(default)]
91 pub socialgraph_root: Option<String>,
92 #[serde(default = "default_crawl_depth")]
94 pub crawl_depth: u32,
95 #[serde(default = "default_max_write_distance")]
97 pub max_write_distance: u32,
98 #[serde(default = "default_nostr_db_max_size_gb")]
100 pub db_max_size_gb: u64,
101 #[serde(default = "default_nostr_spambox_max_size_gb")]
104 pub spambox_max_size_gb: u64,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct BlossomConfig {
109 #[serde(default)]
111 pub servers: Vec<String>,
112 #[serde(default = "default_read_servers")]
114 pub read_servers: Vec<String>,
115 #[serde(default = "default_write_servers")]
117 pub write_servers: Vec<String>,
118 #[serde(default = "default_max_upload_mb")]
120 pub max_upload_mb: u64,
121}
122
123fn default_read_servers() -> Vec<String> {
125 vec!["https://cdn.iris.to".to_string(), "https://hashtree.iris.to".to_string()]
126}
127
128fn default_write_servers() -> Vec<String> {
129 vec!["https://upload.iris.to".to_string()]
130}
131
132fn default_max_upload_mb() -> u64 {
133 5
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SyncConfig {
138 #[serde(default = "default_sync_enabled")]
140 pub enabled: bool,
141 #[serde(default = "default_sync_own")]
143 pub sync_own: bool,
144 #[serde(default = "default_sync_followed")]
146 pub sync_followed: bool,
147 #[serde(default = "default_max_concurrent")]
149 pub max_concurrent: usize,
150 #[serde(default = "default_webrtc_timeout_ms")]
152 pub webrtc_timeout_ms: u64,
153 #[serde(default = "default_blossom_timeout_ms")]
155 pub blossom_timeout_ms: u64,
156}
157
158
159fn default_sync_enabled() -> bool {
160 true
161}
162
163fn default_sync_own() -> bool {
164 true
165}
166
167fn default_sync_followed() -> bool {
168 true
169}
170
171fn default_max_concurrent() -> usize {
172 3
173}
174
175fn default_webrtc_timeout_ms() -> u64 {
176 2000
177}
178
179fn default_blossom_timeout_ms() -> u64 {
180 10000
181}
182
183fn default_crawl_depth() -> u32 {
184 2
185}
186
187fn default_max_write_distance() -> u32 {
188 3
189}
190
191fn default_nostr_db_max_size_gb() -> u64 {
192 10
193}
194
195fn default_nostr_spambox_max_size_gb() -> u64 {
196 1
197}
198
199fn default_relays() -> Vec<String> {
200 vec![
201 "wss://relay.damus.io".to_string(),
202 "wss://relay.snort.social".to_string(),
203 "wss://nos.lol".to_string(),
204 "wss://temp.iris.to".to_string(),
205 ]
206}
207
208fn default_bind_address() -> String {
209 "127.0.0.1:8080".to_string()
210}
211
212fn default_enable_auth() -> bool {
213 true
214}
215
216fn default_stun_port() -> u16 {
217 3478 }
219
220fn default_enable_webrtc() -> bool {
221 true
222}
223
224fn default_data_dir() -> String {
225 hashtree_config::get_hashtree_dir()
226 .join("data")
227 .to_string_lossy()
228 .to_string()
229}
230
231fn default_max_size_gb() -> u64 {
232 10
233}
234
235impl Default for ServerConfig {
236 fn default() -> Self {
237 Self {
238 bind_address: default_bind_address(),
239 enable_auth: default_enable_auth(),
240 stun_port: default_stun_port(),
241 enable_webrtc: default_enable_webrtc(),
242 public_writes: default_public_writes(),
243 }
244 }
245}
246
247impl Default for StorageConfig {
248 fn default() -> Self {
249 Self {
250 data_dir: default_data_dir(),
251 max_size_gb: default_max_size_gb(),
252 s3: None,
253 }
254 }
255}
256
257impl Default for NostrConfig {
258 fn default() -> Self {
259 Self {
260 relays: default_relays(),
261 allowed_npubs: Vec::new(),
262 socialgraph_root: None,
263 crawl_depth: default_crawl_depth(),
264 max_write_distance: default_max_write_distance(),
265 db_max_size_gb: default_nostr_db_max_size_gb(),
266 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
267 }
268 }
269}
270
271impl Default for BlossomConfig {
272 fn default() -> Self {
273 Self {
274 servers: Vec::new(),
275 read_servers: default_read_servers(),
276 write_servers: default_write_servers(),
277 max_upload_mb: default_max_upload_mb(),
278 }
279 }
280}
281
282impl Default for SyncConfig {
283 fn default() -> Self {
284 Self {
285 enabled: default_sync_enabled(),
286 sync_own: default_sync_own(),
287 sync_followed: default_sync_followed(),
288 max_concurrent: default_max_concurrent(),
289 webrtc_timeout_ms: default_webrtc_timeout_ms(),
290 blossom_timeout_ms: default_blossom_timeout_ms(),
291 }
292 }
293}
294
295impl Default for Config {
296 fn default() -> Self {
297 Self {
298 server: ServerConfig::default(),
299 storage: StorageConfig::default(),
300 nostr: NostrConfig::default(),
301 blossom: BlossomConfig::default(),
302 sync: SyncConfig::default(),
303 }
304 }
305}
306
307impl Config {
308 pub fn load() -> Result<Self> {
310 let config_path = get_config_path();
311
312 if config_path.exists() {
313 let content = fs::read_to_string(&config_path)
314 .context("Failed to read config file")?;
315 toml::from_str(&content).context("Failed to parse config file")
316 } else {
317 let config = Config::default();
318 config.save()?;
319 Ok(config)
320 }
321 }
322
323 pub fn save(&self) -> Result<()> {
325 let config_path = get_config_path();
326
327 if let Some(parent) = config_path.parent() {
329 fs::create_dir_all(parent)?;
330 }
331
332 let content = toml::to_string_pretty(self)?;
333 fs::write(&config_path, content)?;
334
335 Ok(())
336 }
337}
338
339pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
341
342pub fn ensure_auth_cookie() -> Result<(String, String)> {
344 let cookie_path = get_auth_cookie_path();
345
346 if cookie_path.exists() {
347 read_auth_cookie()
348 } else {
349 generate_auth_cookie()
350 }
351}
352
353pub fn read_auth_cookie() -> Result<(String, String)> {
355 let cookie_path = get_auth_cookie_path();
356 let content = fs::read_to_string(&cookie_path)
357 .context("Failed to read auth cookie")?;
358
359 let parts: Vec<&str> = content.trim().split(':').collect();
360 if parts.len() != 2 {
361 anyhow::bail!("Invalid auth cookie format");
362 }
363
364 Ok((parts[0].to_string(), parts[1].to_string()))
365}
366
367pub fn ensure_keys() -> Result<(Keys, bool)> {
370 let keys_path = get_keys_path();
371
372 if keys_path.exists() {
373 let content = fs::read_to_string(&keys_path)
374 .context("Failed to read keys file")?;
375 let entries = hashtree_config::parse_keys_file(&content);
376 let nsec_str = entries.into_iter().next()
377 .map(|e| e.secret)
378 .context("Keys file is empty")?;
379 let secret_key = SecretKey::from_bech32(&nsec_str)
380 .context("Invalid nsec format")?;
381 let keys = Keys::new(secret_key);
382 Ok((keys, false))
383 } else {
384 let keys = generate_keys()?;
385 Ok((keys, true))
386 }
387}
388
389pub fn read_keys() -> Result<Keys> {
391 let keys_path = get_keys_path();
392 let content = fs::read_to_string(&keys_path)
393 .context("Failed to read keys file")?;
394 let entries = hashtree_config::parse_keys_file(&content);
395 let nsec_str = entries.into_iter().next()
396 .map(|e| e.secret)
397 .context("Keys file is empty")?;
398 let secret_key = SecretKey::from_bech32(&nsec_str)
399 .context("Invalid nsec format")?;
400 Ok(Keys::new(secret_key))
401}
402
403pub fn ensure_keys_string() -> Result<(String, bool)> {
406 let keys_path = get_keys_path();
407
408 if keys_path.exists() {
409 let content = fs::read_to_string(&keys_path)
410 .context("Failed to read keys file")?;
411 let entries = hashtree_config::parse_keys_file(&content);
412 let nsec_str = entries.into_iter().next()
413 .map(|e| e.secret)
414 .context("Keys file is empty")?;
415 Ok((nsec_str, false))
416 } else {
417 let keys = generate_keys()?;
418 let nsec = keys.secret_key().to_bech32()
419 .context("Failed to encode nsec")?;
420 Ok((nsec, true))
421 }
422}
423
424pub fn generate_keys() -> Result<Keys> {
426 let keys_path = get_keys_path();
427
428 if let Some(parent) = keys_path.parent() {
430 fs::create_dir_all(parent)?;
431 }
432
433 let keys = Keys::generate();
435 let nsec = keys.secret_key().to_bech32()
436 .context("Failed to encode nsec")?;
437
438 fs::write(&keys_path, &nsec)?;
440
441 #[cfg(unix)]
443 {
444 use std::os::unix::fs::PermissionsExt;
445 let perms = fs::Permissions::from_mode(0o600);
446 fs::set_permissions(&keys_path, perms)?;
447 }
448
449 Ok(keys)
450}
451
452pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
454 keys.public_key().to_bytes()
455}
456
457pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
459 use nostr::PublicKey;
460 let pk = PublicKey::from_bech32(npub)
461 .context("Invalid npub format")?;
462 Ok(pk.to_bytes())
463}
464
465pub fn generate_auth_cookie() -> Result<(String, String)> {
467 use rand::Rng;
468
469 let cookie_path = get_auth_cookie_path();
470
471 if let Some(parent) = cookie_path.parent() {
473 fs::create_dir_all(parent)?;
474 }
475
476 let mut rng = rand::thread_rng();
478 let username = format!("htree_{}", rng.gen::<u32>());
479 let password: String = (0..32)
480 .map(|_| {
481 let idx = rng.gen_range(0..62);
482 match idx {
483 0..=25 => (b'a' + idx) as char,
484 26..=51 => (b'A' + (idx - 26)) as char,
485 _ => (b'0' + (idx - 52)) as char,
486 }
487 })
488 .collect();
489
490 let content = format!("{}:{}", username, password);
492 fs::write(&cookie_path, content)?;
493
494 #[cfg(unix)]
496 {
497 use std::os::unix::fs::PermissionsExt;
498 let perms = fs::Permissions::from_mode(0o600);
499 fs::set_permissions(&cookie_path, perms)?;
500 }
501
502 Ok((username, password))
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use tempfile::TempDir;
509
510 #[test]
511 fn test_config_default() {
512 let config = Config::default();
513 assert_eq!(config.server.bind_address, "127.0.0.1:8080");
514 assert_eq!(config.server.enable_auth, true);
515 assert_eq!(config.storage.max_size_gb, 10);
516 assert_eq!(config.nostr.crawl_depth, 2);
517 assert_eq!(config.nostr.max_write_distance, 3);
518 assert_eq!(config.nostr.db_max_size_gb, 10);
519 assert_eq!(config.nostr.spambox_max_size_gb, 1);
520 assert!(config.nostr.socialgraph_root.is_none());
521 }
522
523 #[test]
524 fn test_nostr_config_deserialize_with_defaults() {
525 let toml_str = r#"
526[nostr]
527relays = ["wss://relay.damus.io"]
528"#;
529 let config: Config = toml::from_str(toml_str).unwrap();
530 assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
531 assert_eq!(config.nostr.crawl_depth, 2);
532 assert_eq!(config.nostr.max_write_distance, 3);
533 assert_eq!(config.nostr.db_max_size_gb, 10);
534 assert_eq!(config.nostr.spambox_max_size_gb, 1);
535 assert!(config.nostr.socialgraph_root.is_none());
536 }
537
538 #[test]
539 fn test_nostr_config_deserialize_with_socialgraph() {
540 let toml_str = r#"
541[nostr]
542relays = ["wss://relay.damus.io"]
543socialgraph_root = "npub1test"
544crawl_depth = 3
545max_write_distance = 5
546"#;
547 let config: Config = toml::from_str(toml_str).unwrap();
548 assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
549 assert_eq!(config.nostr.crawl_depth, 3);
550 assert_eq!(config.nostr.max_write_distance, 5);
551 assert_eq!(config.nostr.db_max_size_gb, 10);
552 assert_eq!(config.nostr.spambox_max_size_gb, 1);
553 }
554
555 #[test]
556 fn test_auth_cookie_generation() -> Result<()> {
557 let temp_dir = TempDir::new()?;
558
559 std::env::set_var("HOME", temp_dir.path());
561
562 let (username, password) = generate_auth_cookie()?;
563
564 assert!(username.starts_with("htree_"));
565 assert_eq!(password.len(), 32);
566
567 let cookie_path = get_auth_cookie_path();
569 assert!(cookie_path.exists());
570
571 let (u2, p2) = read_auth_cookie()?;
573 assert_eq!(username, u2);
574 assert_eq!(password, p2);
575
576 Ok(())
577 }
578}