1mod config;
5mod store;
6pub use config::ClientConfig;
7pub use store::BlobStore;
8
9use anyhow::{ensure, Context, Result};
10use hashtree_core::{Cid, HashTree, HashTreeConfig};
11use hashtree_resolver::{
12 nostr::{NostrResolverConfig, NostrRootResolver},
13 RootResolver,
14};
15use nostr::{nips::nip19::ToBech32, PublicKey};
16use percent_encoding::percent_decode_str;
17use std::{path::Path, sync::Arc};
18use tokio::sync::OnceCell;
19
20#[derive(Clone, Debug)]
23pub struct Reference {
24 pub key: String,
25 pub path: String,
26}
27
28impl Reference {
29 pub fn parse(value: &str) -> Result<Self> {
30 let url = reqwest::Url::parse(value)?;
31 ensure!(
32 url.scheme() == "htree"
33 && url.username().is_empty()
34 && url.password().is_none()
35 && url.port().is_none()
36 && url.query().is_none()
37 && url.fragment().is_none(),
38 "expected a public htree:// publisher/tree URL"
39 );
40 let author = PublicKey::parse(url.host_str().context("missing publisher")?)?.to_bech32()?;
41 let mut parts = url.path().trim_start_matches('/').splitn(2, '/');
42 let tree = percent_decode_str(parts.next().unwrap_or_default()).decode_utf8()?;
43 ensure!(!tree.is_empty(), "missing Hashtree tree name");
44 let path = percent_decode_str(parts.next().unwrap_or_default())
45 .decode_utf8()?
46 .into_owned();
47 ensure!(
48 !path
49 .split('/')
50 .any(|s| s == "." || s == ".." || s.contains('\\') || s.contains('\0')),
51 "invalid directory path"
52 );
53 Ok(Self {
54 key: format!("{author}/{tree}"),
55 path,
56 })
57 }
58}
59
60pub struct Client {
61 config: ClientConfig,
62 store: Arc<BlobStore>,
63 daemon: OnceCell<Option<String>>,
64}
65
66impl Client {
67 pub fn new(config: ClientConfig, cache: &Path) -> Result<Self> {
68 let store = Arc::new(BlobStore::new(cache, config.clone())?);
69 Ok(Self {
70 config,
71 store,
72 daemon: OnceCell::new(),
73 })
74 }
75
76 pub fn store(&self) -> Arc<BlobStore> {
77 self.store.clone()
78 }
79
80 pub async fn daemon_url(&self) -> Option<&str> {
81 self.daemon
82 .get_or_init(|| async {
83 let url = self.config.daemon_url.as_ref()?;
84 let response = self
85 .store
86 .http
87 .get(format!("{url}/health"))
88 .timeout(std::time::Duration::from_millis(500))
89 .send()
90 .await
91 .ok()?;
92 response.status().is_success().then(|| url.clone())
93 })
94 .await
95 .as_deref()
96 }
97
98 async fn observe(&self, key: &str, relays: Vec<String>) -> Result<Cid> {
99 let resolver = NostrRootResolver::new(NostrResolverConfig {
100 relays,
101 resolve_timeout: self.config.resolve_window,
102 secret_key: None,
103 })
104 .await?;
105 let result = resolver.resolve_open(key, self.config.resolve_window).await;
106 resolver.stop().await?;
107 Ok(result?)
108 }
109
110 pub async fn resolve(&self, reference: &Reference) -> Result<Cid> {
114 let local_result = if let Some(daemon) = self.daemon_url().await {
115 let mut url = reqwest::Url::parse(daemon)?;
116 url.set_scheme("ws")
117 .map_err(|_| anyhow::anyhow!("invalid local daemon URL"))?;
118 url.set_path("/ws");
119 Some(self.observe(&reference.key, vec![url.to_string()]).await)
120 } else {
121 None
122 };
123 let root = match local_result {
124 Some(Ok(root)) => root,
125 Some(Err(error)) if self.config.local_only => return Err(error),
126 None if self.config.local_only => {
127 anyhow::bail!("HTREE_LOCAL_DAEMON_ONLY requires a running local htree daemon")
128 }
129 _ => {
130 self.observe(&reference.key, self.config.relays.clone())
131 .await?
132 }
133 };
134 if reference.path.is_empty() {
135 return Ok(root);
136 }
137 self.tree()
138 .resolve(&root, &reference.path)
139 .await?
140 .context("Hashtree directory is missing")
141 }
142
143 fn tree(&self) -> HashTree<BlobStore> {
144 HashTree::new(HashTreeConfig::new(self.store.clone()))
145 }
146
147 pub async fn read_file(&self, root: &Cid, path: &str, limit: usize) -> Result<Vec<u8>> {
149 let cid = self
150 .tree()
151 .resolve(root, path)
152 .await?
153 .context("Hashtree file is missing")?;
154 self.tree()
155 .get(&cid, Some(limit as u64))
156 .await?
157 .context("Hashtree file is unavailable")
158 }
159}