diff --git a/src/common/di.rs b/src/common/di.rs index 680c9b15..aeb24aaf 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -542,6 +542,7 @@ impl AppServiceFactory { wopi_discovery_service: None, device_auth_service: None, app_password_service: None, + path_resolver: None, }; // 9b. Wire admin settings service when auth is available @@ -651,6 +652,13 @@ impl AppServiceFactory { } } + // 9e. Wire PathResolver for single-query WebDAV path resolution + { + use crate::infrastructure::services::path_resolver_service::PathResolverService; + app_state.path_resolver = Some(Arc::new(PathResolverService::new(pool.clone()))); + tracing::info!("PathResolver service initialized"); + } + // 10. Wire CalDAV/CardDAV services { // CalDAV @@ -846,6 +854,8 @@ pub struct AppState { Option>, pub app_password_service: Option>, + pub path_resolver: + Option>, } // All AppState construction is done via struct literal in build_app_state(). diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 3efe9479..df3e06fc 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -7,6 +7,7 @@ pub mod image_transcode_service; pub mod jwt_service; pub mod oidc_service; pub mod password_hasher; +pub mod path_resolver_service; pub mod path_service; pub mod thumbnail_service; pub mod trash_cleanup_service; diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs new file mode 100644 index 00000000..08521405 --- /dev/null +++ b/src/infrastructure/services/path_resolver_service.rs @@ -0,0 +1,205 @@ +//! Single-query WebDAV path resolver. +//! +//! Replaces the double-query pattern (`get_folder_by_path` + `get_file_by_path`) +//! with a single `UNION ALL` query that returns the first match. PostgreSQL's +//! `Append` node short-circuits on `LIMIT 1`, so if the folder branch matches +//! the file branch is never executed. + +use sqlx::PgPool; +use std::sync::Arc; + +use crate::application::dtos::display_helpers::{ + category_for, format_file_size, icon_class_for, icon_special_class_for, +}; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; +use crate::common::errors::DomainError; + +/// Result of resolving a WebDAV path — either a folder or a file. +#[derive(Debug, Clone)] +pub enum ResolvedResource { + Folder(FolderDto), + File(FileDto), +} + +/// Resolves a WebDAV path to a folder or file in a single SQL round-trip. +pub struct PathResolverService { + pool: Arc, +} + +impl PathResolverService { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + /// Resolve `path` (without leading `/`) to either a folder or a file. + /// + /// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated + /// first, and PG short-circuits if it produces a row. + pub async fn resolve_path(&self, path: &str) -> Result { + let path = path.trim_start_matches('/').trim_end_matches('/'); + if path.is_empty() { + return Err(DomainError::not_found("Resource", "empty path")); + } + + // Split into folder_path + filename for the file branch + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let filename = segments[segments.len() - 1]; + let folder_path = if segments.len() > 1 { + segments[..segments.len() - 1].join("/") + } else { + String::new() + }; + + // Single round-trip: folder branch ∪ file branch, LIMIT 1. + // Column order: resource_type, id, name, path, parent_id, user_id, + // created_at, modified_at, size, mime_type, folder_id + let row = sqlx::query_as::<_, ( + String, // resource_type + String, // id + String, // name + String, // path + Option, // parent_id (folder) / NULL (file) + Option, // user_id + i64, // created_at epoch + i64, // modified_at epoch + Option, // size (NULL for folder) + Option, // mime_type (NULL for folder) + Option, // folder_id (NULL for folder) + )>( + r#" + SELECT resource_type, id, name, path, parent_id, user_id, + created_at, modified_at, size, mime_type, folder_id + FROM ( + SELECT 'folder'::text AS resource_type, + fo.id::text, + fo.name, + fo.path, + fo.parent_id::text, + fo.user_id::text, + EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at, + EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, + NULL::bigint AS size, + NULL::text AS mime_type, + NULL::text AS folder_id + FROM storage.folders fo + WHERE fo.path = $1 AND NOT fo.is_trashed + + UNION ALL + + SELECT 'file'::text AS resource_type, + fi.id::text, + fi.name, + CASE + WHEN fo.path IS NOT NULL AND fo.path != '' + THEN fo.path || '/' || fi.name + ELSE fi.name + END AS path, + NULL::text AS parent_id, + fi.user_id::text, + EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at, + EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, + fi.size, + fi.mime_type, + fi.folder_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.name = $2 + AND ( + ($3 = '' AND fi.folder_id IS NULL) + OR fo.path = $3 + ) + AND NOT fi.is_trashed + ) sub + LIMIT 1 + "#, + ) + .bind(path) // $1 — full path for folder lookup + .bind(filename) // $2 — filename for file lookup + .bind(&folder_path) // $3 — parent folder path for file lookup + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))? + .ok_or_else(|| DomainError::not_found("Resource", path))?; + + let (resource_type, id, name, res_path, parent_id, user_id, + created_at, modified_at, size, mime_type, folder_id) = row; + + match resource_type.as_str() { + "folder" => Ok(ResolvedResource::Folder(FolderDto { + id, + name: name.clone(), + path: res_path, + parent_id, + owner_id: user_id, + created_at: created_at as u64, + modified_at: modified_at as u64, + is_root: false, + icon_class: "fas fa-folder".to_string(), + icon_special_class: "folder-icon".to_string(), + category: "Folder".to_string(), + })), + _ => { + let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); + let sz = size.unwrap_or(0) as u64; + Ok(ResolvedResource::File(FileDto { + id, + name: name.clone(), + path: res_path, + size: sz, + mime_type: mime.clone(), + folder_id, + created_at: created_at as u64, + modified_at: modified_at as u64, + icon_class: icon_class_for(&name, &mime).to_string(), + icon_special_class: icon_special_class_for(&name, &mime).to_string(), + category: category_for(&name, &mime).to_string(), + size_formatted: format_file_size(sz), + owner_id: user_id, + })) + } + } + } + + /// Check whether *any* resource (folder or file) exists at the given path. + /// + /// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO. + pub async fn exists(&self, path: &str) -> Result { + let path = path.trim_start_matches('/').trim_end_matches('/'); + if path.is_empty() { + return Ok(false); + } + + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let filename = segments[segments.len() - 1]; + let folder_path = if segments.len() > 1 { + segments[..segments.len() - 1].join("/") + } else { + String::new() + }; + + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM storage.folders + WHERE path = $1 AND NOT is_trashed + ) OR EXISTS( + SELECT 1 + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.name = $2 + AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3) + AND NOT fi.is_trashed + ) + "#, + ) + .bind(path) + .bind(filename) + .bind(&folder_path) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?; + + Ok(exists) + } +} diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index ca7fa198..7b3479e6 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -25,6 +25,7 @@ use crate::application::dtos::folder_dto::FolderDto; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::inbound::FolderUseCase; use crate::common::di::AppState; +use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet}; @@ -301,38 +302,73 @@ async fn handle_propfind( .await; } - // Try folder first - if let Ok(folder) = folder_service.get_folder_by_path(&path).await { - let folder_id = folder.id.clone(); - return build_streaming_propfind_response( - folder, - Some(folder_id), - &depth_owned, - &base_href, - propfind_request, - folder_service, - file_retrieval_service, - ) - .await; - } - - // Try file - if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await { - let mut buf = Vec::with_capacity(1024); - { - let mut xml_writer = Writer::new(&mut buf); - WebDavAdapter::write_multistatus_start(&mut xml_writer) - .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; - WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href) - .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; - WebDavAdapter::write_multistatus_end(&mut xml_writer) - .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + // Single-query path resolution: folder OR file in one DB round-trip + if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path(&path).await { + Ok(ResolvedResource::Folder(folder)) => { + let folder_id = folder.id.clone(); + return build_streaming_propfind_response( + folder, + Some(folder_id), + &depth_owned, + &base_href, + propfind_request, + folder_service, + file_retrieval_service, + ) + .await; + } + Ok(ResolvedResource::File(file)) => { + let mut buf = Vec::with_capacity(1024); + { + let mut xml_writer = Writer::new(&mut buf); + WebDavAdapter::write_multistatus_start(&mut xml_writer) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_multistatus_end(&mut xml_writer) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + } + return Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(buf)) + .unwrap()); + } + Err(_) => {} + } + } else { + // Fallback: legacy double-query path when PathResolver is unavailable + if let Ok(folder) = folder_service.get_folder_by_path(&path).await { + let folder_id = folder.id.clone(); + return build_streaming_propfind_response( + folder, + Some(folder_id), + &depth_owned, + &base_href, + propfind_request, + folder_service, + file_retrieval_service, + ) + .await; + } + if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await { + let mut buf = Vec::with_capacity(1024); + { + let mut xml_writer = Writer::new(&mut buf); + WebDavAdapter::write_multistatus_start(&mut xml_writer) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + WebDavAdapter::write_multistatus_end(&mut xml_writer) + .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; + } + return Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(buf)) + .unwrap()); } - return Ok(Response::builder() - .status(StatusCode::MULTI_STATUS) - .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(buf)) - .unwrap()); } Err(AppError::not_found(format!("Resource not found: {}", path))) @@ -596,7 +632,38 @@ async fn handle_head( .unwrap()); } - // Check if it's a folder first + // Single-query path resolution + if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path(&path).await { + Ok(ResolvedResource::Folder(folder)) => { + return Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "httpd/unix-directory") + .header(header::CONTENT_LENGTH, 0) + .header(header::ETAG, format!("\"{}\"", folder.id)) + .body(Body::empty()) + .unwrap()); + } + Ok(ResolvedResource::File(file)) => { + return Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &file.mime_type) + .header(header::CONTENT_LENGTH, file.size) + .header(header::ETAG, format!("\"{}\"", file.id)) + .header( + header::LAST_MODIFIED, + chrono::DateTime::::from_timestamp(file.created_at as i64, 0) + .unwrap_or_else(Utc::now) + .to_rfc2822(), + ) + .body(Body::empty()) + .unwrap()); + } + Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))), + } + } + + // Fallback: legacy double-query path if let Ok(folder) = folder_service.get_folder_by_path(&path).await { return Ok(Response::builder() .status(StatusCode::OK) @@ -835,27 +902,45 @@ async fn handle_delete( return Err(AppError::forbidden("Cannot delete root folder")); } - // Check if path is a folder - let folder_result = folder_service.get_folder_by_path(&path).await; - - if let Ok(folder) = folder_result { - // Delete folder — use the folder's own owner as caller_id - let caller_id = folder.owner_id.as_deref().unwrap_or("webdav"); - folder_service - .delete_folder(&folder.id, caller_id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; + // Single-query path resolution + if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path(&path).await { + Ok(ResolvedResource::Folder(folder)) => { + let caller_id = folder.owner_id.as_deref().unwrap_or("webdav"); + folder_service + .delete_folder(&folder.id, caller_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; + } + Ok(ResolvedResource::File(file)) => { + file_management_service + .delete_file(&file.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; + } + Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))), + } } else { - // Try to delete file - let file = file_retrieval_service - .get_file_by_path(&path) - .await - .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; + // Fallback: legacy double-query path + let folder_result = folder_service.get_folder_by_path(&path).await; - file_management_service - .delete_file(&file.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; + if let Ok(folder) = folder_result { + let caller_id = folder.owner_id.as_deref().unwrap_or("webdav"); + folder_service + .delete_folder(&folder.id, caller_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; + } else { + let file = file_retrieval_service + .get_file_by_path(&path) + .await + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; + + file_management_service + .delete_file(&file.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; + } } Ok(Response::builder() @@ -914,14 +999,12 @@ async fn handle_move( // Check if destination already exists (for Overwrite header compliance) if !overwrite { - let dest_exists = folder_service - .get_folder_by_path(&destination_path) - .await - .is_ok() - || file_retrieval_service - .get_file_by_path(&destination_path) - .await - .is_ok(); + let dest_exists = if let Some(resolver) = &state.path_resolver { + resolver.exists(&destination_path).await.unwrap_or(false) + } else { + folder_service.get_folder_by_path(&destination_path).await.is_ok() + || file_retrieval_service.get_file_by_path(&destination_path).await.is_ok() + }; if dest_exists { return Err(AppError::precondition_failed( "Destination already exists and Overwrite is F", @@ -929,94 +1012,166 @@ async fn handle_move( } } - // Check if source is a folder - let folder_result = folder_service.get_folder_by_path(&source_path).await; + // Resolve source: single-query when PathResolver is available + if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path(&source_path).await { + Ok(ResolvedResource::Folder(folder)) => { + let dest_folder_name = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; - if let Ok(folder) = folder_result { - // Move folder - let dest_folder_name = destination_path - .split('/') - .next_back() - .unwrap_or(&destination_path); - let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { - &destination_path[..idx] - } else { - "" - }; + let move_dto = crate::application::dtos::folder_dto::MoveFolderDto { + parent_id: if dest_parent_path.is_empty() { + None + } else { + match folder_service.get_folder_by_path(dest_parent_path).await { + Ok(parent) => Some(parent.id), + Err(_) => None, + } + }, + }; - // Create DTOs for moving and renaming - let move_dto = crate::application::dtos::folder_dto::MoveFolderDto { - parent_id: if dest_parent_path.is_empty() { - None - } else { - match folder_service.get_folder_by_path(dest_parent_path).await { - Ok(parent) => Some(parent.id), - Err(_) => None, // If not found, use root + folder_service + .move_folder( + &folder.id, + move_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) + .await + .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; + + if folder.name != dest_folder_name { + let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto { + name: dest_folder_name.to_string(), + }; + folder_service + .rename_folder( + &folder.id, + rename_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) + .await + .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; } - }, - }; + } + Ok(ResolvedResource::File(file)) => { + let dest_filename = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; + let source_parent_path = if let Some(idx) = source_path.rfind('/') { + &source_path[..idx] + } else { + "" + }; - folder_service - .move_folder( - &folder.id, - move_dto, - folder.owner_id.as_deref().unwrap_or("webdav"), - ) - .await - .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; + if source_parent_path != dest_parent_path { + file_management_service + .move_file(&file.id, Some(dest_parent_path.to_string())) + .await + .map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?; + } + if file.name != dest_filename { + file_management_service + .rename_file(&file.id, dest_filename) + .await + .map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?; + } + } + Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))), + } + } else { + // Fallback: legacy double-query path + let folder_result = folder_service.get_folder_by_path(&source_path).await; - if folder.name != dest_folder_name { - let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto { - name: dest_folder_name.to_string(), + if let Ok(folder) = folder_result { + let dest_folder_name = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; + + let move_dto = crate::application::dtos::folder_dto::MoveFolderDto { + parent_id: if dest_parent_path.is_empty() { + None + } else { + match folder_service.get_folder_by_path(dest_parent_path).await { + Ok(parent) => Some(parent.id), + Err(_) => None, + } + }, }; folder_service - .rename_folder( + .move_folder( &folder.id, - rename_dto, + move_dto, folder.owner_id.as_deref().unwrap_or("webdav"), ) .await - .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; - } - } else { - // Try to move file - let file = file_retrieval_service - .get_file_by_path(&source_path) - .await - .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; + .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; - let dest_filename = destination_path - .split('/') - .next_back() - .unwrap_or(&destination_path); - let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { - &destination_path[..idx] + if folder.name != dest_folder_name { + let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto { + name: dest_folder_name.to_string(), + }; + folder_service + .rename_folder( + &folder.id, + rename_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) + .await + .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; + } } else { - "" - }; - - // Determine source parent path for comparison - let source_parent_path = if let Some(idx) = source_path.rfind('/') { - &source_path[..idx] - } else { - "" - }; - - // Only call move_file if the parent directory actually changes - if source_parent_path != dest_parent_path { - file_management_service - .move_file(&file.id, Some(dest_parent_path.to_string())) + let file = file_retrieval_service + .get_file_by_path(&source_path) .await - .map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?; - } + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; - // Rename the file if the name changed - if file.name != dest_filename { - file_management_service - .rename_file(&file.id, dest_filename) - .await - .map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?; + let dest_filename = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; + let source_parent_path = if let Some(idx) = source_path.rfind('/') { + &source_path[..idx] + } else { + "" + }; + + if source_parent_path != dest_parent_path { + file_management_service + .move_file(&file.id, Some(dest_parent_path.to_string())) + .await + .map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?; + } + if file.name != dest_filename { + file_management_service + .rename_file(&file.id, dest_filename) + .await + .map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?; + } } } @@ -1082,14 +1237,12 @@ async fn handle_copy( // Check if destination already exists (for Overwrite header compliance) if !overwrite { - let dest_exists = folder_service - .get_folder_by_path(&destination_path) - .await - .is_ok() - || file_retrieval_service - .get_file_by_path(&destination_path) - .await - .is_ok(); + let dest_exists = if let Some(resolver) = &state.path_resolver { + resolver.exists(&destination_path).await.unwrap_or(false) + } else { + folder_service.get_folder_by_path(&destination_path).await.is_ok() + || file_retrieval_service.get_file_by_path(&destination_path).await.is_ok() + }; if dest_exists { return Err(AppError::precondition_failed( "Destination already exists and Overwrite is F", @@ -1097,90 +1250,157 @@ async fn handle_copy( } } - // Check if source is a folder - let folder_result = folder_service.get_folder_by_path(&source_path).await; + // Resolve source: single-query when PathResolver is available + if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path(&source_path).await { + Ok(ResolvedResource::Folder(folder)) => { + let recursive = depth != "0"; - if let Ok(folder) = folder_result { - // Copy folder - let recursive = depth != "0"; + let dest_folder_name = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; - let dest_folder_name = destination_path - .split('/') - .next_back() - .unwrap_or(&destination_path); - let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { - &destination_path[..idx] - } else { - "" - }; + let target_parent_id = if dest_parent_path.is_empty() { + None + } else { + match folder_service.get_folder_by_path(dest_parent_path).await { + Ok(parent) => Some(parent.id), + Err(_) => None, + } + }; - let target_parent_id = if dest_parent_path.is_empty() { - None - } else { - match folder_service.get_folder_by_path(dest_parent_path).await { - Ok(parent) => Some(parent.id), - Err(_) => None, + if recursive { + let file_management_service = &state.applications.file_management_service; + file_management_service + .copy_folder_tree( + &folder.id, + target_parent_id, + Some(dest_folder_name.to_string()), + ) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to copy folder tree: {}", e)) + })?; + } else { + let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { + name: dest_folder_name.to_string(), + parent_id: target_parent_id, + }; + folder_service + .create_folder(create_dto) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to create destination folder: {}", e)) + })?; + } } - }; + Ok(ResolvedResource::File(file)) => { + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; - if recursive { - // Atomic recursive copy: single SQL function call copies the entire - // folder tree (all sub-folders + all files) with zero-copy dedup. - // O(depth) folder INSERTs + 1 batch file INSERT + 1 batch ref_count UPDATE. - let file_management_service = &state.applications.file_management_service; - file_management_service - .copy_folder_tree( - &folder.id, - target_parent_id, - Some(dest_folder_name.to_string()), - ) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to copy folder tree: {}", e)) - })?; - } else { - // Depth: 0 — create empty folder only (no sub-folder or file copy) - let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { - name: dest_folder_name.to_string(), - parent_id: target_parent_id, - }; - folder_service - .create_folder(create_dto) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to create destination folder: {}", e)) - })?; + let target_folder_id = if dest_parent_path.is_empty() { + None + } else { + match folder_service.get_folder_by_path(dest_parent_path).await { + Ok(parent) => Some(parent.id), + Err(_) => None, + } + }; + + let file_management_service = &state.applications.file_management_service; + file_management_service + .copy_file(&file.id, target_folder_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; + } + Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))), } } else { - // Copy file — use zero-copy dedup (only increments blob ref_count, no content loaded) - let file = file_retrieval_service - .get_file_by_path(&source_path) - .await - .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; + // Fallback: legacy double-query path + let folder_result = folder_service.get_folder_by_path(&source_path).await; - // Get destination parent folder ID - let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { - &destination_path[..idx] - } else { - "" - }; + if let Ok(folder) = folder_result { + let recursive = depth != "0"; - let target_folder_id = if dest_parent_path.is_empty() { - None - } else { - match folder_service.get_folder_by_path(dest_parent_path).await { - Ok(parent) => Some(parent.id), - Err(_) => None, + let dest_folder_name = destination_path + .split('/') + .next_back() + .unwrap_or(&destination_path); + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; + + let target_parent_id = if dest_parent_path.is_empty() { + None + } else { + match folder_service.get_folder_by_path(dest_parent_path).await { + Ok(parent) => Some(parent.id), + Err(_) => None, + } + }; + + if recursive { + let file_management_service = &state.applications.file_management_service; + file_management_service + .copy_folder_tree( + &folder.id, + target_parent_id, + Some(dest_folder_name.to_string()), + ) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to copy folder tree: {}", e)) + })?; + } else { + let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { + name: dest_folder_name.to_string(), + parent_id: target_parent_id, + }; + folder_service + .create_folder(create_dto) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to create destination folder: {}", e)) + })?; } - }; + } else { + let file = file_retrieval_service + .get_file_by_path(&source_path) + .await + .map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?; - // Zero-copy: only creates a new metadata row + increments blob reference count. - // No file content is ever loaded into memory. - let file_management_service = &state.applications.file_management_service; - file_management_service - .copy_file(&file.id, target_folder_id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; + let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { + &destination_path[..idx] + } else { + "" + }; + + let target_folder_id = if dest_parent_path.is_empty() { + None + } else { + match folder_service.get_folder_by_path(dest_parent_path).await { + Ok(parent) => Some(parent.id), + Err(_) => None, + } + }; + + let file_management_service = &state.applications.file_management_service; + file_management_service + .copy_file(&file.id, target_folder_id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; + } } Ok(Response::builder()