Skip to main content

hashtree_cli/
fetch.rs

1//! Remote content fetching through configured Blossom read servers.
2//!
3//! Provides shared logic for fetching content from:
4//! 1. Local storage (first)
5//! 2. Blossom HTTP servers
6
7use anyhow::Result;
8use hashtree_blossom::BlossomClient;
9use hashtree_config::detect_local_daemon_url;
10use hashtree_core::{to_hex, Cid, HashTree, HashTreeConfig, Link};
11use nostr::Keys;
12use std::collections::{HashSet, VecDeque};
13use std::sync::Arc;
14use std::time::Duration;
15use tracing::debug;
16
17use crate::config::Config as CliConfig;
18use crate::storage::HashtreeStore;
19use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
20
21fn child_cid(parent: &Cid, link: &Link) -> Cid {
22    let inherits_parent_key = link
23        .name
24        .as_deref()
25        .map(|name| {
26            name.starts_with("_chunk_")
27                || (name.starts_with('_') && name.chars().count() == 2 && link.link_type.is_tree())
28        })
29        .unwrap_or(false);
30
31    Cid {
32        hash: link.hash,
33        key: link.key.or(if inherits_parent_key {
34            parent.key
35        } else {
36            None
37        }),
38    }
39}
40
41#[derive(Debug, Default)]
42pub struct FetchProgress {
43    chunks_fetched: AtomicUsize,
44    bytes_fetched: AtomicU64,
45}
46
47#[derive(Debug, Clone, Copy, Default)]
48pub struct FetchProgressSnapshot {
49    pub chunks_fetched: usize,
50    pub bytes_fetched: u64,
51}
52
53impl FetchProgress {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    pub fn snapshot(&self) -> FetchProgressSnapshot {
59        FetchProgressSnapshot {
60            chunks_fetched: self.chunks_fetched.load(Ordering::Relaxed),
61            bytes_fetched: self.bytes_fetched.load(Ordering::Relaxed),
62        }
63    }
64
65    fn record_chunk(&self, byte_len: usize) {
66        self.chunks_fetched.fetch_add(1, Ordering::Relaxed);
67        self.bytes_fetched
68            .fetch_add(byte_len as u64, Ordering::Relaxed);
69    }
70}
71
72/// Configuration for remote fetching
73#[derive(Clone)]
74pub struct FetchConfig {
75    /// Timeout for Blossom requests
76    pub blossom_timeout: Duration,
77}
78
79impl Default for FetchConfig {
80    fn default() -> Self {
81        Self {
82            blossom_timeout: Duration::from_millis(10000),
83        }
84    }
85}
86
87/// Fetcher for remote content
88pub struct Fetcher {
89    blossom: BlossomClient,
90}
91
92impl Fetcher {
93    /// Create a new fetcher with the given config
94    /// BlossomClient auto-loads servers from ~/.hashtree/config.toml
95    pub fn new(config: FetchConfig) -> Self {
96        // Generate ephemeral keys for downloads (no signing needed)
97        let keys = Keys::generate();
98        let blossom = BlossomClient::new(keys).with_timeout(config.blossom_timeout);
99        let blossom = with_local_daemon_read(blossom);
100
101        Self { blossom }
102    }
103
104    /// Create a new fetcher with specific keys (for authenticated uploads)
105    pub fn with_keys(config: FetchConfig, keys: Keys) -> Self {
106        let blossom = BlossomClient::new(keys).with_timeout(config.blossom_timeout);
107        let blossom = with_local_daemon_read(blossom);
108
109        Self { blossom }
110    }
111
112    /// Get the underlying BlossomClient
113    pub fn blossom(&self) -> &BlossomClient {
114        &self.blossom
115    }
116
117    /// Fetch a single chunk by hash.
118    pub async fn fetch_chunk(&self, hash_hex: &str) -> Result<Vec<u8>> {
119        let short_hash = if hash_hex.len() >= 12 {
120            &hash_hex[..12]
121        } else {
122            hash_hex
123        };
124
125        debug!("Trying Blossom for {}", short_hash);
126        match self.blossom.download(hash_hex).await {
127            Ok(data) => {
128                debug!("Got {} from Blossom ({} bytes)", short_hash, data.len());
129                Ok(data)
130            }
131            Err(e) => {
132                debug!("Blossom download failed for {}: {}", short_hash, e);
133                Err(anyhow::anyhow!(
134                    "Failed to fetch {} from any source: {}",
135                    short_hash,
136                    e
137                ))
138            }
139        }
140    }
141
142    /// Fetch a chunk, checking local storage first
143    pub async fn fetch_chunk_with_store(
144        &self,
145        store: &HashtreeStore,
146        hash: &[u8; 32],
147    ) -> Result<Vec<u8>> {
148        // Check local storage first
149        if let Some(data) = store.get_chunk(hash)? {
150            return Ok(data);
151        }
152
153        // Fetch remotely and store
154        let hash_hex = to_hex(hash);
155        let data = self.fetch_chunk(&hash_hex).await?;
156        store.put_cached_blob(&data)?;
157        Ok(data)
158    }
159
160    /// Fetch an entire tree (all chunks recursively) - sequential version
161    /// Returns (chunks_fetched, bytes_fetched)
162    pub async fn fetch_tree(
163        &self,
164        store: &HashtreeStore,
165        root_hash: &[u8; 32],
166    ) -> Result<(usize, u64)> {
167        self.fetch_cid_tree(store, &Cid::public(*root_hash)).await
168    }
169
170    /// Fetch an entire tree from a CID, preserving decryption keys for encrypted trees.
171    pub async fn fetch_cid_tree(
172        &self,
173        store: &HashtreeStore,
174        root_cid: &Cid,
175    ) -> Result<(usize, u64)> {
176        self.fetch_cid_tree_with_progress(store, root_cid, None)
177            .await
178    }
179
180    /// Fetch an entire tree from a CID, preserving decryption keys, with optional progress updates.
181    pub async fn fetch_cid_tree_with_progress(
182        &self,
183        store: &HashtreeStore,
184        root_cid: &Cid,
185        progress: Option<&FetchProgress>,
186    ) -> Result<(usize, u64)> {
187        self.fetch_cid_tree_parallel_with_progress(store, root_cid, 1, progress)
188            .await
189    }
190
191    /// Fetch an entire tree with parallel downloads
192    /// Uses work-stealing: always keeps `concurrency` requests in flight
193    /// Returns (chunks_fetched, bytes_fetched)
194    pub async fn fetch_tree_parallel(
195        &self,
196        store: &HashtreeStore,
197        root_hash: &[u8; 32],
198        concurrency: usize,
199    ) -> Result<(usize, u64)> {
200        self.fetch_cid_tree_parallel(store, &Cid::public(*root_hash), concurrency)
201            .await
202    }
203
204    /// Fetch an entire tree with parallel downloads, preserving decryption keys.
205    pub async fn fetch_cid_tree_parallel(
206        &self,
207        store: &HashtreeStore,
208        root_cid: &Cid,
209        concurrency: usize,
210    ) -> Result<(usize, u64)> {
211        self.fetch_cid_tree_parallel_with_progress(store, root_cid, concurrency, None)
212            .await
213    }
214
215    /// Fetch an entire tree with parallel downloads, preserving decryption keys, with optional progress updates.
216    pub async fn fetch_cid_tree_parallel_with_progress(
217        &self,
218        store: &HashtreeStore,
219        root_cid: &Cid,
220        concurrency: usize,
221        progress: Option<&FetchProgress>,
222    ) -> Result<(usize, u64)> {
223        use futures::stream::{FuturesUnordered, StreamExt};
224
225        let chunks_fetched = Arc::new(AtomicUsize::new(0));
226        let bytes_fetched = Arc::new(AtomicU64::new(0));
227        let mut queued: HashSet<[u8; 32]> = HashSet::new();
228        let mut pending: VecDeque<Cid> = VecDeque::new();
229
230        pending.push_back(root_cid.clone());
231        queued.insert(root_cid.hash);
232
233        let mut active = FuturesUnordered::new();
234        let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
235
236        loop {
237            // Fill up to concurrency limit from pending queue
238            while active.len() < concurrency {
239                if let Some(cid) = pending.pop_front() {
240                    if store.blob_exists(&cid.hash).unwrap_or(false) {
241                        if let Some(node) = tree.get_node(&cid).await? {
242                            for link in node.links {
243                                let child = child_cid(&cid, &link);
244                                if queued.insert(child.hash) {
245                                    pending.push_back(child);
246                                }
247                            }
248                        }
249                        continue;
250                    }
251
252                    let hash_hex = to_hex(&cid.hash);
253                    let blossom = self.blossom.clone();
254                    let fut = async move {
255                        let data = blossom.download(&hash_hex).await;
256                        (cid, data)
257                    };
258                    active.push(fut);
259                } else {
260                    break;
261                }
262            }
263
264            // If nothing active, we're done
265            if active.is_empty() {
266                break;
267            }
268
269            // Wait for any download to complete
270            if let Some((cid, result)) = active.next().await {
271                match result {
272                    Ok(data) => {
273                        // Store it
274                        store.put_cached_blob(&data)?;
275                        if let Some(progress) = progress {
276                            progress.record_chunk(data.len());
277                        }
278                        chunks_fetched.fetch_add(1, Ordering::Relaxed);
279                        bytes_fetched.fetch_add(data.len() as u64, Ordering::Relaxed);
280
281                        if let Some(node) = tree.get_node(&cid).await? {
282                            for link in node.links {
283                                let child = child_cid(&cid, &link);
284                                if queued.insert(child.hash) {
285                                    pending.push_back(child);
286                                }
287                            }
288                        }
289                    }
290                    Err(e) => {
291                        debug!("Failed to fetch {}: {}", to_hex(&cid.hash), e);
292                        // Continue with other chunks - don't fail the whole tree
293                    }
294                }
295            }
296        }
297
298        Ok((
299            chunks_fetched.load(Ordering::Relaxed),
300            bytes_fetched.load(Ordering::Relaxed),
301        ))
302    }
303
304    /// Fetch a file by hash, fetching all chunks if needed
305    /// Returns the complete file content
306    pub async fn fetch_file(
307        &self,
308        store: &HashtreeStore,
309        hash: &[u8; 32],
310    ) -> Result<Option<Vec<u8>>> {
311        // First, try to get from local storage
312        if let Some(content) = store.get_file(hash)? {
313            return Ok(Some(content));
314        }
315
316        // Fetch the tree
317        self.fetch_tree(store, hash).await?;
318
319        // Now try to read the file
320        store.get_file(hash)
321    }
322
323    /// Fetch a directory listing, fetching chunks if needed
324    pub async fn fetch_directory(
325        &self,
326        store: &HashtreeStore,
327        hash: &[u8; 32],
328    ) -> Result<Option<crate::storage::DirectoryListing>> {
329        // First, try to get from local storage
330        if let Ok(Some(listing)) = store.get_directory_listing(hash) {
331            return Ok(Some(listing));
332        }
333
334        // Fetch the tree
335        self.fetch_tree(store, hash).await?;
336
337        // Now try to get the directory listing
338        store.get_directory_listing(hash)
339    }
340
341    /// Upload data to Blossom servers
342    pub async fn upload(&self, data: &[u8]) -> Result<String> {
343        self.blossom
344            .upload(data)
345            .await
346            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
347    }
348
349    /// Upload data if it doesn't already exist
350    pub async fn upload_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
351        self.blossom
352            .upload_if_missing(data)
353            .await
354            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
355    }
356}
357
358fn with_local_daemon_read(blossom: BlossomClient) -> BlossomClient {
359    let bind_address = CliConfig::load().ok().map(|cfg| cfg.server.bind_address);
360    let local_url = detect_local_daemon_url(bind_address.as_deref());
361    let Some(local_url) = local_url else {
362        return blossom;
363    };
364
365    let mut servers = blossom.read_servers().to_vec();
366    if servers.iter().any(|server| server == &local_url) {
367        return blossom;
368    }
369    servers.insert(0, local_url);
370    blossom.with_read_servers(servers)
371}