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 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -52,7 +52,17 @@ struct AdminUsersPageResponse {
|
||||
|
||||
/// Admin API routes — all require admin role.
|
||||
pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
use super::admin_external_mounts as ext_mounts;
|
||||
Router::new()
|
||||
// External file mounts
|
||||
.route(
|
||||
"/external-mounts",
|
||||
get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount),
|
||||
)
|
||||
.route(
|
||||
"/external-mounts/{id}",
|
||||
delete(ext_mounts::delete_external_mount),
|
||||
)
|
||||
// OIDC settings
|
||||
.route("/settings/oidc", get(get_oidc_settings))
|
||||
.route("/settings/oidc", put(save_oidc_settings))
|
||||
|
||||
@@ -11,13 +11,18 @@ use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::application::ports::external_mount_ports::MountStat;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, RangeContent,
|
||||
};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
|
||||
use crate::application::ports::thumbnail_ports::ThumbnailPort;
|
||||
use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase};
|
||||
use crate::application::services::external_mount_router::ResolvedId;
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::services::external_mount_id::{NodeId, virtual_file_etag};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use crate::interfaces::range_requests::not_modified_response;
|
||||
@@ -251,6 +256,35 @@ impl FileHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// ── External mount destination? Stream to the provider ──
|
||||
// Detected BEFORE the CAS ingest so the bytes never touch
|
||||
// BLAKE3/dedup. Authorization happens inside the service.
|
||||
if let Some(ref fid) = folder_id {
|
||||
let (mount_cfg, parent_node) = match state.mount_router.classify(fid) {
|
||||
ResolvedId::MountRoot { cfg } => (Some(cfg), NodeId::default()),
|
||||
ResolvedId::MountChild { cfg, node_id } => (Some(cfg), node_id),
|
||||
ResolvedId::Regular => (None, NodeId::default()),
|
||||
};
|
||||
if let Some(cfg) = mount_cfg {
|
||||
use futures::StreamExt;
|
||||
let body: crate::application::ports::external_mount_ports::MountByteStream<
|
||||
'_,
|
||||
> = Box::pin(
|
||||
upload_ingest::multipart_field_stream(field)
|
||||
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string()))),
|
||||
);
|
||||
return match state
|
||||
.applications
|
||||
.external_upload_service
|
||||
.write_file(&cfg, &parent_node, &filename, body, auth_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(file) => Ok((file, String::new())),
|
||||
Err(err) => Err(Self::domain_error_response(err)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stream the field into the CDC chunk store ────────
|
||||
// Chunking (FastCDC) + hashing (BLAKE3) + dedup checks +
|
||||
// MIME sniffing all happen while the bytes arrive; chunks
|
||||
@@ -683,6 +717,22 @@ impl FileHandler {
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
headers: &HeaderMap,
|
||||
) -> impl IntoResponse + use<> {
|
||||
// External mount: download a file living on the provider's backend.
|
||||
// (A mount-root UUID is a folder and is not downloadable — it falls
|
||||
// through and 404s as a non-file.)
|
||||
if let ResolvedId::MountChild { cfg, node_id } = state.mount_router.classify(&id) {
|
||||
return Self::download_mount_file(
|
||||
&state,
|
||||
&cfg,
|
||||
&node_id,
|
||||
&id,
|
||||
auth_user.id,
|
||||
¶ms,
|
||||
headers,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let retrieval = &state.applications.file_retrieval_service;
|
||||
|
||||
// ── Get file metadata (ownership-scoped) ────────────────────────
|
||||
@@ -838,6 +888,134 @@ impl FileHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file living inside an external mount: stat via the provider
|
||||
/// (authorized against the mount root), then serve metadata / 304 / Range /
|
||||
/// full stream straight from the backend. No blob cache, dedup, or WebP
|
||||
/// transcode — mount content is served as-is.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn download_mount_file(
|
||||
state: &AppState,
|
||||
cfg: &MountConfig,
|
||||
node_id: &NodeId,
|
||||
id: &str,
|
||||
caller_id: uuid::Uuid,
|
||||
params: &HashMap<String, String>,
|
||||
headers: &HeaderMap,
|
||||
) -> axum::response::Response {
|
||||
let retrieval = &state.applications.file_retrieval_service;
|
||||
|
||||
let stat: MountStat = match retrieval
|
||||
.stat_mount_file_with_perms(cfg, node_id, caller_id)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(err) => return AppError::from(err).into_response(),
|
||||
};
|
||||
if stat.is_dir {
|
||||
// Directories are not downloadable through this endpoint.
|
||||
return AppError::from(DomainError::not_found("File", id)).into_response();
|
||||
}
|
||||
|
||||
let name = node_id
|
||||
.as_str()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or_else(|| node_id.as_str());
|
||||
|
||||
// ── Metadata-only request ────────────────────────────────────
|
||||
if params
|
||||
.get("metadata")
|
||||
.is_some_and(|v| v == "true" || v == "1")
|
||||
{
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"size": stat.size,
|
||||
"mime_type": stat.mime_type,
|
||||
"modified_at": stat.modified_at,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let etag = format!("\"{}\"", virtual_file_etag(stat.size, stat.modified_at));
|
||||
if let Some(resp) = not_modified_response(headers, &etag) {
|
||||
return resp.into_response();
|
||||
}
|
||||
|
||||
// ── Range Requests ───────────────────────────────────────────
|
||||
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
||||
match plan_mount_range(stat.size, range_header) {
|
||||
MountRangePlan::Full => {}
|
||||
MountRangePlan::NotSatisfiable => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{}", stat.size))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
MountRangePlan::Range { start, end } => {
|
||||
let range_length = end - start + 1;
|
||||
let disposition = Self::content_disposition(name, &stat.mime_type, params);
|
||||
match retrieval
|
||||
.open_mount_file_with_perms(cfg, node_id, caller_id, Some((start, Some(end))))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, &stat.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, range_length)
|
||||
.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, stat.size),
|
||||
)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::ETAG, &etag)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error creating mount range stream: {}", err);
|
||||
// fall through to full download
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normal download ──────────────────────────────────────────
|
||||
let disposition = Self::content_disposition(name, &stat.mime_type, params);
|
||||
match retrieval
|
||||
.open_mount_file_with_perms(cfg, node_id, caller_id, None)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &stat.mime_type)
|
||||
.header(header::CONTENT_DISPOSITION, &disposition)
|
||||
.header(header::CONTENT_LENGTH, stat.size)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
.into_response(),
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// LIST
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -1472,3 +1650,93 @@ pub async fn move_file_simple(
|
||||
) -> impl IntoResponse {
|
||||
FileHandler::move_file_simple_impl(state, auth_user, path, json).await
|
||||
}
|
||||
|
||||
/// The download decision for a mount file given a `Range` header — the gnarly
|
||||
/// parse-and-validate logic, extracted so it is unit-testable without I/O.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum MountRangePlan {
|
||||
/// Serve the whole file (no/invalid range header).
|
||||
Full,
|
||||
/// Serve `start..=end` (inclusive) as 206 Partial Content.
|
||||
Range { start: u64, end: u64 },
|
||||
/// The requested range is unsatisfiable for this size → 416.
|
||||
NotSatisfiable,
|
||||
}
|
||||
|
||||
/// Decide how to serve a mount file for a given size + optional `Range` header.
|
||||
/// A missing or unparseable range → `Full`; a valid range → `Range`; an
|
||||
/// out-of-bounds range → `NotSatisfiable`.
|
||||
pub(super) fn plan_mount_range(size: u64, range_header: Option<&str>) -> MountRangePlan {
|
||||
let Some(rh) = range_header else {
|
||||
return MountRangePlan::Full;
|
||||
};
|
||||
let Ok(ranges) = parse_range_header(rh) else {
|
||||
// Malformed range header: ignore it and serve the whole file (RFC 7233).
|
||||
return MountRangePlan::Full;
|
||||
};
|
||||
match ranges.validate(size) {
|
||||
Ok(valid) => match valid.first() {
|
||||
Some(r) => MountRangePlan::Range {
|
||||
start: *r.start(),
|
||||
end: *r.end(),
|
||||
},
|
||||
None => MountRangePlan::Full,
|
||||
},
|
||||
Err(_) => MountRangePlan::NotSatisfiable,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mount_range_tests {
|
||||
use super::{MountRangePlan, plan_mount_range};
|
||||
|
||||
#[test]
|
||||
fn no_range_header_is_full() {
|
||||
assert_eq!(plan_mount_range(100, None), MountRangePlan::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_range_falls_back_to_full() {
|
||||
assert_eq!(
|
||||
plan_mount_range(100, Some("not-a-range")),
|
||||
MountRangePlan::Full
|
||||
);
|
||||
assert_eq!(
|
||||
plan_mount_range(100, Some("bytes=abc")),
|
||||
MountRangePlan::Full
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_range_is_parsed_inclusive() {
|
||||
assert_eq!(
|
||||
plan_mount_range(100, Some("bytes=10-19")),
|
||||
MountRangePlan::Range { start: 10, end: 19 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_ended_range_extends_to_eof() {
|
||||
assert_eq!(
|
||||
plan_mount_range(100, Some("bytes=90-")),
|
||||
MountRangePlan::Range { start: 90, end: 99 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_range_counts_from_end() {
|
||||
// last 10 bytes of a 100-byte file => 90..=99
|
||||
assert_eq!(
|
||||
plan_mount_range(100, Some("bytes=-10")),
|
||||
MountRangePlan::Range { start: 90, end: 99 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_range_is_not_satisfiable() {
|
||||
assert_eq!(
|
||||
plan_mount_range(100, Some("bytes=200-300")),
|
||||
MountRangePlan::NotSatisfiable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ use axum::{
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
classify_display, format_file_size, intern_display, intern_mime,
|
||||
category_for, classify_display, format_file_size, icon_class_for, icon_special_class_for,
|
||||
intern_display, intern_mime,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
@@ -15,11 +16,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;
|
||||
|
||||
@@ -190,6 +197,22 @@ impl FolderHandler {
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = auth_user.id;
|
||||
|
||||
// External mounts have no trash — a permanent provider delete is the
|
||||
// only option. Route `ext:` ids straight to the mount-aware service
|
||||
// delete, skipping the (always-failing) trash attempt.
|
||||
if state.mount_router.is_mount_id(&id) {
|
||||
return match state
|
||||
.applications
|
||||
.folder_service
|
||||
.delete_folder_with_perms(&id, user_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if trash service is available
|
||||
// FIXME: permissions !!
|
||||
if let Some(trash_service) = &state.trash_service {
|
||||
@@ -481,6 +504,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
|
||||
@@ -585,3 +630,174 @@ 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()),
|
||||
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: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
};
|
||||
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),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(entry.size, entry.modified_at),
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
};
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin_external_mounts;
|
||||
pub mod admin_handler;
|
||||
pub mod app_password_handler;
|
||||
pub mod auth_handler;
|
||||
|
||||
Reference in New Issue
Block a user