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 url.set_scheme("ws")
118 .map_err(|_| anyhow::anyhow!("invalid local daemon URL"))?;
119 url.set_path("/ws");
120 Some(self.observe(&reference.key, vec![url.to_string()]).await)
121 } else {
122 None
123 };
124 let root = match local_result {
125 Some(Ok(root)) => root,
126 Some(Err(error)) if self.config.local_only => return Err(error),
127 None if self.config.local_only => {
128 anyhow::bail!("HTREE_LOCAL_DAEMON_ONLY requires a running local htree daemon")
129 }
130 _ => {
131 self.observe(&reference.key, self.config.relays.clone())
132 .await?
133 }
134 };
135 if reference.path.is_empty() {
136 return Ok(root);
137 }
138 self.tree()
139 .resolve(&root, &reference.path)
140 .await?
141 .context("Hashtree directory is missing")
142 }
143
144 fn tree(&self) -> HashTree<BlobStore> {
145 HashTree::new(HashTreeConfig::new(self.store.clone()))
146 }
147
148 pub async fn read_file(&self, root: &Cid, path: &str, limit: usize) -> Result<Vec<u8>> {
150 let cid = self
151 .tree()
152 .resolve(root, path)
153 .await?
154 .context("Hashtree file is missing")?;
155 self.tree()
156 .get(&cid, Some(limit as u64))
157 .await?
158 .context("Hashtree file is unavailable")
159 }
160}