1mod 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#[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 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 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 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}