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_secs() + 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), "")
291            .tags(tags)
292            .sign_with_keys(keys)
293            .expect("sign blossom auth");
294        let encoded = base64::engine::general_purpose::STANDARD
295            .encode(serde_json::to_string(&event).expect("serialize auth event"));
296        format!("Nostr {encoded}")
297    }
298
299    #[test]
300    fn host_runtime_starts_and_shuts_down() {
301        let temp = TempDir::new().expect("temp dir");
302        let mut runtime =
303            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
304
305        let status = runtime.status();
306        assert!(
307            status.config_dir.join("keys").exists(),
308            "expected host daemon to materialize keys in its config dir"
309        );
310
311        let response = reqwest::blocking::get(format!("{}/htree/test", status.base_url))
312            .expect("fetch test endpoint");
313        assert!(
314            response.status().is_success(),
315            "embedded daemon should answer"
316        );
317
318        runtime.shutdown();
319
320        let stopped = reqwest::blocking::get(format!("{}/htree/test", status.base_url)).is_err();
321        assert!(stopped, "expected host daemon shutdown to stop serving");
322    }
323
324    #[test]
325    fn host_runtime_rejects_public_blossom_uploads() {
326        let temp = TempDir::new().expect("temp dir");
327        let runtime =
328            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
329        let status = runtime.status();
330
331        let keys = Keys::generate();
332        let response = Client::new()
333            .put(format!("{}/upload", status.base_url))
334            .header("Authorization", create_blossom_auth(&keys, "upload"))
335            .header("Content-Type", "text/plain")
336            .body("browser-mode upload probe")
337            .send()
338            .expect("upload request");
339
340        assert_eq!(
341            response.status(),
342            reqwest::StatusCode::FORBIDDEN,
343            "browser-mode daemon must not accept public Blossom uploads"
344        );
345    }
346
347    #[test]
348    fn host_runtime_keeps_default_relays_and_file_servers() {
349        let temp = TempDir::new().expect("temp dir");
350        let runtime =
351            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
352        let status = runtime.status();
353
354        let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
355            .expect("fetch daemon status");
356        assert!(
357            response.status().is_success(),
358            "status endpoint should answer"
359        );
360        let payload: serde_json::Value = response.json().expect("parse daemon status");
361
362        assert!(
363            payload["upstream"]["nostr_relays"]
364                .as_u64()
365                .unwrap_or_default()
366                > 0,
367            "browser mode should keep default relays for profile lookups",
368        );
369        assert!(
370            payload["upstream"]["blossom_servers"]
371                .as_u64()
372                .unwrap_or_default()
373                > 0,
374            "browser mode should keep default file servers for content fetches",
375        );
376        assert_eq!(
377            payload["mesh"]["enabled"].as_bool(),
378            Some(false),
379            "browser mode should still keep peer discovery off until enabled",
380        );
381    }
382
383    #[test]
384    fn host_runtime_applies_browser_settings_overrides() {
385        let temp = TempDir::new().expect("temp dir");
386        let config_dir = temp.path().join("config");
387        std::fs::create_dir_all(&config_dir).expect("create config dir");
388        std::fs::write(
389            config_dir.join("browser_settings.json"),
390            serde_json::to_vec_pretty(&json!({
391                "nostrRelays": [
392                    "wss://relay.example-two",
393                    "wss://relay.example-one",
394                    "wss://relay.example-two"
395                ],
396                "blossomReadServers": [
397                    "https://cdn.example"
398                ],
399                "blossomWriteServers": [
400                    "https://upload.example"
401                ],
402                "enableWebrtc": true,
403                "enableFips": false,
404                "enableFipsUdp": false,
405                "enableFipsWebrtc": false,
406                "fetchFromFipsPeers": false,
407                "socialGraphCrawlDepth": 0,
408                "syncEnabled": false,
409                "syncOwn": false,
410                "syncFollowed": false
411            }))
412            .expect("serialize browser settings"),
413        )
414        .expect("write browser settings");
415
416        let runtime =
417            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
418        let status = runtime.status();
419
420        let response = reqwest::blocking::get(format!("{}/api/status", status.base_url))
421            .expect("fetch daemon status");
422        assert!(
423            response.status().is_success(),
424            "status endpoint should answer"
425        );
426        let payload: serde_json::Value = response.json().expect("parse daemon status");
427
428        assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(2));
429        assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
430        assert_eq!(payload["mesh"]["enabled"].as_bool(), Some(false));
431    }
432
433    #[test]
434    fn browser_config_applies_background_service_overrides() {
435        let temp = TempDir::new().expect("temp dir");
436        let config_dir = temp.path().join("config");
437        let data_dir = temp.path().join("data");
438        std::fs::create_dir_all(&config_dir).expect("create config dir");
439        std::fs::write(
440            config_dir.join("browser_settings.json"),
441            serde_json::to_vec_pretty(&json!({
442                "enableFips": false,
443                "enableFipsUdp": false,
444                "enableFipsWebrtc": false,
445                "fetchFromFipsPeers": false,
446                "socialGraphCrawlDepth": 0
447            }))
448            .expect("serialize browser settings"),
449        )
450        .expect("write browser settings");
451
452        let config = browser_config(&data_dir, &config_dir);
453
454        assert!(!config.server.enable_fips);
455        assert!(!config.server.enable_fips_udp);
456        assert!(!config.server.enable_fips_webrtc);
457        assert!(!config.server.fetch_from_fips_peers);
458        assert_eq!(config.nostr.social_graph_crawl_depth, 0);
459    }
460
461    #[test]
462    fn host_runtime_reload_applies_updated_browser_settings() {
463        let temp = TempDir::new().expect("temp dir");
464        let mut runtime =
465            HostDaemonRuntime::start(HostDaemonOptions::new(temp.path())).expect("start daemon");
466        let initial_status = runtime.status();
467
468        let config_dir = temp.path().join("config");
469        std::fs::create_dir_all(&config_dir).expect("create config dir");
470        std::fs::write(
471            config_dir.join("browser_settings.json"),
472            serde_json::to_vec_pretty(&json!({
473                "nostrRelays": ["wss://relay.example-one"],
474                "blossomReadServers": ["https://cdn.example"],
475                "blossomWriteServers": ["https://upload.example"],
476                "enableWebrtc": true
477            }))
478            .expect("serialize browser settings"),
479        )
480        .expect("write browser settings");
481
482        let reloaded_status = runtime.reload().expect("reload daemon");
483        let response = reqwest::blocking::get(format!("{}/api/status", reloaded_status.base_url))
484            .expect("fetch daemon status after reload");
485        assert!(
486            response.status().is_success(),
487            "reloaded daemon should answer"
488        );
489        let payload: serde_json::Value = response.json().expect("parse daemon status");
490
491        assert_eq!(payload["upstream"]["nostr_relays"].as_u64(), Some(1));
492        assert_eq!(payload["upstream"]["blossom_servers"].as_u64(), Some(2));
493        assert_eq!(
494            payload["mesh"]["enabled"].as_bool(),
495            Some(false),
496            "embedded non-P2P builds keep mesh disabled even when browser settings request WebRTC",
497        );
498        assert!(
499            !reloaded_status.base_url.is_empty(),
500            "reload should keep serving from some loopback endpoint"
501        );
502        assert_eq!(
503            reloaded_status.self_npub, initial_status.self_npub,
504            "reload should keep the same browser-owned identity"
505        );
506    }
507}