d8229650ef
Mounts are now browsable and downloadable over both WebDAV surfaces (/webdav/ and NextCloud /remote.php/dav) and NextCloud clients. The work funnels through the path-based service methods all three surfaces already use, so both WebDAV handlers gain mount support with no handler changes. - get_folder_by_path / get_file_by_path resolve a path that descends past a mount root to a synthetic ext: DTO (the mount root itself stays a real row) - list_folders_paginated_with_perms / list_files_batch_with_perms branch to the provider, so PROPFIND Depth:1 enumerates mount directory contents in both WebDAV handlers - get_file_stream / get_file_range_stream branch ext: file ids to the provider, so WebDAV GET streams mount content (Pin<Box<dyn Stream>> re-boxed) - router injected into FileRetrievalService; new MountRouter::find_path delegates to the registry's (drive_id, mount_path) index - WebDAV mkdir/delete/move/rename by path work via path→ext:id→the P2 service methods (no extra wiring) Fixes a leading-slash normalization bug in the registry path index: materialized folder paths arrive both as `Personal/Media` and `/Personal/Media`; keys and lookups now normalize the leading slash (would have broken real WebDAV paths). Known follow-up: WebDAV PUT (upload/update by path) still ingests to the CAS; streaming a WebDAV PUT straight to the provider needs a pre-ingest branch in the two WebDAV PUT handlers (mirrors the REST upload branch). Integration test: get_folder_by_path/get_file_by_path resolution, PROPFIND Depth:1 folder+file listing, and content streaming on a real mount + Postgres.
193 lines
6.7 KiB
Rust
193 lines
6.7 KiB
Rust
//! Classifies file/folder ids into native vs. external-mount handling.
|
|
//!
|
|
//! This is the single, cheap hook the service layer calls before any
|
|
//! `Uuid::parse_str`, so synthetic `ext:` ids and mount-root UUIDs branch to the
|
|
//! provider while everything else flows to the PostgreSQL repositories unchanged.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use uuid::Uuid;
|
|
|
|
use crate::application::services::mount_registry::{MountConfig, MountRegistry};
|
|
use crate::domain::services::external_mount_id::{NodeId, is_external_id, parse_child_id};
|
|
|
|
/// The result of classifying an id.
|
|
pub enum ResolvedId {
|
|
/// Plain native resource (UUID not registered as a mount root, or an
|
|
/// unrecognized id). Handle exactly as today.
|
|
Regular,
|
|
/// A real UUID that IS a mount root. Listing/metadata branch to the provider;
|
|
/// the row itself still exists natively.
|
|
MountRoot { cfg: Arc<MountConfig> },
|
|
/// A synthetic id addressing an entry inside a mount.
|
|
MountChild {
|
|
cfg: Arc<MountConfig>,
|
|
node_id: NodeId,
|
|
},
|
|
}
|
|
|
|
/// Thin, cloneable classifier over the mount registry.
|
|
#[derive(Clone)]
|
|
pub struct MountRouter {
|
|
registry: Arc<MountRegistry>,
|
|
}
|
|
|
|
impl MountRouter {
|
|
/// Construct from the shared registry.
|
|
pub fn new(registry: Arc<MountRegistry>) -> Self {
|
|
Self { registry }
|
|
}
|
|
|
|
/// Borrow the underlying registry (for path-based resolution / admin reload).
|
|
pub fn registry(&self) -> &Arc<MountRegistry> {
|
|
&self.registry
|
|
}
|
|
|
|
/// Fast path: are there no mounts at all? Lets callers skip classification.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.registry.is_empty()
|
|
}
|
|
|
|
/// Classify an id. Never parses a provider `node_id` — only the envelope.
|
|
pub fn classify(&self, id: &str) -> ResolvedId {
|
|
if is_external_id(id) {
|
|
if let Some(child) = parse_child_id(id)
|
|
&& let Some(cfg) = self.registry.get(&child.mount_id)
|
|
{
|
|
return ResolvedId::MountChild {
|
|
cfg,
|
|
node_id: child.node_id,
|
|
};
|
|
}
|
|
// Malformed or dangling `ext:` id — fall through to Regular so it
|
|
// surfaces a clean NotFound downstream rather than hitting the repos.
|
|
return ResolvedId::Regular;
|
|
}
|
|
if let Ok(uuid) = Uuid::parse_str(id)
|
|
&& let Some(cfg) = self.registry.get(&uuid)
|
|
{
|
|
return ResolvedId::MountRoot { cfg };
|
|
}
|
|
ResolvedId::Regular
|
|
}
|
|
|
|
/// True when `id` addresses anything inside a mount (root or child).
|
|
pub fn is_mount_id(&self, id: &str) -> bool {
|
|
matches!(
|
|
self.classify(id),
|
|
ResolvedId::MountRoot { .. } | ResolvedId::MountChild { .. }
|
|
)
|
|
}
|
|
|
|
/// Path-based lookup for the protocol surfaces (WebDAV / NextCloud): does
|
|
/// `internal_path` descend into a mount within `drive_id`? Returns the mount
|
|
/// config plus the remainder relpath (empty when the path IS the mount root).
|
|
pub fn find_path(
|
|
&self,
|
|
drive_id: uuid::Uuid,
|
|
internal_path: &str,
|
|
) -> Option<(Arc<MountConfig>, String)> {
|
|
self.registry.find_mount_for_path(drive_id, internal_path)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::application::ports::external_mount_ports::{
|
|
ExternalMountRecord, ExternalMountRepositoryPort,
|
|
};
|
|
use crate::domain::errors::DomainError;
|
|
use crate::domain::services::external_mount_id::encode_child_id;
|
|
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
|
|
use async_trait::async_trait;
|
|
use tempfile::TempDir;
|
|
|
|
struct FakeRepo(Vec<ExternalMountRecord>);
|
|
#[async_trait]
|
|
impl ExternalMountRepositoryPort for FakeRepo {
|
|
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError> {
|
|
Ok(self.0.clone())
|
|
}
|
|
}
|
|
|
|
async fn router_with_mount(mount_id: Uuid, dir: &TempDir) -> MountRouter {
|
|
let repo = FakeRepo(vec![ExternalMountRecord {
|
|
mount_folder_id: mount_id,
|
|
kind: "local_fs".to_string(),
|
|
config: serde_json::json!({ "path": dir.path().to_str().unwrap() }),
|
|
name: "M".to_string(),
|
|
owner_id: Uuid::new_v4(),
|
|
read_only: false,
|
|
drive_id: Uuid::new_v4(),
|
|
mount_path: "Personal/M".to_string(),
|
|
}]);
|
|
let reg = Arc::new(MountRegistry::empty());
|
|
reg.reload(&repo, &DefaultMountProviderFactory::new()).await;
|
|
MountRouter::new(reg)
|
|
}
|
|
|
|
#[test]
|
|
fn empty_registry_classifies_everything_regular() {
|
|
let router = MountRouter::new(Arc::new(MountRegistry::empty()));
|
|
assert!(router.is_empty());
|
|
assert!(matches!(
|
|
router.classify(&Uuid::new_v4().to_string()),
|
|
ResolvedId::Regular
|
|
));
|
|
assert!(matches!(
|
|
router.classify("ext:deadbeef:dG9rZW4"),
|
|
ResolvedId::Regular
|
|
));
|
|
assert!(matches!(router.classify("garbage"), ResolvedId::Regular));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn classifies_mount_root_uuid() {
|
|
let dir = TempDir::new().unwrap();
|
|
let mount_id = Uuid::new_v4();
|
|
let router = router_with_mount(mount_id, &dir).await;
|
|
|
|
match router.classify(&mount_id.to_string()) {
|
|
ResolvedId::MountRoot { cfg } => assert_eq!(cfg.mount_id, mount_id),
|
|
_ => panic!("expected MountRoot"),
|
|
}
|
|
assert!(router.is_mount_id(&mount_id.to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn classifies_ext_child_id() {
|
|
let dir = TempDir::new().unwrap();
|
|
let mount_id = Uuid::new_v4();
|
|
let router = router_with_mount(mount_id, &dir).await;
|
|
|
|
let child = encode_child_id(mount_id, "docs/a.txt");
|
|
match router.classify(&child) {
|
|
ResolvedId::MountChild { cfg, node_id } => {
|
|
assert_eq!(cfg.mount_id, mount_id);
|
|
assert_eq!(node_id.as_str(), "docs/a.txt");
|
|
}
|
|
_ => panic!("expected MountChild"),
|
|
}
|
|
assert!(router.is_mount_id(&child));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ext_id_for_unregistered_mount_is_regular() {
|
|
let dir = TempDir::new().unwrap();
|
|
let mount_id = Uuid::new_v4();
|
|
let router = router_with_mount(mount_id, &dir).await;
|
|
|
|
// A well-formed ext: id but for a DIFFERENT (unknown) mount → Regular,
|
|
// so it 404s downstream rather than hitting the repos.
|
|
let dangling = encode_child_id(Uuid::new_v4(), "x");
|
|
assert!(matches!(router.classify(&dangling), ResolvedId::Regular));
|
|
|
|
// A plain (non-mount) UUID is also Regular.
|
|
assert!(matches!(
|
|
router.classify(&Uuid::new_v4().to_string()),
|
|
ResolvedId::Regular
|
|
));
|
|
}
|
|
}
|