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::{
7    AuthorId, DocTicket, NamespaceId,
8    api::{Doc, protocol::ShareMode},
9};
10use tokio::sync::RwLock;
11
12use crate::error::{Result, SyncwebError};
13use crate::node::{blob_store::BlobStore, docs_engine::DocsEngine};
14use crate::snapshot::{Snapshot, SnapshotDiff, SnapshotId, SnapshotStore};
15
16use super::SyncMode;
17use super::public_subscription::{EntryLike, FolderLike};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub enum Capability {
22    Admin,
23    Write,
24    Read,
25}
26
27impl Capability {
28    #[must_use]
29    pub const fn can_write(self) -> bool {
30        matches!(self, Self::Admin | Self::Write)
31    }
32}
33
34#[derive(Clone)]
35pub struct SyncwebFolder {
36    doc: Doc,
37    namespace_id: NamespaceId,
38    author: AuthorId,
39    blob_store: BlobStore,
40    docs_engine: DocsEngine,
41    sync_mode: SyncMode,
42    capabilities: Arc<RwLock<HashMap<PublicKey, Capability>>>,
43}
44
45impl SyncwebFolder {
46    #[must_use]
47    pub fn new(
48        doc: Doc,
49        author: AuthorId,
50        blob_store: BlobStore,
51        docs_engine: DocsEngine,
52        sync_mode: SyncMode,
53    ) -> Self {
54        let namespace_id = doc.id();
55        Self {
56            doc,
57            namespace_id,
58            author,
59            blob_store,
60            docs_engine,
61            sync_mode,
62            capabilities: Arc::new(RwLock::new(HashMap::new())),
63        }
64    }
65
66    /// Create a new folder by allocating a namespace in the docs engine.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the namespace cannot be created or the default author cannot be retrieved.
71    pub async fn create(docs_engine: DocsEngine, blob_store: BlobStore, sync_mode: SyncMode) -> Result<Self> {
72        let doc = docs_engine
73            .create_namespace()
74            .await
75            .map_err(|error| SyncwebError::operation("failed to create folder namespace", error))?;
76        let author = docs_engine
77            .author()
78            .await
79            .map_err(|error| SyncwebError::operation("failed to retrieve folder author", error))?;
80        Ok(Self::new(doc, author, blob_store, docs_engine, sync_mode))
81    }
82
83    /// Accept a locally available folder by namespace ID.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the namespace cannot be opened.
88    pub async fn accept(docs_engine: DocsEngine, blob_store: BlobStore, namespace_id: NamespaceId) -> Result<Self> {
89        let doc = docs_engine
90            .open(namespace_id)
91            .await
92            .map_err(|error| SyncwebError::operation("failed to open folder namespace", error))?
93            .ok_or(SyncwebError::NamespaceNotAvailable)?;
94        let author = docs_engine
95            .author()
96            .await
97            .map_err(|error| SyncwebError::operation("failed to retrieve folder author", error))?;
98        Ok(Self::new(doc, author, blob_store, docs_engine, SyncMode::ReceiveOnly))
99    }
100
101    /// Drop this folder's namespace from the docs engine.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the namespace cannot be dropped.
106    pub async fn drop_namespace(&self) -> Result<()> {
107        self.docs_engine
108            .drop_namespace(self.namespace_id)
109            .await
110            .map_err(|error| SyncwebError::operation("failed to drop folder namespace", error))
111    }
112
113    #[must_use]
114    pub const fn namespace_id(&self) -> NamespaceId {
115        self.namespace_id
116    }
117
118    #[must_use]
119    pub const fn author(&self) -> AuthorId {
120        self.author
121    }
122
123    #[must_use]
124    pub const fn mode(&self) -> SyncMode {
125        self.sync_mode
126    }
127
128    #[must_use]
129    pub const fn doc(&self) -> &Doc {
130        &self.doc
131    }
132
133    #[must_use]
134    pub const fn docs_engine(&self) -> &DocsEngine {
135        &self.docs_engine
136    }
137
138    pub async fn grant(&self, node_id: PublicKey, capability: Capability) {
139        self.capabilities.write().await.insert(node_id, capability);
140    }
141
142    pub async fn capability(&self, node_id: PublicKey) -> Option<Capability> {
143        self.capabilities.read().await.get(&node_id).copied()
144    }
145
146    pub async fn can_write_as(&self, node_id: PublicKey) -> bool {
147        self.sync_mode.can_write_locally() && self.capability(node_id).await.is_some_and(Capability::can_write)
148    }
149
150    /// Check whether a blob is complete in this folder's local store.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the blob store cannot be queried.
155    pub async fn has_local(&self, hash: Hash) -> Result<bool> {
156        self.blob_store.has(hash).await
157    }
158
159    /// Create a content-addressed snapshot of this folder.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if document entries cannot be read or referenced blobs
164    /// are unavailable.
165    pub async fn create_snapshot(&self, description: Option<String>) -> Result<Snapshot> {
166        SnapshotStore::with_docs(self.blob_store.clone(), self.docs_engine.clone())
167            .create_for_folder(self, description)
168            .await
169    }
170
171    /// Restore this folder's document entries from a snapshot.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the snapshot belongs to another folder or content
176    /// is unavailable.
177    pub async fn restore_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
178        SnapshotStore::with_docs(self.blob_store.clone(), self.docs_engine.clone())
179            .restore_for_folder(self, snapshot)
180            .await
181    }
182
183    /// List snapshots stored in the local blob store.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if snapshot manifests cannot be read.
188    pub async fn list_snapshots(&self) -> Result<Vec<Snapshot>> {
189        SnapshotStore::with_docs(self.blob_store.clone(), self.docs_engine.clone())
190            .list()
191            .await
192    }
193
194    /// Delete a snapshot and release its pins.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if the snapshot cannot be found or pins cannot be released.
199    pub async fn delete_snapshot(&self, snapshot_id: SnapshotId) -> Result<()> {
200        SnapshotStore::with_docs(self.blob_store.clone(), self.docs_engine.clone())
201            .delete(snapshot_id)
202            .await
203    }
204
205    /// Compare two snapshots.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if either snapshot is invalid.
210    pub fn diff_snapshots(&self, first: &Snapshot, second: &Snapshot) -> Result<SnapshotDiff> {
211        first.diff(second)
212    }
213
214    /// # Errors
215    ///
216    /// Returns an error if the blob fails to be stored or set.
217    pub async fn set_blob(&self, key: impl AsRef<[u8]>, value: impl AsRef<[u8]>) -> Result<Hash> {
218        if !self.sync_mode.can_write_locally() {
219            return Err(SyncwebError::WriteDenied {
220                mode: self.sync_mode.to_string(),
221            });
222        }
223        let value_bytes = value.as_ref();
224        let hash = self.blob_store.add_bytes(value_bytes).await?;
225        let len = u64::try_from(value_bytes.len())
226            .map_err(|error| SyncwebError::operation("blob size exceeds u64::MAX", error))?;
227        self.docs_engine
228            .set_blob(&self.doc, self.author, key, hash, len)
229            .await?;
230        Ok(hash)
231    }
232
233    /// Store an existing blob reference in this folder.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the folder is read-only, the blob is unavailable, or
238    /// the document entry cannot be written.
239    pub async fn set_blob_ref(&self, key: impl AsRef<[u8]>, hash: Hash, size: u64) -> Result<()> {
240        if !self.sync_mode.can_write_locally() {
241            return Err(SyncwebError::WriteDenied {
242                mode: self.sync_mode.to_string(),
243            });
244        }
245        self.docs_engine.set_blob(&self.doc, self.author, key, hash, size).await
246    }
247
248    /// Delete a folder entry.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if the folder is read-only or the document entry cannot be deleted.
253    pub async fn delete_entry(&self, key: impl AsRef<[u8]>) -> Result<()> {
254        if !self.sync_mode.can_write_locally() {
255            return Err(SyncwebError::WriteDenied {
256                mode: self.sync_mode.to_string(),
257            });
258        }
259        self.docs_engine.delete(&self.doc, self.author, key).await
260    }
261
262    /// # Errors
263    ///
264    /// Returns an error if the folder ticket cannot be created.
265    pub async fn ticket(&self, endpoint: iroh::EndpointAddr, writable: bool) -> Result<DocTicket> {
266        let mode = if writable && self.sync_mode.can_grant_write() {
267            ShareMode::Write
268        } else {
269            ShareMode::Read
270        };
271        self.docs_engine.share(&self.doc, mode, endpoint).await
272    }
273
274    /// Create an unauthenticated ticket for a blob in this folder and pin it
275    /// while it is publicly shared.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the blob is unavailable or cannot be pinned.
280    pub async fn publish_blob(&self, endpoint: iroh::EndpointAddr, hash: Hash) -> Result<BlobTicket> {
281        if !self.blob_store.has(hash).await? {
282            return Err(SyncwebError::InvalidConfig(format!(
283                "cannot publish missing blob {hash}"
284            )));
285        }
286        let ticket = self.blob_store.ticket_for_addr(endpoint, hash);
287        self.blob_store
288            .pin(public_pin_name(self.namespace_id, hash), hash)
289            .await?;
290        Ok(ticket)
291    }
292
293    /// Remove the public-sharing pin from a folder blob.
294    ///
295    /// Existing blob tickets are capabilities and remain usable while another
296    /// tag or active transfer retains the blob.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the public-sharing pin cannot be removed.
301    pub async fn unpublish_blob(&self, hash: Hash) -> Result<()> {
302        self.blob_store.unpin(public_pin_name(self.namespace_id, hash)).await
303    }
304}
305
306#[async_trait]
307impl FolderLike for SyncwebFolder {
308    fn namespace_id(&self) -> String {
309        self.namespace_id.to_string()
310    }
311
312    fn label(&self) -> String {
313        self.namespace_id.to_string()
314    }
315
316    fn kind(&self) -> &'static str {
317        "folder"
318    }
319
320    fn path(&self) -> Option<&Path> {
321        None
322    }
323
324    async fn list_entries(&self) -> Result<Vec<EntryLike>> {
325        let entries = self.docs_engine.list_latest(&self.doc).await?;
326        let mut result = Vec::with_capacity(entries.len());
327        for entry in entries {
328            if entry.key().starts_with(b"sys/") {
329                continue;
330            }
331            let path = String::from_utf8_lossy(entry.key()).to_string();
332            result.push(EntryLike {
333                path,
334                hash: entry.content_hash(),
335                size: entry.content_len(),
336            });
337        }
338        Ok(result)
339    }
340}
341
342fn public_pin_name(namespace_id: NamespaceId, hash: Hash) -> String {
343    format!("syncweb/public/{namespace_id}/{hash}")
344}