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    /// List the folder's content entries, excluding `sys/` metadata keys.
121    ///
122    /// Used by stats, verify, and health-reporting paths so the "iterate the
123    /// latest per-key entries, skip system keys" pattern lives in one place.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the document query fails.
128    pub async fn content_entries(&self) -> Result<Vec<iroh_docs::Entry>> {
129        let entries = self.docs_engine.list_latest(self.doc()).await?;
130        Ok(entries
131            .into_iter()
132            .filter(|entry| !entry.key().starts_with(b"sys/"))
133            .collect())
134    }
135
136    /// # Errors
137    ///
138    /// Returns an error if the blob fails to be stored or set.
139    pub async fn set_blob(&self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>) -> Result<Hash> {
140        if !self.sync_mode.can_write_locally() {
141            return Err(SyncwebError::WriteDenied {
142                mode: self.sync_mode.to_string(),
143            });
144        }
145        let value_bytes = value.as_ref();
146        let hash = self.blob_store.add_bytes(value_bytes).await?;
147        let len = u64::try_from(value_bytes.len())
148            .map_err(|error| SyncwebError::operation("blob size exceeds u64::MAX", error))?;
149        self.docs_engine
150            .set_blob(&self.doc, self.author, key, hash, len)
151            .await?;
152        Ok(hash)
153    }
154
155    /// Store an existing blob reference in this folder.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the folder is read-only, the blob is unavailable, or
160    /// the document entry cannot be written.
161    pub async fn set_blob_ref(&self, key: impl AsRef<[u8]>, hash: Hash, size: u64) -> Result<()> {
162        if !self.sync_mode.can_write_locally() {
163            return Err(SyncwebError::WriteDenied {
164                mode: self.sync_mode.to_string(),
165            });
166        }
167        self.docs_engine.set_blob(&self.doc, self.author, key, hash, size).await
168    }
169
170    /// Delete a folder entry.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the folder is read-only or the document entry cannot be deleted.
175    pub async fn delete_entry(&self, key: impl AsRef<[u8]>) -> Result<()> {
176        if !self.sync_mode.can_write_locally() {
177            return Err(SyncwebError::WriteDenied {
178                mode: self.sync_mode.to_string(),
179            });
180        }
181        self.docs_engine.delete(&self.doc, self.author, key).await
182    }
183
184    /// # Errors
185    ///
186    /// Returns an error if the folder ticket cannot be created.
187    pub async fn ticket(&self, writable: bool) -> Result<DocTicket> {
188        let can_write = writable && self.sync_mode.can_grant_write();
189        self.docs_engine.share_ticket(&self.doc, can_write).await
190    }
191
192    /// Create an unauthenticated ticket for a blob in this folder and pin it
193    /// while it is publicly shared.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the blob is unavailable or cannot be pinned.
198    pub async fn publish_blob(&self, endpoint: iroh::EndpointAddr, hash: Hash) -> Result<BlobTicket> {
199        if !self.blob_store.has(hash).await? {
200            return Err(SyncwebError::InvalidConfig(format!(
201                "cannot publish missing blob {hash}"
202            )));
203        }
204        let ticket = self.blob_store.ticket_for_addr(endpoint, hash);
205        self.blob_store
206            .pin(crate::pins::public_blob_pin(self.namespace_id, hash), hash)
207            .await?;
208        Ok(ticket)
209    }
210
211    /// Remove the public-sharing pin from a folder blob.
212    ///
213    /// Existing blob tickets are capabilities and remain usable while another
214    /// tag or active transfer retains the blob.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if the public-sharing pin cannot be removed.
219    pub async fn unpublish_blob(&self, hash: Hash) -> Result<()> {
220        self.blob_store
221            .unpin(crate::pins::public_blob_pin(self.namespace_id, hash))
222            .await
223    }
224
225    /// Pin every blob currently referenced by the folder's document so the
226    /// shared content is retained (not evicted by garbage collection).
227    ///
228    /// Returns the number of distinct blobs pinned.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if the folder's entries cannot be read or a pin fails.
233    pub async fn pin_all_content(&self) -> Result<usize> {
234        let entries = self.docs_engine.list_latest(&self.doc).await?;
235        let mut pinned = 0_usize;
236        for entry in entries {
237            let hash = entry.content_hash();
238            self.blob_store
239                .pin(crate::pins::public_blob_pin(self.namespace_id, hash), hash)
240                .await?;
241            pinned = pinned.saturating_add(1);
242        }
243        Ok(pinned)
244    }
245
246    /// Remove the retention pins placed by [`pin_all_content`](Self::pin_all_content)
247    /// for every blob currently referenced by the folder's document.
248    ///
249    /// Returns the number of pins removed.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if the folder's entries cannot be read or an unpin fails.
254    pub async fn unpin_all_content(&self) -> Result<usize> {
255        let entries = self.docs_engine.list_latest(&self.doc).await?;
256        let mut unpinned = 0_usize;
257        for entry in entries {
258            let hash = entry.content_hash();
259            self.blob_store
260                .unpin(crate::pins::public_blob_pin(self.namespace_id, hash))
261                .await?;
262            unpinned = unpinned.saturating_add(1);
263        }
264        Ok(unpinned)
265    }
266}
267
268#[async_trait]
269impl FolderLike for SyncwebFolder {
270    fn namespace_id(&self) -> String {
271        self.namespace_id.to_string()
272    }
273
274    fn label(&self) -> String {
275        self.namespace_id.to_string()
276    }
277
278    fn kind(&self) -> &'static str {
279        "folder"
280    }
281
282    fn path(&self) -> Option<&Path> {
283        None
284    }
285
286    async fn list_entries(&self) -> Result<Vec<EntryLike>> {
287        let entries = self.docs_engine.list_latest(&self.doc).await?;
288        let mut result = Vec::with_capacity(entries.len());
289        for entry in entries {
290            if entry.key().starts_with(b"sys/") {
291                continue;
292            }
293            let path = String::from_utf8_lossy(entry.key()).to_string();
294            result.push(EntryLike {
295                path,
296                hash: entry.content_hash(),
297                size: entry.content_len(),
298            });
299        }
300        Ok(result)
301    }
302}