Files
Oxicloud/src/interfaces/api/handlers/admin_external_mounts.rs
T
Bradley Nelson 07320a3925 feat(mounts): P4 — admin CRUD endpoints + frontend admin tab
Mounts are now configurable at runtime without DB surgery.

Backend (admin-gated, /api/admin/external-mounts):
- GET    list all configured mounts
- POST   create: validate the provider config up front, create a mount-root
         folder under the admin's drive, insert the row, hot-reload the registry
- DELETE remove the mount row + its root folder (host content is left intact),
         then hot-reload
- ExternalMountRepositoryPort gains create/delete (PG impl); registry.reload()
  runs in-process so changes are live immediately
- audit lines event="external_mount.config" action=create|delete

Frontend:
- admin.ts endpoints: listExternalMounts / createExternalMount / deleteExternalMount
- new "External Mounts" tab in the admin page: add form (name + host path +
  read-only) and a list with delete

Integration test for the repo create/delete round-trip (testcontainers).
2026-06-25 01:07:03 -06:00

215 lines
7.0 KiB
Rust

//! Admin CRUD for external file mounts (`/api/admin/external-mounts`).
//!
//! Creating a mount: validate the backend config, create a mount-root folder
//! under the admin's drive, insert the `external_mounts` row, then hot-reload
//! the in-memory registry. Deleting: remove the row + the folder and reload.
//! Every endpoint is admin-gated.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::application::ports::external_mount_ports::{
ExternalMountRecord, ExternalMountRepositoryPort, MountProviderFactory, NewExternalMount,
};
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::infrastructure::repositories::pg::ExternalMountPgRepository;
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
/// JSON view of a configured mount.
#[derive(Debug, Serialize)]
pub struct ExternalMountResponse {
pub mount_folder_id: String,
pub name: String,
pub kind: String,
pub owner_id: String,
pub read_only: bool,
pub drive_id: String,
pub mount_path: String,
pub config: serde_json::Value,
}
impl From<ExternalMountRecord> for ExternalMountResponse {
fn from(r: ExternalMountRecord) -> Self {
Self {
mount_folder_id: r.mount_folder_id.to_string(),
name: r.name,
kind: r.kind,
owner_id: r.owner_id.to_string(),
read_only: r.read_only,
drive_id: r.drive_id.to_string(),
mount_path: r.mount_path,
config: r.config,
}
}
}
/// Request body for creating a mount.
#[derive(Debug, Deserialize)]
pub struct CreateExternalMountRequest {
/// Display name (also the mount-root folder name).
pub name: String,
/// Absolute host path for the `local_fs` provider.
pub host_path: String,
/// Provider kind. Defaults to `local_fs`.
#[serde(default = "default_kind")]
pub kind: String,
/// When true, the mount refuses all mutations.
#[serde(default)]
pub read_only: bool,
}
fn default_kind() -> String {
"local_fs".to_string()
}
fn pool(state: &AppState) -> Result<Arc<sqlx::PgPool>, AppError> {
state
.db_pool
.clone()
.ok_or_else(|| AppError::internal_error("Database not available"))
}
/// `GET /api/admin/external-mounts` — list all configured mounts.
pub async fn list_external_mounts(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
require_admin(&state, &headers).await?;
let repo = ExternalMountPgRepository::new(pool(&state)?);
let mounts = repo
.list_all()
.await
.map_err(|e| AppError::internal_error(format!("list external mounts: {e}")))?;
let out: Vec<ExternalMountResponse> = mounts.into_iter().map(Into::into).collect();
Ok(Json(out))
}
/// `POST /api/admin/external-mounts` — create a mount in the admin's drive.
pub async fn create_external_mount(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<CreateExternalMountRequest>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _role) = require_admin(&state, &headers).await?;
if req.name.trim().is_empty() {
return Err(AppError::bad_request("Mount name must not be empty"));
}
// Build the provider config and validate it up front (path exists, etc.).
let config = serde_json::json!({ "path": req.host_path, "read_only": req.read_only });
let factory = DefaultMountProviderFactory::new();
factory
.build(&req.kind, &config)
.await
.map_err(|e| AppError::bad_request(format!("invalid mount configuration: {e}")))?;
// Create the mount-root folder under the admin's default drive root.
let drive = state
.drive_repo
.find_default_for_user(admin_id)
.await
.map_err(|e| AppError::internal_error(format!("find default drive: {e}")))?;
let root_folder_id = drive.drive.root_folder_id.to_string();
let folder = state
.repositories
.folder_repository
.create_folder(req.name.clone(), Some(root_folder_id), admin_id)
.await
.map_err(AppError::from)?;
let mount_folder_id =
Uuid::parse_str(folder.id()).map_err(|_| AppError::internal_error("bad folder id"))?;
let repo = ExternalMountPgRepository::new(pool(&state)?);
repo.create(&NewExternalMount {
mount_folder_id,
kind: req.kind.clone(),
config,
name: req.name.clone(),
owner_id: admin_id,
read_only: req.read_only,
})
.await
.map_err(|e| AppError::internal_error(format!("create mount row: {e}")))?;
// Hot-reload so the new mount is live immediately.
state.mount_router.registry().reload(&repo, &factory).await;
tracing::info!(
target: "audit",
event = "external_mount.config",
action = "create",
mount_id = %mount_folder_id,
caller_id = %admin_id,
kind = %req.kind,
reason = "external_mount_admin",
"👮🏻‍♂️ external mount created",
);
// Return the freshly created mount.
let created = repo
.list_all()
.await
.map_err(|e| AppError::internal_error(format!("reload mounts: {e}")))?
.into_iter()
.find(|m| m.mount_folder_id == mount_folder_id)
.map(ExternalMountResponse::from)
.ok_or_else(|| AppError::internal_error("created mount not found"))?;
Ok((StatusCode::CREATED, Json(created)))
}
/// `DELETE /api/admin/external-mounts/{id}` — remove a mount (and its root
/// folder). The host filesystem content is untouched.
pub async fn delete_external_mount(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _role) = require_admin(&state, &headers).await?;
let repo = ExternalMountPgRepository::new(pool(&state)?);
let removed = repo
.delete(id)
.await
.map_err(|e| AppError::internal_error(format!("delete mount row: {e}")))?;
if !removed {
return Err(AppError::not_found("Mount not found"));
}
// Remove the mount-root folder row (host content is left intact).
state
.repositories
.folder_repository
.delete_folder(&id.to_string())
.await
.map_err(AppError::from)?;
let factory = DefaultMountProviderFactory::new();
state.mount_router.registry().reload(&repo, &factory).await;
tracing::info!(
target: "audit",
event = "external_mount.config",
action = "delete",
mount_id = %id,
caller_id = %admin_id,
reason = "external_mount_admin",
"👮🏻‍♂️ external mount deleted",
);
Ok(StatusCode::NO_CONTENT)
}