feat(mounts): P2 — read-write REST for external mounts
Adds full CRUD on mount contents, mirroring the P1 read pattern (handlers/ services classify; authorization stays in the service via the mount-root folder grant; the provider does the I/O). - mkdir / rename / delete / move-within branch inside FolderService and FileManagementService (router injected into both) - streaming upload via a new ExternalUploadService: the upload handler detects a mount destination BEFORE the CAS ingest and streams the multipart body straight to the provider (no BLAKE3/dedup). `write_stream` now takes a lifetime-bound boxed stream so the borrowing multipart field can be passed without buffering. - deletes on mounts are permanent (no trash): the trash-first folder handler routes `ext:` ids straight to the provider delete; file delete goes through the branched delete_and_cleanup - cross-backend move/copy (mount ↔ native, or between mounts) is forbidden (UnsupportedOperation); the mount root itself cannot be renamed/moved/deleted - every mutation emits a `target:"audit" event="external_mount.write"` line - shared mount_dto builders synthesize FolderDto/FileDto from a provider MountStat Tests: 529 unit + integration tests for mkdir/rename/delete, file rename/delete, streaming upload, cross-boundary forbid, and stranger-denied — all against real Postgres + a real provider (testcontainers).
This commit is contained in:
@@ -240,6 +240,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
|
||||
|
||||
@@ -177,6 +177,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 {
|
||||
|
||||
Reference in New Issue
Block a user