hashtree_config/
lib.rs

1//! Shared configuration for hashtree tools
2//!
3//! Reads from ~/.hashtree/config.toml
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10/// Default read-only file servers
11pub const DEFAULT_READ_SERVERS: &[&str] = &[
12    "https://cdn.iris.to",
13    "https://hashtree.iris.to",
14];
15
16/// Default write-enabled file servers
17pub const DEFAULT_WRITE_SERVERS: &[&str] = &[
18    "https://upload.iris.to",
19];
20
21/// Default nostr relays
22pub const DEFAULT_RELAYS: &[&str] = &[
23    "wss://temp.iris.to",
24    "wss://relay.damus.io",
25    "wss://nos.lol",
26    "wss://relay.primal.net",
27];
28
29/// Top-level config structure
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct Config {
32    #[serde(default)]
33    pub server: ServerConfig,
34    #[serde(default)]
35    pub storage: StorageConfig,
36    #[serde(default)]
37    pub nostr: NostrConfig,
38    #[serde(default)]
39    pub blossom: BlossomConfig,
40    #[serde(default)]
41    pub sync: SyncConfig,
42}
43
44/// Server configuration
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ServerConfig {
47    #[serde(default = "default_bind_address")]
48    pub bind_address: String,
49    #[serde(default = "default_true")]
50    pub enable_auth: bool,
51    #[serde(default)]
52    pub public_writes: bool,
53    #[serde(default)]
54    pub enable_webrtc: bool,
55    #[serde(default)]
56    pub stun_port: u16,
57}
58
59impl Default for ServerConfig {
60    fn default() -> Self {
61        Self {
62            bind_address: default_bind_address(),
63            enable_auth: true,
64            public_writes: false,
65            enable_webrtc: false,
66            stun_port: 0,
67        }
68    }
69}
70
71fn default_bind_address() -> String {
72    "127.0.0.1:8080".to_string()
73}
74
75fn default_true() -> bool {
76    true
77}
78
79/// Storage configuration
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct StorageConfig {
82    #[serde(default = "default_data_dir")]
83    pub data_dir: String,
84    #[serde(default = "default_max_size_gb")]
85    pub max_size_gb: u64,
86    #[serde(default)]
87    pub s3: Option<S3Config>,
88}
89
90impl Default for StorageConfig {
91    fn default() -> Self {
92        Self {
93            data_dir: default_data_dir(),
94            max_size_gb: default_max_size_gb(),
95            s3: None,
96        }
97    }
98}
99
100fn default_data_dir() -> String {
101    get_hashtree_dir()
102        .join("data")
103        .to_string_lossy()
104        .to_string()
105}
106
107fn default_max_size_gb() -> u64 {
108    10
109}
110
111/// S3-compatible storage configuration
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct S3Config {
114    pub endpoint: String,
115    pub bucket: String,
116    pub region: String,
117    #[serde(default)]
118    pub prefix: Option<String>,
119}
120
121/// Nostr relay configuration
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct NostrConfig {
124    #[serde(default = "default_relays")]
125    pub relays: Vec<String>,
126    #[serde(default)]
127    pub allowed_npubs: Vec<String>,
128}
129
130impl Default for NostrConfig {
131    fn default() -> Self {
132        Self {
133            relays: default_relays(),
134            allowed_npubs: vec![],
135        }
136    }
137}
138
139fn default_relays() -> Vec<String> {
140    DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
141}
142
143/// File server (blossom) configuration
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct BlossomConfig {
146    /// Legacy servers field (both read and write)
147    #[serde(default)]
148    pub servers: Vec<String>,
149    /// Read-only file servers
150    #[serde(default = "default_read_servers")]
151    pub read_servers: Vec<String>,
152    /// Write-enabled file servers
153    #[serde(default = "default_write_servers")]
154    pub write_servers: Vec<String>,
155    /// Max upload size in MB
156    #[serde(default = "default_max_upload_mb")]
157    pub max_upload_mb: u64,
158    /// Force upload all blobs, skipping "server already has" check
159    #[serde(default)]
160    pub force_upload: bool,
161}
162
163impl Default for BlossomConfig {
164    fn default() -> Self {
165        Self {
166            servers: vec![],
167            read_servers: default_read_servers(),
168            write_servers: default_write_servers(),
169            max_upload_mb: default_max_upload_mb(),
170            force_upload: false,
171        }
172    }
173}
174
175fn default_read_servers() -> Vec<String> {
176    DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect()
177}
178
179fn default_write_servers() -> Vec<String> {
180    DEFAULT_WRITE_SERVERS.iter().map(|s| s.to_string()).collect()
181}
182
183fn default_max_upload_mb() -> u64 {
184    100
185}
186
187impl BlossomConfig {
188    /// Get all read servers (legacy + read_servers)
189    pub fn all_read_servers(&self) -> Vec<String> {
190        let mut servers = self.servers.clone();
191        servers.extend(self.read_servers.clone());
192        servers.sort();
193        servers.dedup();
194        servers
195    }
196
197    /// Get all write servers (legacy + write_servers)
198    pub fn all_write_servers(&self) -> Vec<String> {
199        let mut servers = self.servers.clone();
200        servers.extend(self.write_servers.clone());
201        servers.sort();
202        servers.dedup();
203        servers
204    }
205}
206
207/// Background sync configuration
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct SyncConfig {
210    #[serde(default)]
211    pub enabled: bool,
212    #[serde(default = "default_true")]
213    pub sync_own: bool,
214    #[serde(default)]
215    pub sync_followed: bool,
216    #[serde(default = "default_max_concurrent")]
217    pub max_concurrent: usize,
218    #[serde(default = "default_webrtc_timeout_ms")]
219    pub webrtc_timeout_ms: u64,
220    #[serde(default = "default_blossom_timeout_ms")]
221    pub blossom_timeout_ms: u64,
222}
223
224impl Default for SyncConfig {
225    fn default() -> Self {
226        Self {
227            enabled: false,
228            sync_own: true,
229            sync_followed: false,
230            max_concurrent: default_max_concurrent(),
231            webrtc_timeout_ms: default_webrtc_timeout_ms(),
232            blossom_timeout_ms: default_blossom_timeout_ms(),
233        }
234    }
235}
236
237fn default_max_concurrent() -> usize {
238    4
239}
240
241fn default_webrtc_timeout_ms() -> u64 {
242    5000
243}
244
245fn default_blossom_timeout_ms() -> u64 {
246    10000
247}
248
249impl Config {
250    /// Load config from file, or create default if doesn't exist
251    pub fn load() -> Result<Self> {
252        let config_path = get_config_path();
253
254        if config_path.exists() {
255            let content = fs::read_to_string(&config_path)
256                .context("Failed to read config file")?;
257            toml::from_str(&content).context("Failed to parse config file")
258        } else {
259            let config = Config::default();
260            config.save()?;
261            Ok(config)
262        }
263    }
264
265    /// Load config, returning default on any error (no panic)
266    pub fn load_or_default() -> Self {
267        Self::load().unwrap_or_default()
268    }
269
270    /// Save config to file
271    pub fn save(&self) -> Result<()> {
272        let config_path = get_config_path();
273
274        if let Some(parent) = config_path.parent() {
275            fs::create_dir_all(parent)?;
276        }
277
278        let content = toml::to_string_pretty(self)?;
279        fs::write(&config_path, content)?;
280
281        Ok(())
282    }
283}
284
285/// Get the hashtree directory (~/.hashtree)
286pub fn get_hashtree_dir() -> PathBuf {
287    if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
288        return PathBuf::from(dir);
289    }
290    dirs::home_dir()
291        .unwrap_or_else(|| PathBuf::from("."))
292        .join(".hashtree")
293}
294
295/// Get the config file path (~/.hashtree/config.toml)
296pub fn get_config_path() -> PathBuf {
297    get_hashtree_dir().join("config.toml")
298}
299
300/// Get the keys file path (~/.hashtree/keys)
301pub fn get_keys_path() -> PathBuf {
302    get_hashtree_dir().join("keys")
303}
304
305/// Get the auth cookie path (~/.hashtree/auth.cookie)
306pub fn get_auth_cookie_path() -> PathBuf {
307    get_hashtree_dir().join("auth.cookie")
308}
309
310/// Get the data directory from config (defaults to ~/.hashtree/data)
311/// Can be overridden with HTREE_DATA_DIR environment variable
312pub fn get_data_dir() -> PathBuf {
313    if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
314        return PathBuf::from(dir);
315    }
316    let config = Config::load_or_default();
317    PathBuf::from(&config.storage.data_dir)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_default_config() {
326        let config = Config::default();
327        assert!(!config.blossom.read_servers.is_empty());
328        assert!(!config.blossom.write_servers.is_empty());
329        assert!(!config.nostr.relays.is_empty());
330    }
331
332    #[test]
333    fn test_parse_empty_config() {
334        let config: Config = toml::from_str("").unwrap();
335        assert!(!config.blossom.read_servers.is_empty());
336    }
337
338    #[test]
339    fn test_parse_partial_config() {
340        let toml = r#"
341[blossom]
342write_servers = ["https://custom.server"]
343"#;
344        let config: Config = toml::from_str(toml).unwrap();
345        assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
346        assert!(!config.blossom.read_servers.is_empty());
347    }
348
349    #[test]
350    fn test_all_servers() {
351        let mut config = BlossomConfig::default();
352        config.servers = vec!["https://legacy.server".to_string()];
353
354        let read = config.all_read_servers();
355        assert!(read.contains(&"https://legacy.server".to_string()));
356        assert!(read.contains(&"https://cdn.iris.to".to_string()));
357
358        let write = config.all_write_servers();
359        assert!(write.contains(&"https://legacy.server".to_string()));
360        assert!(write.contains(&"https://upload.iris.to".to_string()));
361    }
362}