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