Skip to main content

hashtree_embedded/
lib.rs

1use anyhow::{Context, Result};
2use hashtree_cli::daemon::{EmbeddedDaemonInfo, EmbeddedDaemonOptions};
3use hashtree_cli::Config;
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub struct HostDaemonOptions {
9    pub state_root: PathBuf,
10    pub bind_address: String,
11}
12
13impl HostDaemonOptions {
14    pub fn new(state_root: impl Into<PathBuf>) -> Self {
15        Self {
16            state_root: state_root.into(),
17            bind_address: "127.0.0.1:0".to_string(),
18        }
19    }
20}
21
22#[derive(Debug, Clone)]
23pub struct HostDaemonStatus {
24    pub base_url: String,
25    pub self_npub: String,
26    pub config_dir: PathBuf,
27    pub data_dir: PathBuf,
28}
29
30pub struct HostDaemonRuntime {
31    runtime: tokio::runtime::Runtime,
32    info: EmbeddedDaemonInfo,
33    bind_address: String,
34    config_dir: PathBuf,
35    data_dir: PathBuf,
36}
37
38#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40#[serde(rename_all = "camelCase")]
41struct BrowserSettings {
42    nostr_relays: Option<Vec<String>>,
43    blossom_read_servers: Option<Vec<String>>,
44    blossom_write_servers: Option<Vec<String>>,
45    enable_webrtc: Option<bool>,
46    enable_multicast: Option<bool>,
47    max_multicast_peers: Option<usize>,
48    enable_fips: Option<bool>,
49    enable_fips_udp: Option<bool>,
50    enable_fips_webrtc: Option<bool>,
51    fetch_from_fips_peers: Option<bool>,
52    social_graph_crawl_depth: Option<u32>,
53    sync_enabled: Option<bool>,
54    sync_own: Option<bool>,
55    sync_followed: Option<bool>,
56    sync_max_concurrent: Option<usize>,
57    public_writes: Option<bool>,
58    allowed_npubs: Option<Vec<String>>,
59    socialgraph_root: Option<String>,
60}
61
62impl HostDaemonRuntime {
63    pub fn start(options: HostDaemonOptions) -> Result<Self> {
64        let runtime = tokio::runtime::Builder::new_multi_thread()
65            .enable_all()
66            .build()
67            .context("build embedded host runtime")?;
68
69        let config_dir = options.state_root.join("config");
70        let data_dir = options.state_root.join("data");
71        std::fs::create_dir_all(&config_dir).context("create embedded config dir")?;
72        std::fs::create_dir_all(&data_dir).context("create embedded data dir")?;
73
74        let config = browser_config(&data_dir, &config_dir);
75        let info = runtime
76            .block_on(hashtree_cli::daemon::start_embedded(
77                EmbeddedDaemonOptions {
78                    config,
79                    data_dir: data_dir.clone(),
80                    config_dir: Some(config_dir.clone()),
81                    bind_address: options.bind_address,
82                    relays: None,
83                    extra_routes: None,
84                    cors: None,
85                },
86            ))
87            .context("start embedded hashtree daemon")?;
88
89        let bind_address = info.addr.clone();
90
91        Ok(Self {
92            runtime,
93            info,
94            bind_address,
95            config_dir,
96            data_dir,
97        })
98    }
99
100    pub fn status(&self) -> HostDaemonStatus {
101        HostDaemonStatus {
102            base_url: format!("http://{}", self.info.addr),
103            self_npub: self.info.npub.clone(),
104            config_dir: self.config_dir.clone(),
105            data_dir: self.data_dir.clone(),
106        }
107    }
108
109    pub fn base_url(&self) -> String {
110        self.status().base_url
111    }
112
113    pub fn self_npub(&self) -> &str {
114        &self.info.npub
115    }
116
117    pub fn reload(&mut self) -> Result<HostDaemonStatus> {
118        let controller = self.info.daemon_controller.clone();
119        self.runtime.block_on(async move {
120            controller.shutdown().await;
121        });
122
123        let config = browser_config(&self.data_dir, &self.config_dir);
124        self.info = self
125            .runtime
126            .block_on(hashtree_cli::daemon::start_embedded(
127                EmbeddedDaemonOptions {
128                    config,
129                    data_dir: self.data_dir.clone(),
130                    config_dir: Some(self.config_dir.clone()),
131                    bind_address: self.bind_address.clone(),
132                    relays: None,
133                    extra_routes: None,
134                    cors: None,
135                },
136            ))
137            .context("reload embedded hashtree daemon")?;
138        self.bind_address = self.info.addr.clone();
139        Ok(self.status())
140    }
141
142    pub fn shutdown(&mut self) {
143        let controller = self.info.daemon_controller.clone();
144        self.runtime.block_on(async move {
145            controller.shutdown().await;
146        });
147    }
148}
149
150impl Drop for HostDaemonRuntime {
151    fn drop(&mut self) {
152        self.shutdown();
153    }
154}
155
156fn browser_config(data_dir: &Path, config_dir: &Path) -> Config {
157    let mut config = Config::default();
158    let settings = load_browser_settings(config_dir).unwrap_or_else(default_browser_settings);
159
160    if let Some(nostr_relays) = settings.nostr_relays {
161        config.nostr.relays = normalize_server_list(nostr_relays);
162        config.nostr.enabled = !config.nostr.relays.is_empty();
163    }
164
165    if let Some(blossom_read_servers) = settings.blossom_read_servers {
166        config.blossom.read_servers = normalize_server_list(blossom_read_servers);
167        config.blossom.servers.clear();
168    }
169    if let Some(blossom_write_servers) = settings.blossom_write_servers {
170        config.blossom.write_servers = normalize_server_list(blossom_write_servers);
171        config.blossom.servers.clear();
172    }
173    config.blossom.enabled =
174        !config.blossom.read_servers.is_empty() || !config.blossom.write_servers.is_empty();
175
176    config.server.enable_webrtc = settings.enable_webrtc.unwrap_or(false);
177    if !config.server.enable_webrtc {
178        config.server.stun_port = 0;
179    }
180
181    config.server.enable_multicast = settings.enable_multicast.unwrap_or(false);
182    if let Some(max_multicast_peers) = settings.max_multicast_peers {
183        config.server.max_multicast_peers = max_multicast_peers;
184    }
185
186    if let Some(enable_fips) = settings.enable_fips {
187        config.server.enable_fips = enable_fips;
188    }
189    if let Some(enable_fips_udp) = settings.enable_fips_udp {
190        config.server.enable_fips_udp = enable_fips_udp;
191    }
192    if let Some(enable_fips_webrtc) = settings.enable_fips_webrtc {
193        config.server.enable_fips_webrtc = enable_fips_webrtc;
194    }
195    if let Some(fetch_from_fips_peers) = settings.fetch_from_fips_peers {
196        config.server.fetch_from_fips_peers = fetch_from_fips_peers;
197    }
198    if let Some(social_graph_crawl_depth) = settings.social_graph_crawl_depth {
199        config.nostr.social_graph_crawl_depth = social_graph_crawl_depth;
200    }
201
202    config.sync.enabled = settings.sync_enabled.unwrap_or(false);
203    if let Some(sync_own) = settings.sync_own {
204        config.sync.sync_own = sync_own;
205    }
206    if let Some(sync_followed) = settings.sync_followed {
207        config.sync.sync_followed = sync_followed;
208    }
209    if let Some(sync_max_concurrent) = settings.sync_max_concurrent {
210        config.sync.max_concurrent = sync_max_concurrent.max(1);
211    }
212
213    config.server.public_writes = settings.public_writes.unwrap_or(false);
214    if let Some(allowed_npubs) = settings.allowed_npubs {
215        config.nostr.allowed_npubs = normalize_server_list(allowed_npubs);
216    }
217    if let Some(socialgraph_root) = settings.socialgraph_root {
218        let socialgraph_root = socialgraph_root.trim().to_string();
219        config.nostr.socialgraph_root = if socialgraph_root.is_empty() {
220            None
221        } else {
222            Some(socialgraph_root)
223        };
224    }
225    config.storage.data_dir = data_dir.to_string_lossy().to_string();
226    config.server.enable_auth = false;
227    config.server.enable_bluetooth = false;
228    config.server.max_bluetooth_peers = 0;
229    config
230}
231
232fn load_browser_settings(config_dir: &Path) -> Option<BrowserSettings> {
233    let settings_path = config_dir.join("browser_settings.json");
234    let raw = std::fs::read_to_string(settings_path).ok()?;
235    serde_json::from_str(&raw).ok()
236}
237
238fn default_browser_settings() -> BrowserSettings {
239    BrowserSettings {
240        nostr_relays: None,
241        blossom_read_servers: None,
242        blossom_write_servers: None,
243        enable_webrtc: Some(false),
244        enable_multicast: Some(false),
245        max_multicast_peers: None,
246        enable_fips: None,
247        enable_fips_udp: None,
248        enable_fips_webrtc: None,
249        fetch_from_fips_peers: None,
250        social_graph_crawl_depth: None,
251        sync_enabled: Some(false),
252        sync_own: Some(true),
253        sync_followed: Some(true),
254        sync_max_concurrent: Some(3),
255        public_writes: Some(false),
256        allowed_npubs: None,
257        socialgraph_root: None,
258    }
259}
260
261fn normalize_server_list(values: Vec<String>) -> Vec<String> {
262    let mut normalized = values
263        .into_iter()
264        .map(|value| value.trim().to_string())
265        .filter(|value| !value.is_empty())
266        .collect::<Vec<_>>();
267    normalized.sort();
268    normalized.dedup();
269    normalized
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use base64::Engine;
276    use nostr::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
277    use reqwest::blocking::Client;
278    use serde_json::json;
279    use tempfile::TempDir;
280
281    fn create_blossom_auth(keys: &Keys, action: &str) -> String {
282        let expiration = Timestamp::from(Timestamp::now().as_u64() + 300);
283        let tags = vec![
284            Tag::custom(TagKind::Custom("t".into()), vec![action.to_string()]),
285            Tag::custom(
286                TagKind::Custom("expiration".into()),
287                vec![expiration.to_string()],
288            ),
289        ];
290        let event = EventBuilder::new(Kind::Custom(24242), "", tags)
291            .to_event(keys)
292            .expect("sign blossom auth");
293        let encoded = base64::engine::general_purpose::STANDARD
294            .encode(serde_json::to_string(&event).expect("serialize auth event"));
295        format!("Nostr {encoded}")
296    }
297
298    #[test]
299    fn host_runtime_starts_and_shuts_down() {
300        let temp = TempDir::new().expect("temp dir");
301        let mut runtime =
302            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
303
304        let status = runtime.status();
305        assert!(
306            status.config_dir.join("keys").exists(),
307            "expected host daemon to materialize keys in its config dir"
308        );
309
310        let response = reqwest::blocking::get(format!("{}/htree/test", status.base_url))
311            .expect("fetch test endpoint");
312        assert!(
313            response.status().is_success(),
314            "embedded daemon should answer"
315        );
316
317        runtime.shutdown();
318
319        let stopped = reqwest::blocking::get(format!("{}/htree/test", status.base_url)).is_err();
320        assert!(stopped, "expected host daemon shutdown to stop serving");
321    }
322
323    #[test]
324    fn host_runtime_rejects_public_blossom_uploads() {
325        let temp = TempDir::new().expect("temp dir");
326        let runtime =
327            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
328        let status = runtime.status();
329
330        let keys = Keys::generate();
331        let response = Client::new()
332            .put(format!("{}/upload", status.base_url))
333            .header("Authorization", create_blossom_auth(&keys, "upload"))
334            .header("Content-Type", "text/plain")
335            .body("browser-mode upload probe")
336            .send()
337            .expect("upload request");
338
339        assert_eq!(
340            response.status(),
341            reqwest::StatusCode::FORBIDDEN,
342            "browser-mode daemon must not accept public Blossom uploads"
343        );
344    }
345
346    #[test]
347    fn host_runtime_keeps_default_relays_and_file_servers() {
348        let temp = TempDir::new().expect("temp dir");
349        let runtime =
350            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
351        let status = runtime.status();
352
353        let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
354            .expect("fetch daemon status");
355        assert!(
356            response.status().is_success(),
357            "status endpoint should answer"
358        );
359        let payload: serde_json::Value = response.json().expect("parse daemon status");
360
361        assert!(
362            payload["upstream"]["nostr_relays"]
363                .as_u64()
364                .unwrap_or_default()
365                > 0,
366            "browser mode should keep default relays for profile lookups",
367        );
368        assert!(
369            payload["upstream"]["blossom_servers"]
370                .as_u64()
371                .unwrap_or_default()
372                > 0,
373            "browser mode should keep default file servers for content fetches",
374        );
375        assert_eq!(
376            payload["mesh"]["enabled"].as_bool(),
377            Some(false),
378            "browser mode should still keep peer discovery off until enabled",
379        );
380    }
381
382    #[test]
383    fn host_runtime_applies_browser_settings_overrides() {
384        let temp = TempDir::new().expect("temp dir");
385        let config_dir = temp.path().join("config");
386        std::fs::create_dir_all(&config_dir).expect("create config dir");
387        std::fs::write(
388            config_dir.join("browser_settings.json"),
389            serde_json::to_vec_pretty(&json!({
390                "nostrRelays": [
391                    "wss://relay.example-two",
392                    "wss://relay.example-one",
393                    "wss://relay.example-two"
394                ],
395                "blossomReadServers": [
396                    "https://cdn.example"
397                ],
398                "blossomWriteServers": [
399                    "https://upload.example"
400                ],
401                "enableWebrtc": true,
402                "enableFips": false,
403                "enableFipsUdp": false,
404                "enableFipsWebrtc": false,
405                "fetchFromFipsPeers": false,
406                "socialGraphCrawlDepth": 0,
407                "syncEnabled": false,
408                "syncOwn": false,
409                "syncFollowed": false
410            }))
411            .expect("serialize browser settings"),
412        )
413        .expect("write browser settings");
414
415        let runtime =
416            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
417        let status = runtime.status();
418
419        let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
420            .expect("fetch daemon status");
421        assert!(
422            response.status().is_success(),
423            "status endpoint should answer"
424        );
425        let payload: serde_json::Value = response.json().expect("parse daemon status");
426
427        assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(2));
428        assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
429        assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(false));
430    }
431
432    #[test]
433    fn browser_config_applies_background_service_overrides() {
434        let temp = TempDir::new().expect("temp dir");
435        let config_dir = temp.path().join("config");
436        let data_dir = temp.path().join("data");
437        std::fs::create_dir_all(&config_dir).expect("create config dir");
438        std::fs::write(
439            config_dir.join("browser_settings.json"),
440            serde_json::to_vec_pretty(&json!({
441                "enableFips": false,
442                "enableFipsUdp": false,
443                "enableFipsWebrtc": false,
444                "fetchFromFipsPeers": false,
445                "socialGraphCrawlDepth": 0
446            }))
447            .expect("serialize browser settings"),
448        )
449        .expect("write browser settings");
450
451        let config = browser_config(&data_dir, &config_dir);
452
453        assert!(!config.server.enable_fips);
454        assert!(!config.server.enable_fips_udp);
455        assert!(!config.server.enable_fips_webrtc);
456        assert!(!config.server.fetch_from_fips_peers);
457        assert_eq!(config.nostr.social_graph_crawl_depth, 0);
458    }
459
460    #[test]
461    fn host_runtime_reload_applies_updated_browser_settings() {
462        let temp = TempDir::new().expect("temp dir");
463        let mut runtime =
464            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
465        let initial_status = runtime.status();
466
467        let config_dir = temp.path().join("config");
468        std::fs::create_dir_all(&config_dir).expect("create config dir");
469        std::fs::write(
470            config_dir.join("browser_settings.json"),
471            serde_json::to_vec_pretty(&json!({
472                "nostrRelays": ["wss://relay.example-one"],
473                "blossomReadServers": ["https://cdn.example"],
474                "blossomWriteServers": ["https://upload.example"],
475                "enableWebrtc": true
476            }))
477            .expect("serialize browser settings"),
478        )
479        .expect("write browser settings");
480
481        let reloaded_status = runtime.reload().expect("reload daemon");
482        let response = reqwest::blocking::get(format!("{}/api/status", reloaded_status.base_url))
483            .expect("fetch daemon status after reload");
484        assert!(
485            response.status().is_success(),
486            "reloaded daemon should answer"
487        );
488        let payload: serde_json::Value = response.json().expect("parse daemon status");
489
490        assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(1));
491        assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
492        assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(true));
493        assert!(
494            !reloaded_status.base_url.is_empty(),
495            "reload should keep serving from some loopback endpoint"
496        );
497        assert_eq!(
498            reloaded_status.self_npub, initial_status.self_npub,
499            "reload should keep the same browser-owned identity"
500        );
501    }
502}