Merge pull request #521 from BCNelson/feat/external-file-mounts
feat(mounts): external file mounts — pluggable provider, read-write, WebDAV/NextCloud, admin UI
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
//! PostgreSQL persistence for external mount configuration.
|
||||
//!
|
||||
//! `list_all` (re)builds the in-memory registry; `create`/`delete` back the
|
||||
//! admin CRUD endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountRecord, ExternalMountRepositoryPort, NewExternalMount,
|
||||
};
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
/// PostgreSQL implementation of [`ExternalMountRepositoryPort`].
|
||||
pub struct ExternalMountPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl ExternalMountPgRepository {
|
||||
/// Construct over a connection pool.
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalMountRepositoryPort for ExternalMountPgRepository {
|
||||
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError> {
|
||||
// Join each mount to its (non-trashed) root folder to pick up the
|
||||
// drive scope and the materialized path needed for path resolution.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
m.mount_folder_id AS mount_folder_id,
|
||||
m.kind AS kind,
|
||||
m.config AS config,
|
||||
m.name AS name,
|
||||
m.owner_id AS owner_id,
|
||||
m.read_only AS read_only,
|
||||
f.drive_id AS drive_id,
|
||||
f.path AS mount_path
|
||||
FROM storage.external_mounts m
|
||||
JOIN storage.folders f ON f.id = m.mount_folder_id
|
||||
WHERE NOT f.is_trashed
|
||||
"#,
|
||||
)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("failed to list external mounts: {e}")))?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let drive_id: Option<Uuid> = row
|
||||
.try_get("drive_id")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?;
|
||||
let Some(drive_id) = drive_id else {
|
||||
// A mount root without a drive shouldn't exist post-D0; skip safely.
|
||||
tracing::warn!(
|
||||
target: "oxicloud::external_mounts",
|
||||
"skipping external mount with NULL drive_id"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
out.push(ExternalMountRecord {
|
||||
mount_folder_id: row
|
||||
.try_get("mount_folder_id")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
kind: row
|
||||
.try_get("kind")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
config: row
|
||||
.try_get("config")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
name: row
|
||||
.try_get("name")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
owner_id: row
|
||||
.try_get("owner_id")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
read_only: row
|
||||
.try_get("read_only")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
drive_id,
|
||||
mount_path: row
|
||||
.try_get("mount_path")
|
||||
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn create(&self, mount: &NewExternalMount) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.external_mounts
|
||||
(mount_folder_id, kind, config, name, owner_id, read_only)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(mount.mount_folder_id)
|
||||
.bind(&mount.kind)
|
||||
.bind(&mount.config)
|
||||
.bind(&mount.name)
|
||||
.bind(mount.owner_id)
|
||||
.bind(mount.read_only)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("failed to create external mount: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, mount_folder_id: Uuid) -> Result<bool, DomainError> {
|
||||
let res = sqlx::query("DELETE FROM storage.external_mounts WHERE mount_folder_id = $1")
|
||||
.bind(mount_folder_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("failed to delete external mount: {e}"))
|
||||
})?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Gated on `test` too: the module uses the `testcontainers` dev-dependency,
|
||||
// which is only linked into test targets — a plain `--cfg integration_tests`
|
||||
// lib build (e.g. clippy's lib pass) must not try to compile it.
|
||||
#[cfg(all(test, integration_tests))]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
use crate::mount_it_support::{fresh_db, insert_mount, provision_folder};
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_returns_mount_joined_with_folder() {
|
||||
let (_c, pool) = fresh_db().await;
|
||||
let p = provision_folder(&pool, "mountowner", "Media").await;
|
||||
insert_mount(&pool, &p, "/srv/media").await;
|
||||
|
||||
let repo = ExternalMountPgRepository::new(pool.clone());
|
||||
let mounts = repo.list_all().await.expect("list_all");
|
||||
|
||||
assert_eq!(mounts.len(), 1);
|
||||
let m = &mounts[0];
|
||||
assert_eq!(m.mount_folder_id, p.mount_folder_id);
|
||||
assert_eq!(m.kind, "local_fs");
|
||||
assert_eq!(m.owner_id, p.owner_id);
|
||||
assert_eq!(m.drive_id, p.drive_id);
|
||||
assert!(!m.read_only);
|
||||
// The joined folder path (drive-scoped materialized path) contains the
|
||||
// mount folder's name.
|
||||
assert!(
|
||||
m.mount_path.contains("Media"),
|
||||
"mount_path was {:?}",
|
||||
m.mount_path
|
||||
);
|
||||
assert_eq!(m.config["path"], "/srv/media");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_skips_trashed_mount_folder() {
|
||||
let (_c, pool) = fresh_db().await;
|
||||
let p = provision_folder(&pool, "mountowner", "Media").await;
|
||||
insert_mount(&pool, &p, "/srv/media").await;
|
||||
|
||||
// Soft-delete the mount-root folder; the join filters NOT is_trashed.
|
||||
sqlx::query("UPDATE storage.folders SET is_trashed = true WHERE id = $1")
|
||||
.bind(p.mount_folder_id)
|
||||
.execute(pool.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let repo = ExternalMountPgRepository::new(pool.clone());
|
||||
let mounts = repo.list_all().await.expect("list_all");
|
||||
assert!(mounts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_empty_when_no_mounts() {
|
||||
let (_c, pool) = fresh_db().await;
|
||||
let repo = ExternalMountPgRepository::new(pool.clone());
|
||||
assert!(repo.list_all().await.expect("list_all").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_delete_round_trip() {
|
||||
use crate::application::ports::external_mount_ports::NewExternalMount;
|
||||
let (_c, pool) = fresh_db().await;
|
||||
let p = provision_folder(&pool, "owner", "Media").await;
|
||||
let repo = ExternalMountPgRepository::new(pool.clone());
|
||||
|
||||
repo.create(&NewExternalMount {
|
||||
mount_folder_id: p.mount_folder_id,
|
||||
kind: "local_fs".to_string(),
|
||||
config: serde_json::json!({ "path": "/srv/x" }),
|
||||
name: "Media".to_string(),
|
||||
owner_id: p.owner_id,
|
||||
read_only: true,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let mounts = repo.list_all().await.expect("list");
|
||||
assert_eq!(mounts.len(), 1);
|
||||
assert!(mounts[0].read_only);
|
||||
assert_eq!(mounts[0].mount_folder_id, p.mount_folder_id);
|
||||
|
||||
assert!(repo.delete(p.mount_folder_id).await.expect("delete"));
|
||||
assert!(repo.list_all().await.expect("list").is_empty());
|
||||
// Deleting a non-existent mount returns false.
|
||||
assert!(!repo.delete(p.mount_folder_id).await.expect("delete again"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
|
||||
mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod drive_pg_repository;
|
||||
mod external_mount_repository;
|
||||
mod face_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
@@ -36,6 +37,7 @@ pub use contact_persistence_dto::*;
|
||||
pub use contact_pg_repository::ContactPgRepository;
|
||||
pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||
pub use drive_pg_repository::DrivePgRepository;
|
||||
pub use external_mount_repository::ExternalMountPgRepository;
|
||||
pub use face_pg_repository::FacePgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,13 @@ pub mod grant_cleanup_service;
|
||||
pub mod image_transcode_service;
|
||||
pub mod jwt_service;
|
||||
pub mod local_blob_backend;
|
||||
pub mod local_fs_mount_provider;
|
||||
pub mod login_lockout_service;
|
||||
pub mod media_metadata_service;
|
||||
pub mod migration_blob_backend;
|
||||
pub mod migration_job;
|
||||
pub mod mock_email_sender;
|
||||
pub mod mount_provider_factory;
|
||||
pub mod nextcloud_chunked_upload_service;
|
||||
pub mod noop_face_analyzer;
|
||||
pub mod oidc_service;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The single place external mount provider kinds are registered.
|
||||
//!
|
||||
//! Adding a new backend (`sftp`, `webdav`, …) is: implement
|
||||
//! [`ExternalMountProvider`](crate::application::ports::external_mount_ports::ExternalMountProvider)
|
||||
//! and add one arm to [`DefaultMountProviderFactory::build`]. Nothing else in the
|
||||
//! router / listing / authz / path-resolution layers changes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountProvider, MountProviderFactory,
|
||||
};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider;
|
||||
|
||||
/// Default factory: knows the built-in provider kinds.
|
||||
#[derive(Default)]
|
||||
pub struct DefaultMountProviderFactory;
|
||||
|
||||
impl DefaultMountProviderFactory {
|
||||
/// Construct the factory.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MountProviderFactory for DefaultMountProviderFactory {
|
||||
async fn build(
|
||||
&self,
|
||||
kind: &str,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<Arc<dyn ExternalMountProvider>, DomainError> {
|
||||
match kind {
|
||||
"local_fs" => {
|
||||
let path = config.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
DomainError::validation_error(
|
||||
"local_fs mount config requires a string \"path\"",
|
||||
)
|
||||
})?;
|
||||
let read_only = config
|
||||
.get("read_only")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let provider = LocalFsMountProvider::new(path, read_only)?;
|
||||
Ok(Arc::new(provider))
|
||||
}
|
||||
other => Err(DomainError::operation_not_supported(
|
||||
"ExternalMount",
|
||||
format!("unknown mount provider kind: {other}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// `Arc<dyn ExternalMountProvider>` isn't `Debug`, so `unwrap_err` won't
|
||||
/// compile — extract the error by matching instead.
|
||||
fn expect_err(r: Result<Arc<dyn ExternalMountProvider>, DomainError>) -> DomainError {
|
||||
match r {
|
||||
Ok(_) => panic!("expected an error"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_local_fs_provider_from_valid_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
let cfg = serde_json::json!({ "path": dir.path().to_str().unwrap() });
|
||||
let provider = factory.build("local_fs", &cfg).await.expect("builds");
|
||||
assert_eq!(provider.kind(), "local_fs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_fs_honours_read_only_flag() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
let cfg = serde_json::json!({ "path": dir.path().to_str().unwrap(), "read_only": true });
|
||||
let provider = factory.build("local_fs", &cfg).await.unwrap();
|
||||
assert!(provider.capabilities().read_only);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_kind_is_unsupported() {
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
let err = expect_err(factory.build("sftp", &serde_json::json!({})).await);
|
||||
assert_eq!(err.kind, ErrorKind::UnsupportedOperation);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_fs_missing_path_is_validation_error() {
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
let err = expect_err(
|
||||
factory
|
||||
.build("local_fs", &serde_json::json!({ "read_only": true }))
|
||||
.await,
|
||||
);
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_fs_nonexistent_path_errors() {
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
let err = expect_err(
|
||||
factory
|
||||
.build(
|
||||
"local_fs",
|
||||
&serde_json::json!({ "path": "/no/such/dir/xyz123" }),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
// Propagated from LocalFsMountProvider::new (canonicalize failure).
|
||||
assert_eq!(err.kind, ErrorKind::InternalError);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user