Skip to main content

syncweb_core/folder/
syncweb_folder.rs

1use std::{collections::HashMap, path::Path, sync::Arc};
2
3use async_trait::async_trait;
4use iroh::PublicKey;
5use iroh_blobs::{Hash, ticket::BlobTicket};
6use iroh_docs::{AuthorId, DocTicket, NamespaceId, api::Doc};
7use tokio::sync::RwLock;
8
9use crate::error::{Result, SyncwebError};
10use crate::node::{blob_store::BlobStore, docs_engine::DocsEngine};
11
12use super::SyncMode;
13use super::public_subscription::{EntryLike, FolderLike};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum Capability {
18    Admin,
19    Write,
20    Read,
21}
22
23impl Capability {
24    #[must_use]
25    pub const fn can_write(self) -> bool {
26        matches!(self, Self::Admin | Self::Write)
27    }
28}
29
30#[derive(Clone)]
31pub struct SyncwebFolder {
32    doc: Doc,
33    namespace_id: NamespaceId,
34    author: AuthorId,
35    blob_store: BlobStore,
36    docs_engine: DocsEngine,
37    sync_mode: SyncMode,
38    capabilities: Arc<RwLock<HashMap<PublicKey, Capability>>>,
39}
40
41impl SyncwebFolder {
42    #[must_use]
43    pub fn new(
44        doc: Doc,
45        author: AuthorId,
46        blob_store: BlobStore,
47        docs_engine: DocsEngine,
48        sync_mode: SyncMode,
49    ) -> Self {
50        let namespace_id = doc.id();
51        Self {
52            doc,
53            namespace_id,
54            author,
55            blob_store,
56            docs_engine,
57            sync_mode,
58            capabilities: Arc::new(RwLock::new(HashMap::new())),
59        }
60    }
61
62    /// Drop this folder's namespace from the docs engine.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the namespace cannot be dropped.
67    pub async fn drop_namespace(&self) -> Result<()> {
68        self.docs_engine
69            .drop_namespace(self.namespace_id)
70            .await
71            .map_err(|error| SyncwebError::operation("failed to drop folder namespace", error))
72    }
73
74    #[must_use]
75    pub const fn namespace_id(&self) -> NamespaceId {
76        self.namespace_id
77    }
78
79    #[must_use]
80    pub const fn author(&self) -> AuthorId {
81        self.author
82    }
83
84    #[must_use]
85    pub const fn mode(&self) -> SyncMode {
86        self.sync_mode
87    }
88
89    #[must_use]
90    pub const fn doc(&self) -> &Doc {
91        &self.doc
92    }
93
94    #[must_use]
95    pub const fn docs_engine(&self) -> &DocsEngine {
96        &self.docs_engine
97    }
98
99    pub async fn grant(&self, node_id: PublicKey, capability: Capability) {
100        self.capabilities.write().await.insert(node_id, capability);
101    }
102
103    pub async fn capability(&self, node_id: PublicKey) -> Option<Capability> {
104        self.capabilities.read().await.get(&node_id).copied()
105    }
106
107    pub async fn can_write_as(&self, node_id: PublicKey) -> bool {
108        self.sync_mode.can_write_locally() && self.capability(node_id).await.is_some_and(Capability::can_write)
109    }
110
111    /// Check whether a blob is complete in this folder's local store.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the blob store cannot be queried.
116    pub async fn has_local(&self, hash: Hash) -> Result<bool> {
117        self.blob_store.has(hash).await
118    }
119
120    /// Track this folder's metadata without fetching content into the local
121    /// store (user stories 1 and 4). Doc entries still sync, but iroh-docs'
122    /// download policy keeps the blobs remote until an explicit `download`.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the download policy cannot be persisted.
127    pub async fn set_metadata_only(&self) -> Result<()> {
128        self.set_download_policy(iroh_docs::store::DownloadPolicy::NothingExcept(Vec::new()))
129            .await
130    }
131
132    /// Restore the default download policy (fetch all referenced content during
133    /// doc sync).
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if the download policy cannot be persisted.
138    pub async fn clear_metadata_only(&self) -> Result<()> {
139        self.set_download_policy(iroh_docs::store::DownloadPolicy::EverythingExcept(Vec::new()))
140            .await
141    }
142
143    /// Whether this folder is currently in metadata-only mode (content stays
144    /// out of the local store until an explicit download).
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the download policy cannot be read.
149    pub async fn is_metadata_only(&self) -> Result<bool> {
150        let policy = self
151            .doc
152            .get_download_policy()
153            .await
154            .map_err(|error| SyncwebError::operation("failed to read folder download policy", error))?;
155        Ok(matches!(policy, iroh_docs::store::DownloadPolicy::NothingExcept(_)))
156    }
157
158    async fn set_download_policy(&self, policy: iroh_docs::store::DownloadPolicy) -> Result<()> {
159        self.doc
160            .set_download_policy(policy)
161            .await
162            .map_err(|error| SyncwebError::operation("failed to set folder download policy", error))
163    }
164
165    /// List the folder's content entries, excluding `sys/` metadata keys.
166    ///
167    /// Used by stats, verify, and health-reporting paths so the "iterate the
168    /// latest per-key entries, skip system keys" pattern lives in one place.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the document query fails.
173    pub async fn content_entries(&self) -> Result<Vec<iroh_docs::Entry>> {
174        let entries = self.docs_engine.list_latest(self.doc()).await?;
175        Ok(entries
176            .into_iter()
177            .filter(|entry| !entry.key().starts_with(b"sys/"))
178            .collect())
179    }
180
181    /// # Errors
182    ///
183    /// Returns an error if the blob fails to be stored or set.
184    pub async fn set_blob(&self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>) -> Result<Hash> {
185        if !self.sync_mode.can_write_locally() {
186            return Err(SyncwebError::WriteDenied {
187                mode: self.sync_mode.to_string(),
188            });
189        }
190        let value_bytes = value.as_ref();
191        let hash = self.blob_store.add_bytes(value_bytes).await?;
192        let len = u64::try_from(value_bytes.len())
193            .map_err(|error| SyncwebError::operation("blob size exceeds u64::MAX", error))?;
194        self.docs_engine
195            .set_blob(&self.doc, self.author, key, hash, len)
196            .await?;
197        Ok(hash)
198    }
199
200    /// Store an existing blob reference in this folder.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the folder is read-only, the blob is unavailable, or
205    /// the document entry cannot be written.
206    pub async fn set_blob_ref(&self, key: impl AsRef<[u8]>, hash: Hash, size: u64) -> Result<()> {
207        if !self.sync_mode.can_write_locally() {
208            return Err(SyncwebError::WriteDenied {
209                mode: self.sync_mode.to_string(),
210            });
211        }
212        self.docs_engine.set_blob(&self.doc, self.author, key, hash, size).await
213    }
214
215    /// Delete a folder entry.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the folder is read-only or the document entry cannot be deleted.
220    pub async fn delete_entry(&self, key: impl AsRef<[u8]>) -> Result<()> {
221        if !self.sync_mode.can_write_locally() {
222            return Err(SyncwebError::WriteDenied {
223                mode: self.sync_mode.to_string(),
224            });
225        }
226        self.docs_engine.delete(&self.doc, self.author, key).await
227    }
228
229    /// # Errors
230    ///
231    /// Returns an error if the folder ticket cannot be created.
232    pub async fn ticket(&self, writable: bool) -> Result<DocTicket> {
233        let can_write = writable && self.sync_mode.can_grant_write();
234        self.docs_engine.share_ticket(&self.doc, can_write).await
235    }
236
237    /// Create an unauthenticated ticket for a blob in this folder and pin it
238    /// while it is publicly shared.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the blob is unavailable or cannot be pinned.
243    pub async fn publish_blob(&self, endpoint: iroh::EndpointAddr, hash: Hash) -> Result<BlobTicket> {
244        if !self.blob_store.has(hash).await? {
245            return Err(SyncwebError::InvalidConfig(format!(
246                "cannot publish missing blob {hash}"
247            )));
248        }
249        let ticket = self.blob_store.ticket_for_addr(endpoint, hash);
250        self.blob_store
251            .pin(crate::pins::public_blob_pin(self.namespace_id, hash), hash)
252            .await?;
253        Ok(ticket)
254    }
255
256    /// Remove the public-sharing pin from a folder blob.
257    ///
258    /// Existing blob tickets are capabilities and remain usable while another
259    /// tag or active transfer retains the blob.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if the public-sharing pin cannot be removed.
264    pub async fn unpublish_blob(&self, hash: Hash) -> Result<()> {
265        self.blob_store
266            .unpin(crate::pins::public_blob_pin(self.namespace_id, hash))
267            .await
268    }
269
270    /// Pin every blob currently referenced by the folder's document so the
271    /// shared content is retained (not evicted by garbage collection).
272    ///
273    /// Returns the number of distinct blobs pinned.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if the folder's entries cannot be read or a pin fails.
278    pub async fn pin_all_content(&self) -> Result<usize> {
279        let entries = self.docs_engine.list_latest(&self.doc).await?;
280        let mut pinned = 0_usize;
281        for entry in entries {
282            let hash = entry.content_hash();
283            self.blob_store
284                .pin(crate::pins::public_blob_pin(self.namespace_id, hash), hash)
285                .await?;
286            pinned = pinned.saturating_add(1);
287        }
288        Ok(pinned)
289    }
290
291    /// Remove the retention pins placed by [`pin_all_content`](Self::pin_all_content)
292    /// for every blob currently referenced by the folder's document.
293    ///
294    /// Returns the number of pins removed.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if the folder's entries cannot be read or an unpin fails.
299    pub async fn unpin_all_content(&self) -> Result<usize> {
300        let entries = self.docs_engine.list_latest(&self.doc).await?;
301        let mut unpinned = 0_usize;
302        for entry in entries {
303            let hash = entry.content_hash();
304            self.blob_store
305                .unpin(crate::pins::public_blob_pin(self.namespace_id, hash))
306                .await?;
307            unpinned = unpinned.saturating_add(1);
308        }
309        Ok(unpinned)
310    }
311}
312
313#[async_trait]
314impl FolderLike for SyncwebFolder {
315    fn namespace_id(&self) -> String {
316        self.namespace_id.to_string()
317    }
318
319    fn label(&self) -> String {
320        self.namespace_id.to_string()
321    }
322
323    fn kind(&self) -> &'static str {
324        "folder"
325    }
326
327    fn path(&self) -> Option<&Path> {
328        None
329    }
330
331    async fn list_entries(&self) -> Result<Vec<EntryLike>> {
332        let entries = self.docs_engine.list_latest(&self.doc).await?;
333        let mut result = Vec::with_capacity(entries.len());
334        for entry in entries {
335            if entry.key().starts_with(b"sys/") {
336                continue;
337            }
338            let path = String::from_utf8_lossy(entry.key()).to_string();
339            result.push(EntryLike {
340                path,
341                hash: entry.content_hash(),
342                size: entry.content_len(),
343            });
344        }
345        Ok(result)
346    }
347}