1use std::{
2 collections::{HashMap, HashSet, hash_map::Entry},
3 sync::Arc,
4};
5
6use iroh::PublicKey;
7use iroh_blobs::ticket::BlobTicket;
8use iroh_docs::{DocTicket, NamespaceId, api::Doc};
9use n0_future::StreamExt;
10use tokio::sync::RwLock;
11
12use crate::constants::{MODE_KEY, NETWORK_MEMBERS_KEY};
13use crate::error::{Result, SyncwebError};
14use crate::node::discovery::TopicTracker;
15use crate::node::iroh_node::IrohNode;
16
17use super::{Capability, SyncMode, SyncwebFolder};
18
19#[derive(Clone)]
20pub struct FolderManager {
21 endpoint: iroh::Endpoint,
22 node_id: PublicKey,
23 blob_store: crate::node::blob_store::BlobStore,
24 docs_engine: crate::node::docs_engine::DocsEngine,
25 folders: Arc<RwLock<HashMap<NamespaceId, SyncwebFolder>>>,
26 subscriptions: Arc<RwLock<HashSet<iroh_blobs::Hash>>>,
27 topic_tracker: TopicTracker,
28 announced: Arc<RwLock<HashSet<NamespaceId>>>,
29}
30
31impl FolderManager {
32 #[must_use]
33 pub fn new(node: &IrohNode) -> Self {
34 Self {
35 endpoint: node.endpoint().clone(),
36 node_id: node.endpoint().id(),
37 blob_store: node.blob_store().clone(),
38 docs_engine: node.docs_engine().clone(),
39 folders: Arc::new(RwLock::new(HashMap::new())),
40 subscriptions: Arc::new(RwLock::new(HashSet::new())),
41 topic_tracker: node.topic_tracker().clone(),
42 announced: Arc::new(RwLock::new(HashSet::new())),
43 }
44 }
45
46 pub async fn create(&self, mode: SyncMode) -> Result<SyncwebFolder> {
50 let provisioned = self
51 .docs_engine
52 .provision(crate::node::docs_engine::ProvisionKind::Folder, None, mode.to_string())
53 .await?;
54 let author = self.docs_engine.author().await?;
55 let folder = SyncwebFolder::new(
56 provisioned.doc,
57 author,
58 self.blob_store.clone(),
59 self.docs_engine.clone(),
60 mode,
61 true,
62 );
63 folder.grant(self.node_id, Capability::Admin).await;
64 self.folders.write().await.insert(folder.namespace_id(), folder.clone());
65 self.announce_namespace(folder.namespace_id()).await;
66 Ok(folder)
67 }
68
69 pub async fn join(&self, ticket_str: impl AsRef<str>, mode: SyncMode) -> Result<SyncwebFolder> {
73 self.join_with_options(ticket_str, mode, false).await
74 }
75
76 pub async fn join_with_options(
87 &self,
88 ticket_str: impl AsRef<str>,
89 mode: SyncMode,
90 metadata_only: bool,
91 ) -> Result<SyncwebFolder> {
92 let ticket = crate::uri::parse_folder_ticket(ticket_str.as_ref())?;
93 let writable = matches!(ticket.capability.kind(), iroh_docs::CapabilityKind::Write);
94 for node in &ticket.nodes {
98 self.blob_store.add_endpoint_addr(node.clone());
99 }
100 let doc = self.docs_engine.import_ticket(ticket).await?;
101 let folder = self.folder_from_doc(doc, mode, writable).await?;
102 if metadata_only {
103 folder.set_metadata_only().await?;
104 }
105 self.folders.write().await.insert(folder.namespace_id(), folder.clone());
106 self.announce_namespace(folder.namespace_id()).await;
107 Ok(folder)
108 }
109
110 pub async fn subscribe_public(&self, ticket: &BlobTicket) -> Result<iroh_blobs::Hash> {
118 let hash = ticket.hash();
119 self.blob_store.fetch(&self.endpoint, ticket).await?;
120 self.subscriptions.write().await.insert(hash);
121 Ok(hash)
122 }
123
124 pub async fn accept(&self, namespace_id: NamespaceId) -> Result<SyncwebFolder> {
128 let existing_folder = self.folders.read().await.get(&namespace_id).cloned();
129 if let Some(folder) = existing_folder {
130 return Ok(folder);
131 }
132 let (doc, ticket) = self.docs_engine.create_or_open_namespace(Some(namespace_id)).await?;
133 let writable = matches!(ticket.capability.kind(), iroh_docs::CapabilityKind::Write);
134 let folder = self.folder_from_doc(doc, SyncMode::ReceiveOnly, writable).await?;
135 self.folders.write().await.insert(namespace_id, folder.clone());
136 self.announce_namespace(namespace_id).await;
137 Ok(folder)
138 }
139
140 pub async fn announce_namespace(&self, namespace_id: NamespaceId) {
143 if self.announced.read().await.contains(&namespace_id) {
144 return;
145 }
146 if let Err(error) = self.topic_tracker.announce(namespace_id).await {
147 tracing::warn!(%namespace_id, %error, "failed to announce folder namespace");
148 } else {
149 self.announced.write().await.insert(namespace_id);
150 }
151 }
152
153 #[must_use]
155 pub async fn subscriptions(&self) -> Vec<iroh_blobs::Hash> {
156 self.subscriptions.read().await.iter().copied().collect()
157 }
158
159 pub async fn seed_subscriptions(&self, hashes: impl IntoIterator<Item = iroh_blobs::Hash>) {
162 self.subscriptions.write().await.extend(hashes);
163 }
164
165 pub async fn drop_subscription(&self, hash: &iroh_blobs::Hash) {
167 self.subscriptions.write().await.remove(hash);
168 }
169
170 #[must_use]
172 pub async fn has_subscription(&self, hash: &iroh_blobs::Hash) -> bool {
173 self.subscriptions.read().await.contains(hash)
174 }
175
176 pub async fn drop(&self, namespace_id: NamespaceId) -> Result<()> {
180 self.docs_engine.drop_namespace(namespace_id).await?;
181 self.folders.write().await.remove(&namespace_id);
182 Ok(())
183 }
184
185 pub async fn drop_when_ready(&self, namespace_id: NamespaceId) -> Result<()> {
192 const MAX_ATTEMPTS: u32 = 50;
193 const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
194 let mut attempts = 0_u32;
195 loop {
196 match self.drop(namespace_id).await {
197 Err(error) if attempts < MAX_ATTEMPTS && error.to_string().contains("replica is not closed") => {
198 attempts = attempts.saturating_add(1);
199 tokio::time::sleep(RETRY_DELAY).await;
200 }
201 result => return result,
202 }
203 }
204 }
205
206 pub async fn delete_folder_files(path: &std::path::Path) -> Result<()> {
216 let canonical = std::fs::canonicalize(path)
217 .map_err(|error| SyncwebError::operation("failed to resolve folder path for deletion", error))?;
218 let current = std::env::current_dir()
219 .map_err(|error| SyncwebError::operation("failed to resolve current directory", error))?;
220 if canonical.as_os_str().is_empty()
221 || canonical.parent().is_none_or(|parent| parent.as_os_str().is_empty())
222 || canonical == current
223 {
224 return Err(SyncwebError::operation(
225 "refusing to delete files at a filesystem root or the current directory",
226 "unsafe folder path",
227 ));
228 }
229 let metadata = tokio::fs::symlink_metadata(&canonical).await?;
230 if metadata.is_dir() {
231 tokio::fs::remove_dir_all(&canonical).await?;
232 } else if metadata.is_file() {
233 tokio::fs::remove_file(&canonical).await?;
234 } else {
235 return Err(SyncwebError::operation(
236 "refusing to delete a non-file, non-directory path",
237 "unsupported folder path",
238 ));
239 }
240 Ok(())
241 }
242
243 pub async fn list(&self) -> Result<Vec<SyncwebFolder>> {
247 let mut documents = self
248 .docs_engine
249 .inner()
250 .list()
251 .await
252 .map_err(|error| SyncwebError::operation("failed to list documents", error))?;
253 let mut listed = Vec::new();
254 while let Some(document) = documents.next().await {
255 listed.push(document.map_err(|error| SyncwebError::operation("failed to read document list", error))?);
256 }
257 for (namespace_id, capability) in listed {
258 if self.folders.read().await.contains_key(&namespace_id) {
259 continue;
260 }
261 let doc = self
262 .docs_engine
263 .open(namespace_id)
264 .await?
265 .ok_or(SyncwebError::NamespaceNotAvailable)?;
266 if self.docs_engine.get_any(&doc, NETWORK_MEMBERS_KEY).await?.is_some() {
267 continue;
268 }
269 let writable = matches!(capability, iroh_docs::CapabilityKind::Write);
270 let fallback_mode = match capability {
271 iroh_docs::CapabilityKind::Write => SyncMode::SendReceive,
272 iroh_docs::CapabilityKind::Read => SyncMode::ReceiveOnly,
273 };
274 let mode = self.mode_from_doc(&doc, fallback_mode).await?;
275 let folder = self.folder_from_doc(doc, mode, writable).await?;
276 if let Entry::Vacant(entry) = self.folders.write().await.entry(namespace_id) {
277 entry.insert(folder);
278 }
279 }
280 Ok(self.folders.read().await.values().cloned().collect())
281 }
282
283 pub async fn get(&self, namespace_id: NamespaceId) -> Result<SyncwebFolder> {
289 let existing = self.folders.read().await.get(&namespace_id).cloned();
290 if let Some(folder) = existing {
291 return Ok(folder);
292 }
293 self.list()
294 .await?
295 .into_iter()
296 .find(|folder| folder.namespace_id() == namespace_id)
297 .ok_or_else(|| SyncwebError::FolderNotFound(namespace_id.to_string()))
298 }
299
300 pub async fn resolve(&self, selector: &std::path::Path) -> Result<SyncwebFolder> {
309 let namespace_id = self.resolve_namespace(&selector.to_string_lossy()).await?;
310 self.get(namespace_id).await
311 }
312
313 pub async fn resolve_namespace(&self, selector: &str) -> Result<NamespaceId> {
320 if let Ok(namespace_id) = selector.parse() {
321 return Ok(namespace_id);
322 }
323 let folders = self.list().await?;
324 match folders.as_slice() {
325 [folder] => Ok(folder.namespace_id()),
326 [] => Err(SyncwebError::NoFolders),
327 _ => Err(SyncwebError::AmbiguousFolderSelector(selector.to_owned())),
328 }
329 }
330
331 pub async fn ticket(&self, namespace_id: NamespaceId, writable: bool) -> Result<DocTicket> {
335 self.ticket_with_options(
336 namespace_id,
337 writable,
338 iroh_docs::api::protocol::AddrInfoOptions::RelayAndAddresses,
339 )
340 .await
341 }
342
343 pub async fn ticket_with_options(
347 &self,
348 namespace_id: NamespaceId,
349 writable: bool,
350 addr_options: iroh_docs::api::protocol::AddrInfoOptions,
351 ) -> Result<DocTicket> {
352 let folder = self
353 .folders
354 .read()
355 .await
356 .get(&namespace_id)
357 .cloned()
358 .ok_or(SyncwebError::FolderNotFound(namespace_id.to_string()))?;
359 folder.ticket_with_options(writable, addr_options).await
360 }
361
362 async fn folder_from_doc(&self, doc: Doc, mode: SyncMode, writable: bool) -> Result<SyncwebFolder> {
363 Ok(SyncwebFolder::new(
364 doc,
365 self.docs_engine.author().await?,
366 self.blob_store.clone(),
367 self.docs_engine.clone(),
368 mode,
369 writable,
370 ))
371 }
372
373 async fn mode_from_doc(&self, doc: &Doc, fallback: SyncMode) -> Result<SyncMode> {
374 let author = self.docs_engine.author().await?;
375 let Some(entry) = self.docs_engine.get(doc, author, MODE_KEY).await? else {
376 return Ok(fallback);
377 };
378 let mode_hash = entry.content_hash();
379 let mode_bytes = match self.blob_store.get(mode_hash).await {
380 Ok(bytes) => bytes,
381 Err(error) => {
382 tracing::warn!(
383 %mode_hash,
384 %error,
385 fallback = %fallback,
386 "folder mode metadata is unavailable; using capability-derived mode"
387 );
388 return Ok(fallback);
389 }
390 };
391 let mode_value = std::str::from_utf8(&mode_bytes)
392 .map_err(|error| SyncwebError::operation("folder mode metadata is not UTF-8", error))?;
393 mode_value.parse()
394 }
395}