2025-03-17 21:28:08 +01:00
use axum ::{
2026-02-14 12:45:46 +01:00
Json ,
2026-02-14 20:22:19 +01:00
extract ::{ Path , Query , State },
2026-06-21 03:17:34 +02:00
http ::{ Response , StatusCode , header },
2026-02-14 20:22:19 +01:00
response ::IntoResponse ,
2025-03-17 21:28:08 +01:00
};
2026-02-14 20:22:19 +01:00
use std ::sync ::Arc ;
2025-03-17 21:28:08 +01:00
2026-05-28 00:44:20 +02:00
use crate ::application ::dtos ::display_helpers ::{
2026-07-21 17:09:36 -06:00
category_for , classify_display , format_file_size , icon_class_for , icon_special_class_for ,
intern_display , intern_mime ,
2026-05-28 00:44:20 +02:00
};
use crate ::application ::dtos ::file_dto ::FileDto ;
2026-04-27 22:59:18 +02:00
use crate ::application ::dtos ::folder_dto ::{
2026-07-26 21:31:04 +02:00
CreateFolderDto , FolderAncestorsDto , FolderDto , FolderResourceItemDto , FolderResourcesDto ,
FolderResourcesQuery , ListResourcesOptions , MoveFolderDto , RenameFolderDto ,
2026-04-27 22:59:18 +02:00
};
2026-05-28 00:44:20 +02:00
use crate ::application ::dtos ::grant_dto ::{ ResourceContentDto , ResourceTypeDto };
2026-06-24 23:52:01 -06:00
use crate ::application ::ports ::external_mount_ports ::MountEntry ;
2026-05-20 15:39:53 +02:00
use crate ::application ::ports ::folder_ports ::FolderUseCase ;
2026-03-04 23:55:08 +01:00
use crate ::application ::ports ::trash_ports ::TrashUseCase ;
2026-06-24 23:52:01 -06:00
use crate ::application ::services ::external_mount_router ::ResolvedId ;
2026-02-14 20:22:19 +01:00
use crate ::application ::services ::folder_service ::FolderService ;
2026-06-24 23:52:01 -06:00
use crate ::application ::services ::mount_registry ::MountConfig ;
2025-03-26 18:33:22 +01:00
use crate ::common ::di ::AppState as GlobalAppState ;
2026-06-06 19:51:51 +02:00
use crate ::domain ::entities ::file ::File ;
2026-06-24 23:52:01 -06:00
use crate ::domain ::services ::external_mount_id ::{
NodeId , encode_child_id , virtual_file_etag , virtual_folder_etag ,
};
2026-03-05 13:15:34 +01:00
use crate ::interfaces ::errors ::AppError ;
2026-02-16 00:22:42 +01:00
use crate ::interfaces ::middleware ::auth ::AuthUser ;
2025-03-17 21:28:08 +01:00
type AppState = Arc < FolderService > ;
2026-07-22 00:36:14 +02:00
use crate ::interfaces ::api ::handlers ::caller_flags ::enrich_folder_flags ;
2025-03-17 21:28:08 +01:00
/// Handler for folder-related API endpoints
pub struct FolderHandler ;
impl FolderHandler {
2026-04-27 22:59:18 +02:00
// ── Why no #[utoipa::path] here? ─────────────────────────────────────────────
// utoipa 5.4.0's proc macro generates helper structs / impls inside its expansion.
// Rust allows struct definitions at module scope but forbids them inside impl blocks,
// so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP
// verb or annotation content. All route handlers are free functions below.
// TODO: collapse after utoipa upgrade.
2026-02-15 23:45:11 +01:00
/// Creates a new folder.
2026-02-14 12:45:46 +01:00
/// When parent_id is not provided, the folder is created inside the
2026-02-15 23:45:11 +01:00
/// authenticated user's home folder rather than at the storage root.
2026-04-27 22:59:18 +02:00
pub ( super ) async fn create_folder_impl (
2026-07-22 00:36:14 +02:00
State ( state ) : State < Arc < GlobalAppState >> ,
2026-02-14 12:45:46 +01:00
auth_user : AuthUser ,
Json ( mut dto ) : Json < CreateFolderDto > ,
2025-03-17 21:28:08 +01:00
) -> impl IntoResponse {
2026-07-22 00:36:14 +02:00
let service = & state . applications . folder_service_concrete ;
2026-02-14 12:45:46 +01:00
// If no parent_id was supplied, resolve the user's home folder as
// the default parent so the new folder is nested correctly.
if dto . parent_id . is_none () {
tracing ::info! (
2026-02-15 23:45:11 +01:00
"create_folder: parent_id is None for user '{}', resolving home folder" ,
auth_user . username
2026-02-14 12:45:46 +01:00
);
2026-05-21 11:07:04 +02:00
match service . list_folders_with_perms ( None , auth_user . id ). await {
2026-02-14 12:45:46 +01:00
Ok ( folders ) => {
2026-02-15 23:45:11 +01:00
if let Some ( home ) = folders . first () {
2026-02-14 12:45:46 +01:00
tracing ::info! (
"create_folder: resolved home folder ID '{}' for user '{}'" ,
2026-02-14 20:22:19 +01:00
home . id ,
auth_user . username
2026-02-14 12:45:46 +01:00
);
dto . parent_id = Some ( home . id . clone ());
} else {
tracing ::warn! (
2026-02-15 23:45:11 +01:00
"create_folder: home folder not found for user '{}', folder will be created at root" ,
auth_user . username
2026-02-14 12:45:46 +01:00
);
}
}
Err ( e ) => {
2026-02-14 20:22:19 +01:00
tracing ::error! (
"create_folder: failed to list folders for home resolution: {}" ,
e
);
2026-02-14 12:45:46 +01:00
}
}
}
2026-05-20 15:39:53 +02:00
match service . create_folder_with_perms ( dto , auth_user . id ). await {
2026-07-22 00:36:14 +02:00
Ok ( mut folder ) => {
enrich_folder_flags ( & state , & mut folder , auth_user . id ). await ;
( StatusCode ::CREATED , Json ( folder )). into_response ()
}
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2025-03-17 21:28:08 +01:00
}
}
2026-02-14 20:22:19 +01:00
2026-02-15 23:45:11 +01:00
/// Gets a folder by ID.
/// Validates that the authenticated user owns the folder.
2026-04-27 22:59:18 +02:00
pub ( super ) async fn get_folder_impl (
2026-07-22 00:36:14 +02:00
State ( state ) : State < Arc < GlobalAppState >> ,
2026-02-15 23:45:11 +01:00
auth_user : AuthUser ,
2025-03-17 21:28:08 +01:00
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
2026-07-22 00:36:14 +02:00
let service = & state . applications . folder_service_concrete ;
2026-05-21 11:07:04 +02:00
match service . get_folder_with_perms ( & id , auth_user . id ). await {
2026-07-22 00:36:14 +02:00
Ok ( mut folder ) => {
enrich_folder_flags ( & state , & mut folder , auth_user . id ). await ;
( StatusCode ::OK , Json ( folder )). into_response ()
}
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2025-03-17 21:28:08 +01:00
}
}
2026-02-14 20:22:19 +01:00
2026-07-26 21:31:04 +02:00
/// `GET /api/folders/{id}/ancestors` — parent-chain + access-source
/// for the shared breadcrumb component. See `FolderAncestorsDto`
/// for the response shape. Anti-enum via `NotFound` on Read denial.
pub ( super ) async fn get_folder_ancestors_impl (
State ( state ) : State < Arc < GlobalAppState >> ,
auth_user : AuthUser ,
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
let service = & state . applications . folder_service_concrete ;
match service . get_ancestors_with_perms ( & id , auth_user . id ). await {
Ok ( dto ) => ( StatusCode ::OK , Json ( dto )). into_response (),
Err ( err ) => AppError ::from ( err ). into_response (),
}
}
2026-02-15 23:45:11 +01:00
/// Lists root folders for the authenticated user.
/// Only returns folders owned by this user — no information disclosure.
2026-04-27 22:59:18 +02:00
pub ( super ) async fn list_root_folders_impl (
2026-02-08 13:40:23 +01:00
State ( service ) : State < AppState > ,
2026-02-13 22:31:05 +01:00
auth_user : AuthUser ,
2026-02-13 23:00:16 +01:00
) -> axum ::response ::Response {
2026-02-15 23:45:11 +01:00
Self ::list_folders_scoped ( service , None , & auth_user ). await
2026-02-08 13:40:23 +01:00
}
2026-07-02 01:06:50 +02:00
/// Internal helper: lists folders the authenticated caller can Read.
/// Post-PR-B, `list_root_folders_for_caller` scopes via
/// drive-membership grants (`role_grants` + group cascade via
/// `storage.caller_group_ids`) instead of the legacy `folders.user_id`
/// filter, so folders in shared drives the caller belongs to
/// surface here too.
2026-02-15 23:45:11 +01:00
async fn list_folders_scoped (
2026-02-13 23:00:16 +01:00
service : AppState ,
2026-02-13 22:31:05 +01:00
parent_id : Option <& str > ,
auth_user : & AuthUser ,
2026-02-13 23:00:16 +01:00
) -> axum ::response ::Response {
2026-02-21 13:33:18 +01:00
match service
2026-05-21 11:07:04 +02:00
. list_folders_with_perms ( parent_id , auth_user . id )
2026-02-21 13:33:18 +01:00
. await
{
2026-02-15 23:45:11 +01:00
Ok ( folders ) => ( StatusCode ::OK , Json ( folders )). into_response (),
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2026-02-16 21:51:53 +01:00
}
}
2026-04-27 22:59:18 +02:00
/// Renames a folder (ownership enforced).
pub ( super ) async fn rename_folder_impl (
2026-07-22 00:36:14 +02:00
State ( state ) : State < Arc < GlobalAppState >> ,
2026-02-16 00:22:42 +01:00
auth_user : AuthUser ,
2025-03-17 21:28:08 +01:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < RenameFolderDto > ,
) -> impl IntoResponse {
2026-07-22 00:36:14 +02:00
let service = & state . applications . folder_service_concrete ;
2026-05-20 15:39:53 +02:00
match service
. rename_folder_with_perms ( & id , dto , auth_user . id )
. await
{
2026-07-22 00:36:14 +02:00
Ok ( mut folder ) => {
enrich_folder_flags ( & state , & mut folder , auth_user . id ). await ;
( StatusCode ::OK , Json ( folder )). into_response ()
}
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2025-03-17 21:28:08 +01:00
}
}
2026-02-14 20:22:19 +01:00
2026-04-27 22:59:18 +02:00
/// Moves a folder to a new parent (ownership enforced).
pub ( super ) async fn move_folder_impl (
2026-07-22 00:36:14 +02:00
State ( state ) : State < Arc < GlobalAppState >> ,
2026-02-16 00:22:42 +01:00
auth_user : AuthUser ,
2025-03-17 21:28:08 +01:00
Path ( id ) : Path < String > ,
Json ( dto ) : Json < MoveFolderDto > ,
) -> impl IntoResponse {
2026-07-22 00:36:14 +02:00
let service = & state . applications . folder_service_concrete ;
2026-05-20 15:39:53 +02:00
match service . move_folder_with_perms ( & id , dto , auth_user . id ). await {
2026-07-22 00:36:14 +02:00
Ok ( mut folder ) => {
enrich_folder_flags ( & state , & mut folder , auth_user . id ). await ;
( StatusCode ::OK , Json ( folder )). into_response ()
}
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2025-03-17 21:28:08 +01:00
}
}
2026-02-14 20:22:19 +01:00
2026-02-16 00:22:42 +01:00
/// Deletes a folder (ownership enforced by service layer)
2025-03-17 21:28:08 +01:00
pub async fn delete_folder (
State ( service ) : State < AppState > ,
2026-02-16 00:22:42 +01:00
auth_user : AuthUser ,
2025-03-17 21:28:08 +01:00
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
2026-05-20 15:39:53 +02:00
match service . delete_folder_with_perms ( & id , auth_user . id ). await {
2025-03-17 21:28:08 +01:00
Ok ( _ ) => StatusCode ::NO_CONTENT . into_response (),
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2025-03-17 21:28:08 +01:00
}
}
2026-02-14 20:22:19 +01:00
2026-04-27 22:59:18 +02:00
/// Deletes a folder (moves to trash if enabled, otherwise permanent).
pub ( super ) async fn delete_folder_with_trash_impl (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < GlobalAppState >> ,
2026-02-16 00:22:42 +01:00
auth_user : AuthUser ,
2025-03-26 18:33:22 +01:00
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
2026-03-07 14:59:32 +01:00
let user_id = auth_user . id ;
2026-06-25 00:30:10 -06:00
// 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 (),
};
}
2025-03-26 18:33:22 +01:00
// Check if trash service is available
2026-05-21 11:07:04 +02:00
// FIXME: permissions !!
2025-03-26 18:33:22 +01:00
if let Some ( trash_service ) = & state . trash_service {
tracing ::info! ( "Moving folder to trash: {}" , id );
2026-02-14 20:22:19 +01:00
2025-03-26 18:33:22 +01:00
// Try to move to trash first
2026-02-13 08:54:51 +01:00
match trash_service . move_to_trash ( & id , "folder" , user_id ). await {
2025-03-26 18:33:22 +01:00
Ok ( _ ) => {
tracing ::info! ( "Folder successfully moved to trash: {}" , id );
return StatusCode ::NO_CONTENT . into_response ();
2026-02-14 20:22:19 +01:00
}
2025-03-26 18:33:22 +01:00
Err ( err ) => {
2026-02-14 20:22:19 +01:00
tracing ::warn! (
"Could not move folder to trash, falling back to permanent delete: {}" ,
err
);
2025-03-26 18:33:22 +01:00
// Fall through to regular delete if trash fails
}
}
}
2026-02-14 20:22:19 +01:00
2025-03-26 18:33:22 +01:00
// Fallback to permanent delete if trash is unavailable or failed
let folder_service = & state . applications . folder_service ;
2026-05-20 15:39:53 +02:00
match folder_service . delete_folder_with_perms ( & id , user_id ). await {
2025-03-26 18:33:22 +01:00
Ok ( _ ) => {
tracing ::info! ( "Folder permanently deleted: {}" , id );
StatusCode ::NO_CONTENT . into_response ()
2026-02-14 20:22:19 +01:00
}
2026-03-05 21:28:51 +01:00
Err ( err ) => AppError ::from ( err ). into_response (),
2025-03-26 18:33:22 +01:00
}
}
2026-02-14 20:22:19 +01:00
2026-04-27 22:59:18 +02:00
/// Downloads a folder and all its contents as a ZIP archive.
pub ( super ) async fn download_folder_zip_impl (
2026-02-24 15:11:56 +01:00
State ( state ) : State < Arc < GlobalAppState >> ,
2026-02-16 00:22:42 +01:00
auth_user : AuthUser ,
2025-04-02 01:22:05 +02:00
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
tracing ::info! ( "Downloading folder as ZIP: {}" , id );
2026-02-14 20:22:19 +01:00
2026-02-16 00:22:42 +01:00
// Get folder information and verify ownership
2025-04-02 01:22:05 +02:00
let folder_service = & state . applications . folder_service ;
2026-02-14 20:22:19 +01:00
2026-05-21 11:07:04 +02:00
match folder_service
. get_folder_with_perms ( & id , auth_user . id )
. await
{
2025-04-02 01:22:05 +02:00
Ok ( folder ) => {
tracing ::info! ( "Preparing ZIP for folder: {} ({})" , folder . name , id );
2026-02-14 20:22:19 +01:00
2026-02-08 13:40:23 +01:00
// Use ZIP service from DI container
2026-03-03 15:36:42 +00:00
let zip_service = match & state . core . zip_service {
Some ( svc ) => svc ,
None => {
tracing ::error! ( "ZipService not initialized" );
return (
StatusCode ::INTERNAL_SERVER_ERROR ,
Json ( serde_json ::json! ({ "error" : "ZipService not initialized" })),
)
. into_response ();
}
};
2026-02-14 20:22:19 +01:00
2026-07-16 16:50:07 +00:00
// Stream the archive as it is built — the first byte reaches
// the client after the first entry, not after the whole ZIP
// exists on disk (benches/ZIP-STREAM.md). No Content-Length:
// the final size isn't known up front (chunked encoding).
match zip_service
. create_folder_zip_stream ( & id , & folder . name )
. await
{
Ok ( stream ) => {
2026-02-22 22:29:07 +01:00
let body = axum ::body ::Body ::from_stream ( stream );
2025-04-02 01:22:05 +02:00
// Setup headers for download
let filename = format! ( " {} .zip" , folder . name );
let content_disposition = format! ( "attachment; filename= \" {} \" " , filename );
2026-02-14 20:22:19 +01:00
2026-07-16 16:50:07 +00:00
Response ::builder ()
2025-04-02 01:22:05 +02:00
. status ( StatusCode ::OK )
2026-02-22 22:29:07 +01:00
. header ( header ::CONTENT_TYPE , "application/zip" )
. header ( header ::CONTENT_DISPOSITION , content_disposition )
. body ( body )
2026-07-16 16:50:07 +00:00
. unwrap ()
. into_response ()
2026-02-14 20:22:19 +01:00
}
2025-04-02 01:22:05 +02:00
Err ( err ) => {
tracing ::error! ( "Error creating ZIP file: {}" , err );
2026-03-05 13:15:34 +01:00
AppError ::internal_error ( format! ( "Error creating ZIP file: {} " , err ))
2026-02-14 20:22:19 +01:00
. into_response ()
2025-04-02 01:22:05 +02:00
}
}
2026-02-14 20:22:19 +01:00
}
2025-04-02 01:22:05 +02:00
Err ( err ) => {
tracing ::error! ( "Folder not found: {}" , err );
2026-03-05 13:15:34 +01:00
AppError ::from ( err ). into_response ()
2025-04-02 01:22:05 +02:00
}
}
}
2026-02-14 20:22:19 +01:00
}
2026-04-27 22:59:18 +02:00
// ── Route handlers (free functions) ──────────────────────────────────────────
//
// All annotated route functions live here rather than as methods on FolderHandler
// because utoipa 5.4.0's #[utoipa::path] macro generates helper structs inside
// its expansion. Rust allows struct definitions at module scope but forbids them
// inside impl blocks — so every #[utoipa::path] annotation on a FolderHandler
// method fails to compile regardless of HTTP verb or annotation content.
//
// All logic lives in the FolderHandler::*_impl methods above; these thin wrappers
// exist solely to carry the OpenAPI annotation at a scope where utoipa can
// generate its helper types.
//
// routes.rs calls these free functions directly.
// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue.
#[utoipa::path(
post,
path = "/api/folders" ,
request_body(content = CreateFolderDto, content_type = "application/json" , description = "Folder creation payload" ),
responses(
(status = 201, description = "Folder created" , body = FolderDto),
(status = 400, description = "Invalid request" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn create_folder (
2026-07-22 00:36:14 +02:00
state : State < Arc < GlobalAppState >> ,
2026-04-27 22:59:18 +02:00
auth_user : AuthUser ,
json : Json < CreateFolderDto > ,
) -> impl IntoResponse {
FolderHandler ::create_folder_impl ( state , auth_user , json ). await
}
#[utoipa::path(
get,
path = "/api/folders/{id}" ,
params(( "id" = String, Path, description = "Folder ID" )),
responses(
(status = 200, description = "Folder" , body = FolderDto),
(status = 404, description = "Folder not found" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn get_folder (
2026-07-22 00:36:14 +02:00
state : State < Arc < GlobalAppState >> ,
2026-04-27 22:59:18 +02:00
auth_user : AuthUser ,
path : Path < String > ,
) -> impl IntoResponse {
FolderHandler ::get_folder_impl ( state , auth_user , path ). await
}
2026-07-26 21:31:04 +02:00
#[utoipa::path(
get,
path = "/api/folders/{id}/ancestors" ,
params(( "id" = String, Path, description = "Leaf folder ID — the walk starts here and climbs the parent chain up to the drive root or the caller's share/drive-membership boundary." )),
responses(
(status = 200, description = "Ancestor chain + access-source. `ancestors` is root-first, leaf-last (length ≥ 1). See `FolderAncestorsDto`." , body = FolderAncestorsDto),
(status = 404, description = "Folder not found or caller lacks Read (anti-enum)" ),
),
security(( "bearerAuth" = [])),
tag = "folders"
)]
pub async fn get_folder_ancestors (
state : State < Arc < GlobalAppState >> ,
auth_user : AuthUser ,
path : Path < String > ,
) -> impl IntoResponse {
FolderHandler ::get_folder_ancestors_impl ( state , auth_user , path ). await
}
2026-04-27 22:59:18 +02:00
#[utoipa::path(
get,
path = "/api/folders" ,
responses(
(status = 200, description = "List of root folders" , body = Vec<FolderDto>),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn list_root_folders (
state : State < AppState > ,
auth_user : AuthUser ,
) -> axum ::response ::Response {
FolderHandler ::list_root_folders_impl ( state , auth_user ). await
}
#[utoipa::path(
put,
path = "/api/folders/{id}/rename" ,
params(( "id" = String, Path, description = "Folder ID" )),
request_body(content = RenameFolderDto, content_type = "application/json" , description = "Rename payload" ),
responses(
(status = 200, description = "Renamed folder" , body = FolderDto),
(status = 404, description = "Folder not found" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn rename_folder (
2026-07-22 00:36:14 +02:00
state : State < Arc < GlobalAppState >> ,
2026-04-27 22:59:18 +02:00
auth_user : AuthUser ,
path : Path < String > ,
json : Json < RenameFolderDto > ,
) -> impl IntoResponse {
FolderHandler ::rename_folder_impl ( state , auth_user , path , json ). await
}
#[utoipa::path(
put,
path = "/api/folders/{id}/move" ,
params(( "id" = String, Path, description = "Folder ID" )),
request_body(content = MoveFolderDto, content_type = "application/json" , description = "Move payload" ),
responses(
(status = 200, description = "Moved folder" , body = FolderDto),
(status = 404, description = "Folder or destination not found" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn move_folder (
2026-07-22 00:36:14 +02:00
state : State < Arc < GlobalAppState >> ,
2026-04-27 22:59:18 +02:00
auth_user : AuthUser ,
path : Path < String > ,
json : Json < MoveFolderDto > ,
) -> impl IntoResponse {
FolderHandler ::move_folder_impl ( state , auth_user , path , json ). await
}
#[utoipa::path(
delete,
path = "/api/folders/{id}" ,
params(( "id" = String, Path, description = "Folder ID" )),
responses(
(status = 204, description = "Folder deleted" ),
(status = 404, description = "Folder not found" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn delete_folder_with_trash (
state : State < Arc < GlobalAppState >> ,
auth_user : AuthUser ,
path : Path < String > ,
) -> impl IntoResponse {
FolderHandler ::delete_folder_with_trash_impl ( state , auth_user , path ). await
}
#[utoipa::path(
get,
path = "/api/folders/{id}/download" ,
params(( "id" = String, Path, description = "Folder ID" )),
responses(
(status = 200, description = "ZIP archive stream (application/zip)" ),
(status = 404, description = "Folder not found" ),
(status = 501, description = "ZIP service not available" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-04-27 22:59:18 +02:00
tag = "folders"
)]
pub async fn download_folder_zip (
state : State < Arc < GlobalAppState >> ,
auth_user : AuthUser ,
path : Path < String > ,
) -> impl IntoResponse {
2026-07-21 00:07:44 +00:00
// No `Query` extractor: the handler reads only the path `id`. axum ignores
// any query string when no extractor is present, so the response is
// byte-identical while a per-request HashMap + owned key/value Strings are
// no longer parsed and dropped (benches/ROUND25.md §M3).
FolderHandler ::download_folder_zip_impl ( state , auth_user , path ). await
2026-04-27 22:59:18 +02:00
}
2026-05-28 00:44:20 +02:00
// ── GET /api/folders/{id}/resources ─────────────────────────────────────────
#[utoipa::path(
get,
path = "/api/folders/{id}/resources" ,
params(
( "id" = String, Path, description = "Folder ID" ),
FolderResourcesQuery,
),
responses(
(status = 200,
description = "Cursor-paginated files and folders inside the requested folder. \
Items arrive in `order_by` order (folders first when order_by=name). \
`next_cursor` is absent on the last page." ,
body = FolderResourcesDto),
(status = 404, description = "Folder not found or access denied" ),
),
2026-05-31 00:26:21 +02:00
security(( "bearerAuth" = [])),
2026-05-28 00:44:20 +02:00
tag = "folders"
)]
pub async fn list_folder_resources (
State ( service ) : State < AppState > ,
auth_user : AuthUser ,
Path ( id ) : Path < String > ,
Query ( q ) : Query < FolderResourcesQuery > ,
) -> impl IntoResponse {
let order_by = q . order_by . clone (). unwrap_or_else ( || "name" . to_owned ());
let kinds = q . resource_kinds ();
let opts = ListResourcesOptions {
limit : q . limit_clamped (),
cursor : q . decode_cursor (),
order_by : & order_by ,
kinds : kinds . as_deref (),
reverse : q . reverse ,
};
2026-06-24 23:52:01 -06:00
// 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 => {}
}
2026-05-28 00:44:20 +02:00
match service
. list_resources_paged_with_perms ( & id , auth_user . id , opts )
. await
{
Ok (( rows , next_cursor )) => {
let items : Vec < FolderResourceItemDto > = rows
. into_iter ()
. map ( | row | {
if row . resource_type == "folder" {
2026-06-06 15:37:27 +02:00
let resource_id = row . id . to_string ();
2026-05-28 00:44:20 +02:00
let dto = FolderDto {
2026-06-06 15:37:27 +02:00
etag : resource_id . clone (),
id : resource_id ,
2026-07-18 13:11:41 +00:00
// Folders use fixed icon classes (below), so `name`
// is never borrowed again — move it instead of cloning.
name : row . name ,
2026-05-28 00:44:20 +02:00
path : String ::new (), // cleared — share recipients must not see hierarchy
parent_id : row . parent_id . map ( | u | u . to_string ()),
2026-06-29 21:55:47 +02:00
drive_id : row . drive_id ,
2026-05-28 00:44:20 +02:00
created_at : row . created_at . timestamp () as u64 ,
modified_at : row . modified_at . timestamp () as u64 ,
is_root : false ,
2026-07-17 15:19:00 +00:00
icon_class : intern_display ( "fas fa-folder" ),
icon_special_class : intern_display ( "folder-icon" ),
category : intern_display ( "Folder" ),
2026-07-20 19:52:51 +02:00
created_by : row . created_by ,
updated_by : row . updated_by ,
2026-07-22 00:36:14 +02:00
is_favorite : row . is_favorite ,
is_shared : row . is_shared ,
2026-05-28 00:44:20 +02:00
};
FolderResourceItemDto {
resource_type : ResourceTypeDto ::Folder ,
resource : ResourceContentDto ::Folder ( dto ),
}
} else {
let mime = row
. mime_type
. as_deref ()
. unwrap_or ( "application/octet-stream" );
let size_bytes = row . size . max ( 0 ) as u64 ;
2026-06-06 19:51:51 +02:00
// `blob_hash` is `Some(_)` for file rows in the
// UNION ALL (`NULL` for folders). Route the
// ETag formula through `File::compute_etag` —
// the single source of truth shared with
// GET/HEAD/PROPFIND/PUT response — so this
// listing's `etag` byte-equals what a
// conditional request would compare against.
let modified_at_u = row . modified_at . timestamp () as u64 ;
2026-07-18 16:12:04 +00:00
let content_hash = row . blob_hash . unwrap_or_default ();
2026-06-06 19:51:51 +02:00
let etag = if content_hash . is_empty () {
String ::new ()
} else {
File ::compute_etag ( & content_hash , modified_at_u )
};
2026-07-18 13:11:41 +00:00
// Compute the name-derived icon/category classes first
// (they borrow `&row.name`), so `name` can be moved into
// the DTO below instead of cloned — one fewer String
// alloc per file row (benches/ROUND7.md).
2026-07-18 22:02:00 +00:00
let classes = classify_display ( & row . name , mime );
let icon_class = intern_display ( classes . icon_class );
let icon_special_class = intern_display ( classes . icon_special_class );
let category = intern_display ( classes . category );
2026-05-28 00:44:20 +02:00
let dto = FileDto {
id : row . id . to_string (),
2026-07-18 13:11:41 +00:00
name : row . name ,
2026-05-28 00:44:20 +02:00
path : String ::new (),
size : size_bytes ,
2026-07-17 15:19:00 +00:00
mime_type : intern_mime ( mime ),
2026-05-28 00:44:20 +02:00
folder_id : row . parent_id . map ( | u | u . to_string ()),
created_at : row . created_at . timestamp () as u64 ,
modified_at : row . modified_at . timestamp () as u64 ,
2026-07-18 13:11:41 +00:00
icon_class ,
icon_special_class ,
category ,
2026-05-28 00:44:20 +02:00
size_formatted : format_file_size ( size_bytes ),
sort_date : None ,
2026-06-06 19:51:51 +02:00
content_hash ,
etag ,
2026-07-20 19:52:51 +02:00
created_by : row . created_by ,
updated_by : row . updated_by ,
2026-07-22 00:36:14 +02:00
is_favorite : row . is_favorite ,
is_shared : row . is_shared ,
2026-05-28 00:44:20 +02:00
};
FolderResourceItemDto {
resource_type : ResourceTypeDto ::File ,
resource : ResourceContentDto ::File ( dto ),
}
}
})
. collect ();
2026-07-19 01:32:00 +00:00
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let body = FolderResourcesDto ::with_cursor ( items , next_cursor );
crate ::interfaces ::api ::sized_json ::sized_json (
128 + body . items . len ()
* crate ::interfaces ::api ::sized_json ::EST_WRAPPED_ROW_BYTES ,
& body ,
)
}
2026-05-28 00:44:20 +02:00
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
2026-06-24 23:52:01 -06:00
/// 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" ),
2026-07-21 17:09:36 -06:00
created_by : Some ( cfg . owner_id ),
updated_by : Some ( cfg . owner_id ),
2026-07-22 08:30:02 +02:00
// Mount entries use synthetic `ext:*` ids — no favorites /
// grants rows can ever key against them.
is_favorite : false ,
is_shared : false ,
2026-06-24 23:52:01 -06:00
};
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 ),
2026-07-21 17:09:36 -06:00
created_by : Some ( cfg . owner_id ),
updated_by : Some ( cfg . owner_id ),
2026-07-22 08:30:02 +02:00
is_favorite : false ,
is_shared : false ,
2026-06-24 23:52:01 -06:00
};
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" );
}
}