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:
@@ -0,0 +1,192 @@
|
||||
//! External mount provider port — the pluggable backend abstraction for mounts.
|
||||
//!
|
||||
//! An [`ExternalMountProvider`] exposes a filesystem-style I/O surface for one
|
||||
//! mount's backend (raw host fs in v1; SFTP/WebDAV/… later). It is the *lowest
|
||||
//! common denominator* of browse + CRUD: deliberately small, so new backend
|
||||
//! `kind`s drop in without touching the router, listing, authz, or path
|
||||
//! resolution. Rich native features (sharing, trash, search, …) are NOT part of
|
||||
//! this trait — they compose above it.
|
||||
//!
|
||||
//! Each provider instance is **bound to one mount's root location at
|
||||
//! construction**, so methods take only a provider-owned [`NodeId`], never a host
|
||||
//! `Path` (an SFTP/WebDAV provider has no local path). The `NodeId` is opaque to
|
||||
//! the rest of the system (see [`crate::domain::services::external_mount_id`]).
|
||||
//!
|
||||
//! The trait returns boxed futures via `#[async_trait]` and takes a boxed write
|
||||
//! stream, so it is dyn-compatible (`Arc<dyn ExternalMountProvider>`) and a
|
||||
//! single mount registry can hold providers of different kinds.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::blob_storage_ports::BlobStream;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::services::external_mount_id::NodeId;
|
||||
|
||||
/// A byte stream handed to [`ExternalMountProvider::write_stream`].
|
||||
///
|
||||
/// Boxed (not generic) so the trait stays object-safe. Callers map their body's
|
||||
/// error type to `std::io::Error` before constructing it.
|
||||
pub type MountByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
|
||||
|
||||
/// One entry returned by [`ExternalMountProvider::list_dir`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountEntry {
|
||||
/// Final path segment (display name).
|
||||
pub name: String,
|
||||
/// Provider-assigned, opaque identity for this entry.
|
||||
pub node_id: NodeId,
|
||||
/// Whether the entry is a directory.
|
||||
pub is_dir: bool,
|
||||
/// Size in bytes (0 for directories).
|
||||
pub size: u64,
|
||||
/// Last-modified time, unix seconds.
|
||||
pub modified_at: u64,
|
||||
/// Creation time, unix seconds (falls back to `modified_at` when unavailable).
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
/// Metadata for a single entry returned by [`ExternalMountProvider::stat`] and
|
||||
/// by the mutating ops (so the caller learns the new entry's `node_id`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountStat {
|
||||
/// Provider-assigned, opaque identity for this entry.
|
||||
pub node_id: NodeId,
|
||||
/// Whether the entry is a directory.
|
||||
pub is_dir: bool,
|
||||
/// Size in bytes (0 for directories).
|
||||
pub size: u64,
|
||||
/// Last-modified time, unix seconds.
|
||||
pub modified_at: u64,
|
||||
/// Creation time, unix seconds.
|
||||
pub created_at: u64,
|
||||
/// MIME type (sniffed from extension for files; `"directory"` for dirs).
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Static capability flags a provider advertises.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MountCaps {
|
||||
/// Provider can serve byte ranges (HTTP Range / partial reads).
|
||||
pub supports_range: bool,
|
||||
/// Provider refuses all mutations.
|
||||
pub read_only: bool,
|
||||
/// `node_id`s are stable across renames/moves (e.g. inode / object id).
|
||||
/// `false` for path-based providers — relevant to the future sharing path.
|
||||
pub stable_ids: bool,
|
||||
}
|
||||
|
||||
/// Pluggable I/O surface for one mount's backend, bound to its root location.
|
||||
///
|
||||
/// Implementations: `LocalFsMountProvider` (v1). All node ids are
|
||||
/// provider-owned and opaque; the system never parses them.
|
||||
#[async_trait]
|
||||
pub trait ExternalMountProvider: Send + Sync + 'static {
|
||||
/// Provider kind identifier (matches the `kind` column / factory arm).
|
||||
fn kind(&self) -> &'static str;
|
||||
|
||||
/// Static capabilities.
|
||||
fn capabilities(&self) -> MountCaps;
|
||||
|
||||
/// Map an internal path (relative to the mount root) to a `node_id`.
|
||||
///
|
||||
/// For path-based providers this is identity (the default). Providers whose
|
||||
/// identity is not a path override this. Does not assert existence — use
|
||||
/// [`stat`](Self::stat) for that.
|
||||
fn resolve_path(&self, path: &str) -> NodeId {
|
||||
NodeId(path.to_string())
|
||||
}
|
||||
|
||||
/// List the directory identified by `node_id` (root = the provider's bound
|
||||
/// location, addressed via `resolve_path("")`).
|
||||
async fn list_dir(&self, node_id: &NodeId) -> Result<Vec<MountEntry>, DomainError>;
|
||||
|
||||
/// Stat a single entry.
|
||||
async fn stat(&self, node_id: &NodeId) -> Result<MountStat, DomainError>;
|
||||
|
||||
/// Open a (optionally ranged) read stream over a file's bytes.
|
||||
///
|
||||
/// `range` is `(start, end_inclusive_opt)`; `None` reads the whole file.
|
||||
async fn open_read_stream(
|
||||
&self,
|
||||
node_id: &NodeId,
|
||||
range: Option<(u64, Option<u64>)>,
|
||||
) -> Result<BlobStream, DomainError>;
|
||||
|
||||
/// Create a child directory `name` under `parent`. Returns the new dir's stat.
|
||||
async fn create_dir(&self, parent: &NodeId, name: &str) -> Result<MountStat, DomainError>;
|
||||
|
||||
/// Stream-write a child file `name` under `parent`. Returns the new file's stat.
|
||||
async fn write_stream(
|
||||
&self,
|
||||
parent: &NodeId,
|
||||
name: &str,
|
||||
body: MountByteStream,
|
||||
) -> Result<MountStat, DomainError>;
|
||||
|
||||
/// Rename an entry in place (same parent). Returns the renamed entry's stat.
|
||||
async fn rename(&self, node_id: &NodeId, new_name: &str) -> Result<MountStat, DomainError>;
|
||||
|
||||
/// Delete an entry (recursively for directories). Permanent — no trash.
|
||||
async fn delete(&self, node_id: &NodeId) -> Result<(), DomainError>;
|
||||
|
||||
/// Move an entry into `dest_parent`, keeping its name. Returns the new stat.
|
||||
async fn move_within(
|
||||
&self,
|
||||
node_id: &NodeId,
|
||||
dest_parent: &NodeId,
|
||||
) -> Result<MountStat, DomainError>;
|
||||
}
|
||||
|
||||
/// A persisted external mount joined with its mount-root folder.
|
||||
///
|
||||
/// Returned by [`ExternalMountRepositoryPort::list_all`] to (re)build the
|
||||
/// in-memory registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExternalMountRecord {
|
||||
/// The mount-root folder UUID (also the mount's identity in the registry).
|
||||
pub mount_folder_id: Uuid,
|
||||
/// Provider kind (factory discriminator).
|
||||
pub kind: String,
|
||||
/// Provider-specific connection config.
|
||||
pub config: serde_json::Value,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Owner of the mount configuration.
|
||||
pub owner_id: Uuid,
|
||||
/// Whether the mount is read-only.
|
||||
pub read_only: bool,
|
||||
/// Drive the mount-root folder belongs to (for path resolution).
|
||||
pub drive_id: Uuid,
|
||||
/// Materialized internal path of the mount-root folder (for path resolution).
|
||||
pub mount_path: String,
|
||||
}
|
||||
|
||||
/// Persistence port for external mount configuration.
|
||||
#[async_trait]
|
||||
pub trait ExternalMountRepositoryPort: Send + Sync {
|
||||
/// Load every (non-trashed) mount joined with its folder, for registry build.
|
||||
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError>;
|
||||
}
|
||||
|
||||
/// Builds [`ExternalMountProvider`]s from a `kind` + `config` pair.
|
||||
///
|
||||
/// The single extension point for new backends: adding a provider is
|
||||
/// implementing the trait plus one arm here.
|
||||
#[async_trait]
|
||||
pub trait MountProviderFactory: Send + Sync {
|
||||
/// Construct a provider for `kind`, parsing its `config` JSON.
|
||||
///
|
||||
/// Errors with `UnsupportedOperation` for an unknown kind, or
|
||||
/// `validation_error` for malformed config.
|
||||
async fn build(
|
||||
&self,
|
||||
kind: &str,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<Arc<dyn ExternalMountProvider>, DomainError>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod compression_ports;
|
||||
pub mod content_index_ports;
|
||||
pub mod dedup_ports;
|
||||
pub mod email_sender;
|
||||
pub mod external_mount_ports;
|
||||
pub mod face_ports;
|
||||
pub mod favorites_ports;
|
||||
pub mod file_lifecycle;
|
||||
|
||||
Reference in New Issue
Block a user