Skip to main content

hashtree_client/
lib.rs

1//! Verified public Hashtree reads, sharing an existing daemon when available.
2//! No daemon database is opened and no background service is started.
3
4mod config;
5mod store;
6pub use config::ClientConfig;
7pub use store::BlobStore;
8
9use anyhow::{ensure, Context, Result};
10pub use hashtree_core::Cid;
11use hashtree_core::{HashTree, HashTreeConfig};
12use hashtree_resolver::{
13    nostr::{NostrResolverConfig, NostrRootResolver},
14    RootResolver,
15};
16use nostr::{nips::nip19::ToBech32, PublicKey};
17use percent_encoding::percent_decode_str;
18use std::{path::Path, sync::Arc};
19use tokio::sync::OnceCell;
20
21/// A public mutable tree reference. The first path segment is the tree name;
22/// percent-encoded slashes belong to that name. Later segments select a directory.
23#[derive(Clone, Debug)]
24pub struct Reference {
25    pub key: String,
26    pub path: String,
27}
28
29impl Reference {
30    pub fn parse(value: &str) -> Result<Self> {
31        let url = reqwest::Url::parse(value)?;
32        ensure!(
33            url.scheme() == "htree"
34                && url.username().is_empty()
35                && url.password().is_none()
36                && url.port().is_none()
37                && url.query().is_none()
38                && url.fragment().is_none(),
39            "expected a public htree:// publisher/tree URL"
40        );
41        let author = PublicKey::parse(url.host_str().context("missing publisher")?)?.to_bech32()?;
42        let mut parts = url.path().trim_start_matches('/').splitn(2, '/');
43        let tree = percent_decode_str(parts.next().unwrap_or_default()).decode_utf8()?;
44        ensure!(!tree.is_empty(), "missing Hashtree tree name");
45        let path = percent_decode_str(parts.next().unwrap_or_default())
46            .decode_utf8()?
47            .into_owned();
48        ensure!(
49            !path
50                .split('/')
51                .any(|s| s == "." || s == ".." || s.contains('\\') || s.contains('\0')),
52            "invalid directory path"
53        );
54        Ok(Self {
55            key: format!("{author}/{tree}"),
56            path,
57        })
58    }
59}
60
61pub struct Client {
62    config: ClientConfig,
63    store: Arc<BlobStore>,
64    daemon: OnceCell<Option<String>>,
65}
66
67impl Client {
68    pub fn new(config: ClientConfig, cache: &Path) -> Result<Self> {
69        let store = Arc::new(BlobStore::new(cache, config.clone())?);
70        Ok(Self {
71            config,
72            store,
73            daemon: OnceCell::new(),
74        })
75    }
76
77    pub fn store(&self) -> Arc<BlobStore> {
78        self.store.clone()
79    }
80
81    pub async fn daemon_url(&self) -> Option<&str> {
82        self.daemon
83            .get_or_init(|| async {
84                let url = self.config.daemon_url.as_ref()?;
85                let response = self
86                    .store
87                    .http
88                    .get(format!("{url}/health"))
89                    .timeout(std::time::Duration::from_millis(500))
90                    .send()
91                    .await
92                    .ok()?;
93                response.status().is_success().then(|| url.clone())
94            })
95            .await
96            .as_deref()
97    }
98
99    async fn observe(&self, key: &str, relays: Vec<String>) -> Result<Cid> {
100        let resolver = NostrRootResolver::new(NostrResolverConfig {
101            relays,
102            resolve_timeout: self.config.resolve_window,
103            secret_key: None,
104        })
105        .await?;
106        let result = resolver.resolve_open(key, self.config.resolve_window).await;
107        resolver.stop().await?;
108        Ok(result?)
109    }
110
111    /// Observe signed roots for the configured window, including after EOSE.
112    /// A quiet/failed local subscription falls back to configured relays unless
113    /// local-daemon-only was explicitly requested. Absence is reported as timeout.
114    pub async fn resolve(&self, reference: &Reference) -> Result<Cid> {
115        let local_result = if let Some(daemon) = self.daemon_url().await {
116            let mut url = reqwest::Url::parse(daemon)?;
117            // Ask the daemon's existing event provider/peers to discover the
118            // root and cache its signed event. The returned CID is only a hint:
119            // authentication still happens through the signed relay event below.
120            // Older daemons may not implement this endpoint.
121            let (publisher, name) = reference
122                .key
123                .split_once('/')
124                .context("invalid resolver key")?;
125            let mut resolve_url = url.clone();
126            resolve_url
127                .path_segments_mut()
128                .map_err(|_| anyhow::anyhow!("invalid daemon URL"))?
129                .extend(["api", "nostr", "resolve", publisher, name]);
130            resolve_url.set_query(Some("refresh=1"));
131            let _ = self.store.http.get(resolve_url).send().await;
132            url.set_scheme("ws")
133                .map_err(|_| anyhow::anyhow!("invalid local daemon URL"))?;
134            url.set_path("/ws");
135            Some(self.observe(&reference.key, vec![url.to_string()]).await)
136        } else {
137            None
138        };
139        let root = match local_result {
140            Some(Ok(root)) => root,
141            Some(Err(error)) if self.config.local_only => return Err(error),
142            None if self.config.local_only => {
143                anyhow::bail!("HTREE_LOCAL_DAEMON_ONLY requires a running local htree daemon")
144            }
145            _ => {
146                self.observe(&reference.key, self.config.relays.clone())
147                    .await?
148            }
149        };
150        if reference.path.is_empty() {
151            return Ok(root);
152        }
153        self.tree()
154            .resolve(&root, &reference.path)
155            .await?
156            .context("Hashtree directory is missing")
157    }
158
159    fn tree(&self) -> HashTree<BlobStore> {
160        HashTree::new(HashTreeConfig::new(self.store.clone()))
161    }
162
163    /// Read a logical file from an immutable root, verifying every raw block.
164    pub async fn read_file(&self, root: &Cid, path: &str, limit: usize) -> Result<Vec<u8>> {
165        let cid = self
166            .tree()
167            .resolve(root, path)
168            .await?
169            .context("Hashtree file is missing")?;
170        self.tree()
171            .get(&cid, Some(limit as u64))
172            .await?
173            .context("Hashtree file is unavailable")
174    }
175}