use std::{
collections::{HashMap, HashSet, hash_map::Entry},
sync::Arc,
};
use iroh::PublicKey;
use iroh_blobs::ticket::BlobTicket;
use iroh_docs::{DocTicket, NamespaceId, api::Doc};
use n0_future::StreamExt;
use tokio::sync::RwLock;
use crate::constants::{MODE_KEY, NETWORK_MEMBERS_KEY};
use crate::error::{Result, SyncwebError};
use crate::node::discovery::TopicTracker;
use crate::node::iroh_node::IrohNode;
use super::{Capability, SyncMode, SyncwebFolder};
#[derive(Clone)]
pub struct FolderManager {
endpoint: iroh::Endpoint,
node_id: PublicKey,
blob_store: crate::node::blob_store::BlobStore,
docs_engine: crate::node::docs_engine::DocsEngine,
folders: Arc<RwLock<HashMap<NamespaceId, SyncwebFolder>>>,
subscriptions: Arc<RwLock<HashSet<iroh_blobs::Hash>>>,
topic_tracker: TopicTracker,
announced: Arc<RwLock<HashSet<NamespaceId>>>,
}
impl FolderManager {
#[must_use]
pub fn new(node: &IrohNode) -> Self {
Self {
endpoint: node.endpoint().clone(),
node_id: node.endpoint().id(),
blob_store: node.blob_store().clone(),
docs_engine: node.docs_engine().clone(),
folders: Arc::new(RwLock::new(HashMap::new())),
subscriptions: Arc::new(RwLock::new(HashSet::new())),
topic_tracker: node.topic_tracker().clone(),
announced: Arc::new(RwLock::new(HashSet::new())),
}
}
pub async fn create(&self, mode: SyncMode) -> Result<SyncwebFolder> {
let provisioned = self
.docs_engine
.provision(crate::node::docs_engine::ProvisionKind::Folder, None, mode.to_string())
.await?;
let author = self.docs_engine.author().await?;
let folder = SyncwebFolder::new(
provisioned.doc,
author,
self.blob_store.clone(),
self.docs_engine.clone(),
mode,
);
folder.grant(self.node_id, Capability::Admin).await;
self.folders.write().await.insert(folder.namespace_id(), folder.clone());
self.announce_namespace(folder.namespace_id()).await;
Ok(folder)
}
pub async fn join(&self, ticket_str: impl AsRef<str>, mode: SyncMode) -> Result<SyncwebFolder> {
self.join_with_options(ticket_str, mode, false).await
}
pub async fn join_with_options(
&self,
ticket_str: impl AsRef<str>,
mode: SyncMode,
metadata_only: bool,
) -> Result<SyncwebFolder> {
let ticket = crate::uri::parse_folder_ticket(ticket_str.as_ref())?;
let doc = self.docs_engine.import_ticket(ticket).await?;
let folder = self.folder_from_doc(doc, mode).await?;
if metadata_only {
folder.set_metadata_only().await?;
}
self.folders.write().await.insert(folder.namespace_id(), folder.clone());
self.announce_namespace(folder.namespace_id()).await;
Ok(folder)
}
pub async fn subscribe_public(&self, ticket: &BlobTicket) -> Result<iroh_blobs::Hash> {
let hash = ticket.hash();
self.blob_store.fetch(&self.endpoint, ticket).await?;
self.subscriptions.write().await.insert(hash);
Ok(hash)
}
pub async fn accept(&self, namespace_id: NamespaceId) -> Result<SyncwebFolder> {
let existing_folder = self.folders.read().await.get(&namespace_id).cloned();
if let Some(folder) = existing_folder {
return Ok(folder);
}
let (doc, _ticket) = self.docs_engine.create_or_open_namespace(Some(namespace_id)).await?;
let folder = self.folder_from_doc(doc, SyncMode::ReceiveOnly).await?;
self.folders.write().await.insert(namespace_id, folder.clone());
self.announce_namespace(namespace_id).await;
Ok(folder)
}
pub async fn announce_namespace(&self, namespace_id: NamespaceId) {
if self.announced.read().await.contains(&namespace_id) {
return;
}
if let Err(error) = self.topic_tracker.announce(namespace_id).await {
tracing::warn!(%namespace_id, %error, "failed to announce folder namespace");
} else {
self.announced.write().await.insert(namespace_id);
}
}
#[must_use]
pub async fn subscriptions(&self) -> Vec<iroh_blobs::Hash> {
self.subscriptions.read().await.iter().copied().collect()
}
pub async fn seed_subscriptions(&self, hashes: impl IntoIterator<Item = iroh_blobs::Hash>) {
self.subscriptions.write().await.extend(hashes);
}
pub async fn drop_subscription(&self, hash: &iroh_blobs::Hash) {
self.subscriptions.write().await.remove(hash);
}
#[must_use]
pub async fn has_subscription(&self, hash: &iroh_blobs::Hash) -> bool {
self.subscriptions.read().await.contains(hash)
}
pub async fn drop(&self, namespace_id: NamespaceId) -> Result<()> {
self.docs_engine.drop_namespace(namespace_id).await?;
self.folders.write().await.remove(&namespace_id);
Ok(())
}
pub async fn drop_when_ready(&self, namespace_id: NamespaceId) -> Result<()> {
const MAX_ATTEMPTS: u32 = 50;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
let mut attempts = 0_u32;
loop {
match self.drop(namespace_id).await {
Err(error) if attempts < MAX_ATTEMPTS && error.to_string().contains("replica is not closed") => {
attempts = attempts.saturating_add(1);
tokio::time::sleep(RETRY_DELAY).await;
}
result => return result,
}
}
}
pub async fn delete_folder_files(path: &std::path::Path) -> Result<()> {
let canonical = std::fs::canonicalize(path)
.map_err(|error| SyncwebError::operation("failed to resolve folder path for deletion", error))?;
let current = std::env::current_dir()
.map_err(|error| SyncwebError::operation("failed to resolve current directory", error))?;
if canonical.as_os_str().is_empty()
|| canonical.parent().is_none_or(|parent| parent.as_os_str().is_empty())
|| canonical == current
{
return Err(SyncwebError::operation(
"refusing to delete files at a filesystem root or the current directory",
"unsafe folder path",
));
}
let metadata = tokio::fs::symlink_metadata(&canonical).await?;
if metadata.is_dir() {
tokio::fs::remove_dir_all(&canonical).await?;
} else if metadata.is_file() {
tokio::fs::remove_file(&canonical).await?;
} else {
return Err(SyncwebError::operation(
"refusing to delete a non-file, non-directory path",
"unsupported folder path",
));
}
Ok(())
}
pub async fn list(&self) -> Result<Vec<SyncwebFolder>> {
let mut documents = self
.docs_engine
.inner()
.list()
.await
.map_err(|error| SyncwebError::operation("failed to list documents", error))?;
let mut listed = Vec::new();
while let Some(document) = documents.next().await {
listed.push(document.map_err(|error| SyncwebError::operation("failed to read document list", error))?);
}
for (namespace_id, capability) in listed {
if self.folders.read().await.contains_key(&namespace_id) {
continue;
}
let doc = self
.docs_engine
.open(namespace_id)
.await?
.ok_or(SyncwebError::NamespaceNotAvailable)?;
if self.docs_engine.get_any(&doc, NETWORK_MEMBERS_KEY).await?.is_some() {
continue;
}
let fallback_mode = match capability {
iroh_docs::CapabilityKind::Write => SyncMode::SendReceive,
iroh_docs::CapabilityKind::Read => SyncMode::ReceiveOnly,
};
let mode = self.mode_from_doc(&doc, fallback_mode).await?;
let folder = self.folder_from_doc(doc, mode).await?;
if let Entry::Vacant(entry) = self.folders.write().await.entry(namespace_id) {
entry.insert(folder);
}
}
Ok(self.folders.read().await.values().cloned().collect())
}
pub async fn get(&self, namespace_id: NamespaceId) -> Result<SyncwebFolder> {
let existing = self.folders.read().await.get(&namespace_id).cloned();
if let Some(folder) = existing {
return Ok(folder);
}
self.list()
.await?
.into_iter()
.find(|folder| folder.namespace_id() == namespace_id)
.ok_or_else(|| SyncwebError::FolderNotFound(namespace_id.to_string()))
}
pub async fn resolve(&self, selector: &std::path::Path) -> Result<SyncwebFolder> {
let namespace_id = self.resolve_namespace(&selector.to_string_lossy()).await?;
self.get(namespace_id).await
}
pub async fn resolve_namespace(&self, selector: &str) -> Result<NamespaceId> {
if let Ok(namespace_id) = selector.parse() {
return Ok(namespace_id);
}
let folders = self.list().await?;
match folders.as_slice() {
[folder] => Ok(folder.namespace_id()),
[] => Err(SyncwebError::NoFolders),
_ => Err(SyncwebError::AmbiguousFolderSelector(selector.to_owned())),
}
}
pub async fn ticket(&self, namespace_id: NamespaceId, writable: bool) -> Result<DocTicket> {
let folder = self
.folders
.read()
.await
.get(&namespace_id)
.cloned()
.ok_or(SyncwebError::FolderNotFound(namespace_id.to_string()))?;
folder.ticket(writable).await
}
async fn folder_from_doc(&self, doc: Doc, mode: SyncMode) -> Result<SyncwebFolder> {
Ok(SyncwebFolder::new(
doc,
self.docs_engine.author().await?,
self.blob_store.clone(),
self.docs_engine.clone(),
mode,
))
}
async fn mode_from_doc(&self, doc: &Doc, fallback: SyncMode) -> Result<SyncMode> {
let author = self.docs_engine.author().await?;
let Some(entry) = self.docs_engine.get(doc, author, MODE_KEY).await? else {
return Ok(fallback);
};
let mode_bytes = self.blob_store.get(entry.content_hash()).await?;
let mode_value = std::str::from_utf8(&mode_bytes)
.map_err(|error| SyncwebError::operation("folder mode metadata is not UTF-8", error))?;
mode_value.parse()
}
}