feat(mounts): external file mounts P1 — pluggable provider + read-only REST
Adds the foundation for external file mounts: admin-configured backends (raw host filesystem in v1; sftp/webdav/… as future provider kinds) surfaced as a folder inside a user's drive. Mount contents are virtual/live-passthrough — read straight from the backend, never stored in storage.files — and are a deliberately separate, limited storage type (no dedup/sharing/trash/search). The feature is dark by default (OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false). P1 scope (this PR): data model, the pluggable provider abstraction, and the read-only REST surface (mount listing + download). Read-write (P2), WebDAV/NextCloud path resolution (P3), and the admin UI (P4) follow. Core model - Mount root = a real storage.folders row; authorization for everything inside collapses onto that folder UUID (ltree-ancestry grant cascade). - Children are virtual, addressed by ext:<mount_id>:<base64url(node_id)> where node_id is provider-owned and opaque to the rest of the system. - A lock-free (arc-swap) MountRegistry maps mount-root UUID -> provider; a thin MountRouter::classify() is the single cheap hook handlers call before parsing an id as a UUID. With no mounts configured it always returns Regular, so existing code paths are unchanged. Added - migrations/20260805000000_external_mounts.sql (storage.external_mounts, kind + config JSONB) - domain/services/external_mount_id (id envelope + virtual etags) - application/ports/external_mount_ports (ExternalMountProvider, MountProviderFactory, repo port) - infrastructure local_fs_mount_provider (tokio::fs, symlink-escape-safe) + factory - application MountRegistry + MountRouter, pg ExternalMountRepository - DI wiring (AppState.mount_router), FeaturesConfig.enable_external_mounts - listing branch (FolderService::list_mount_dir_with_perms + folder_handler) and download branch (FileRetrievalService stat/open mount methods + file_handler) Authorization stays in the service layer (authz.require(Resource::Folder(mount_id))); handlers only classify. Cross-backend operations are out of scope for P1. Tests: 529 unit tests + 5 testcontainers integration tests (real Postgres 17), including end-to-end authorization (owner allowed, stranger denied). Line coverage of the new modules is 84–100% (cargo-llvm-cov). Known gap: file_handler::download_mount_file (HTTP glue) needs a full-app test (P4).
This commit is contained in:
@@ -17,11 +17,17 @@ use crate::application::dtos::folder_dto::{
|
||||
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::application::ports::external_mount_ports::MountEntry;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::services::external_mount_router::ResolvedId;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::di::AppState as GlobalAppState;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::services::external_mount_id::{
|
||||
NodeId, encode_child_id, virtual_file_etag, virtual_folder_etag,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
@@ -485,6 +491,28 @@ pub async fn list_folder_resources(
|
||||
reverse: q.reverse,
|
||||
};
|
||||
|
||||
// External mount branch: a mount-root UUID or an `ext:` id lists live from
|
||||
// the provider instead of the PostgreSQL UNION. The parent of each entry is
|
||||
// the requested id itself.
|
||||
match service.mount_router().classify(&id) {
|
||||
ResolvedId::MountRoot { cfg } => {
|
||||
return list_mount_dir_response(
|
||||
&service,
|
||||
&cfg,
|
||||
&NodeId::default(),
|
||||
&id,
|
||||
auth_user.id,
|
||||
opts,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ResolvedId::MountChild { cfg, node_id } => {
|
||||
return list_mount_dir_response(&service, &cfg, &node_id, &id, auth_user.id, opts)
|
||||
.await;
|
||||
}
|
||||
ResolvedId::Regular => {}
|
||||
}
|
||||
|
||||
match service
|
||||
.list_resources_paged_with_perms(&id, auth_user.id, opts)
|
||||
.await
|
||||
@@ -578,3 +606,176 @@ pub async fn list_folder_resources(
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// List one directory inside an external mount and render the standard
|
||||
/// `/resources` envelope, mapping each live provider entry to a
|
||||
/// `FolderResourceItemDto` with a synthetic `ext:` id. `parent_id` is the
|
||||
/// requested id (the directory being listed), which becomes each entry's parent.
|
||||
async fn list_mount_dir_response(
|
||||
service: &FolderService,
|
||||
cfg: &MountConfig,
|
||||
node_id: &NodeId,
|
||||
parent_id: &str,
|
||||
caller_id: uuid::Uuid,
|
||||
opts: ListResourcesOptions<'_>,
|
||||
) -> axum::response::Response {
|
||||
match service
|
||||
.list_mount_dir_with_perms(cfg, node_id, caller_id, opts)
|
||||
.await
|
||||
{
|
||||
Ok((entries, next_cursor)) => {
|
||||
let items: Vec<FolderResourceItemDto> = entries
|
||||
.into_iter()
|
||||
.map(|entry| mount_entry_to_item(cfg, parent_id, entry))
|
||||
.collect();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(FolderResourcesDto::with_cursor(items, next_cursor)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a live mount entry to a `/resources` item with a synthetic `ext:` id and
|
||||
/// virtual (size+mtime / mtime) etag. Mount entries have no blob hash.
|
||||
fn mount_entry_to_item(
|
||||
cfg: &MountConfig,
|
||||
parent_id: &str,
|
||||
entry: MountEntry,
|
||||
) -> FolderResourceItemDto {
|
||||
let id = encode_child_id(cfg.mount_id, entry.node_id.clone());
|
||||
if entry.is_dir {
|
||||
let dto = FolderDto {
|
||||
etag: virtual_folder_etag(entry.modified_at),
|
||||
id,
|
||||
name: entry.name.clone(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
FolderResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
resource: ResourceContentDto::Folder(dto),
|
||||
}
|
||||
} else {
|
||||
let mime = mime_guess::from_path(&entry.name)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
let dto = FileDto {
|
||||
id,
|
||||
name: entry.name.clone(),
|
||||
path: String::new(),
|
||||
size: entry.size,
|
||||
mime_type: Arc::from(mime.as_str()),
|
||||
folder_id: Some(parent_id.to_owned()),
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
icon_class: Arc::from(icon_class_for(&entry.name, &mime)),
|
||||
icon_special_class: Arc::from(icon_special_class_for(&entry.name, &mime)),
|
||||
category: Arc::from(category_for(&entry.name, &mime)),
|
||||
size_formatted: format_file_size(entry.size),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(entry.size, entry.modified_at),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
FolderResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
resource: ResourceContentDto::File(dto),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mount_mapping_tests {
|
||||
use super::*;
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn config() -> MountConfig {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Leak the tempdir so the path stays valid for the provider's lifetime;
|
||||
// the provider is never exercised here (mapping is pure metadata).
|
||||
let path = dir.keep();
|
||||
MountConfig {
|
||||
mount_id: Uuid::new_v4(),
|
||||
kind: "local_fs".to_string(),
|
||||
name: "Media".to_string(),
|
||||
owner_id: Uuid::new_v4(),
|
||||
drive_id: Uuid::new_v4(),
|
||||
read_only: false,
|
||||
mount_path: "Personal/Media".to_string(),
|
||||
provider: Arc::new(LocalFsMountProvider::new(&path, false).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_entry(name: &str, node_id: &str, is_dir: bool, size: u64, mtime: u64) -> MountEntry {
|
||||
MountEntry {
|
||||
name: name.to_string(),
|
||||
node_id: NodeId(node_id.to_string()),
|
||||
is_dir,
|
||||
size,
|
||||
modified_at: mtime,
|
||||
created_at: mtime,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_folder_entry_to_item() {
|
||||
let cfg = config();
|
||||
let parent = cfg.mount_id.to_string();
|
||||
let item = mount_entry_to_item(&cfg, &parent, mount_entry("docs", "docs", true, 0, 1234));
|
||||
|
||||
assert!(matches!(item.resource_type, ResourceTypeDto::Folder));
|
||||
let ResourceContentDto::Folder(dto) = item.resource else {
|
||||
panic!("expected folder");
|
||||
};
|
||||
assert_eq!(dto.name, "docs");
|
||||
// id is the synthetic ext: envelope for (mount_id, node_id).
|
||||
assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs"));
|
||||
assert_eq!(dto.parent_id.as_deref(), Some(parent.as_str()));
|
||||
assert_eq!(dto.etag, virtual_folder_etag(1234));
|
||||
assert_eq!(dto.drive_id, cfg.drive_id);
|
||||
assert!(!dto.is_root);
|
||||
// Hierarchy is intentionally cleared on this listing.
|
||||
assert_eq!(dto.path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_file_entry_to_item_with_virtual_etag_and_no_hash() {
|
||||
let cfg = config();
|
||||
let parent = encode_child_id(cfg.mount_id, "docs");
|
||||
let item = mount_entry_to_item(
|
||||
&cfg,
|
||||
&parent,
|
||||
mount_entry("report.json", "docs/report.json", false, 42, 999),
|
||||
);
|
||||
|
||||
assert!(matches!(item.resource_type, ResourceTypeDto::File));
|
||||
let ResourceContentDto::File(dto) = item.resource else {
|
||||
panic!("expected file");
|
||||
};
|
||||
assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs/report.json"));
|
||||
assert_eq!(dto.folder_id.as_deref(), Some(parent.as_str()));
|
||||
assert_eq!(dto.size, 42);
|
||||
assert_eq!(dto.etag, virtual_file_etag(42, 999));
|
||||
// Virtual files have no blob hash.
|
||||
assert_eq!(dto.content_hash, "");
|
||||
// Mime is sniffed from the name.
|
||||
assert_eq!(&*dto.mime_type, "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user