diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 317b84fc..bc2be325 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -254,58 +254,34 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { } } -// ───────────────────────────────────────────────────── -// Management port (delete, move) -// ───────────────────────────────────────────────────── - /// Primary port for file management operations pub trait FileManagementUseCase: Send + Sync + 'static { - /// Moves a file to another folder (system/internal — no ownership check). - async fn move_file( - &self, - file_id: &str, - folder_id: Option, - ) -> Result; - /// Moves a file, enforcing that `caller_id` is the owner. - async fn move_file_owned( + async fn move_file_with_perms( &self, file_id: &str, caller_id: Uuid, folder_id: Option, ) -> Result; - /// Copies a file to another folder (zero-copy with dedup). - async fn copy_file( - &self, - file_id: &str, - target_folder_id: Option, - ) -> Result; - /// Copies a file, enforcing that `caller_id` is the owner. - async fn copy_file_owned( + async fn copy_file_with_perms( &self, file_id: &str, caller_id: Uuid, target_folder_id: Option, ) -> Result; - /// Renames a file (system/internal — no ownership check). - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; - /// Renames a file, enforcing that `caller_id` is the owner. - async fn rename_file_owned( + async fn rename_file_with_perms( &self, file_id: &str, caller_id: Uuid, new_name: &str, ) -> Result; - /// Deletes a file (system/internal — no ownership check). - async fn delete_file(&self, id: &str) -> Result<(), DomainError>; - /// Deletes a file, enforcing that `caller_id` is the owner. - async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; + async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; /// Smart delete: trash-first with dedup reference cleanup. /// @@ -314,30 +290,22 @@ pub trait FileManagementUseCase: Send + Sync + 'static { /// 3. Decrements the dedup reference count for the content hash. /// /// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted. - async fn delete_with_cleanup(&self, id: &str, user_id: Uuid) -> Result; + async fn delete_and_cleanup_with_perms( + &self, + id: &str, + user_id: Uuid, + ) -> Result; /// Copies an entire folder subtree atomically (WebDAV COPY Depth: infinity). + /// enforcing that `caller_id` owns both the source folder + /// and the target parent folder. /// /// Creates a copy of `source_folder_id` (with optional name override) under /// `target_parent_id`, including ALL sub-folders and files. Files are /// zero-copy (blob ref_counts incremented in batch). /// /// Default: returns error (only available with PostgreSQL backend). - async fn copy_folder_tree( - &self, - _source_folder_id: &str, - _target_parent_id: Option, - _dest_name: Option, - ) -> Result { - Err(DomainError::internal_error( - "FileManagement", - "copy_folder_tree not implemented", - )) - } - - /// Copies a folder tree, enforcing that `caller_id` owns both the source folder - /// and the target parent folder. - async fn copy_folder_tree_owned( + async fn copy_folder_tree_with_perms( &self, source_folder_id: &str, caller_id: Uuid, diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs new file mode 100644 index 00000000..1e736adc --- /dev/null +++ b/src/application/ports/folder_ports.rs @@ -0,0 +1,94 @@ +/// Primary port for folder operations +use uuid::Uuid; + +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; + +use crate::common::errors::DomainError; + +pub trait FolderUseCase: Send + Sync + 'static { + /// Creates a new folder + async fn create_folder_with_perms( + &self, + dto: CreateFolderDto, + caller_id: Uuid, + ) -> Result; + + /// Gets a folder by its ID + async fn get_folder(&self, id: &str) -> Result; + + /// Gets a folder by its ID, enforcing that `caller_id` is the owner. + /// + /// Returns `NotFound` if the folder does not exist **or** belongs to + /// another user. All user-facing handlers should use this method. + async fn get_folder_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result; + + /// Gets a folder by its path + async fn get_folder_by_path(&self, path: &str) -> Result; + + /// Lists folders within a parent folder + async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; + + /// Lists folders scoped to a specific owner (for user-facing endpoints). + /// At root level, only returns folders belonging to this user. + async fn list_folders_for_owner( + &self, + parent_id: Option<&str>, + owner_id: Uuid, + ) -> Result, DomainError>; + + /// Lists folders with pagination + async fn list_folders_paginated( + &self, + parent_id: Option<&str>, + pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result, DomainError>; + + /// Lists folders with pagination, scoped to a specific owner. + async fn list_folders_for_owner_paginated( + &self, + parent_id: Option<&str>, + owner_id: Uuid, + pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result, DomainError>; + + /// Renames a folder (ownership verified against caller_id) + async fn rename_folder_with_perms( + &self, + id: &str, + dto: RenameFolderDto, + caller_id: Uuid, + ) -> Result; + + /// Moves a folder to another parent (ownership verified against caller_id) + async fn move_folder_with_perms( + &self, + id: &str, + dto: MoveFolderDto, + caller_id: Uuid, + ) -> Result; + + /// Deletes a folder (ownership verified against caller_id) + async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; + + /// Creates a root-level home folder for a user during registration. + async fn create_home_folder( + &self, + user_id: Uuid, + name: String, + ) -> Result; + + /// Lists every folder in a subtree rooted at `folder_id` (inclusive), + /// ordered by path. Uses ltree `<@` — single GiST-indexed query. + /// + /// Default: returns an empty vec (stubs / mocks). + async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { + let _ = folder_id; + Ok(Vec::new()) + } +} diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 86816eeb..96d71970 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -2,97 +2,11 @@ use std::sync::Arc; use uuid::Uuid; -use crate::application::dtos::folder_dto::{ - CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, -}; use crate::application::dtos::search_dto::{ SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, }; use crate::common::errors::DomainError; -/// Primary port for folder operations -pub trait FolderUseCase: Send + Sync + 'static { - /// Creates a new folder - async fn create_folder( - &self, - dto: CreateFolderDto, - caller_id: Uuid, - ) -> Result; - - /// Gets a folder by its ID - async fn get_folder(&self, id: &str) -> Result; - - /// Gets a folder by its ID, enforcing that `caller_id` is the owner. - /// - /// Returns `NotFound` if the folder does not exist **or** belongs to - /// another user. All user-facing handlers should use this method. - async fn get_folder_owned(&self, id: &str, caller_id: Uuid) -> Result; - - /// Gets a folder by its path - async fn get_folder_by_path(&self, path: &str) -> Result; - - /// Lists folders within a parent folder - async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - - /// Lists folders scoped to a specific owner (for user-facing endpoints). - /// At root level, only returns folders belonging to this user. - async fn list_folders_for_owner( - &self, - parent_id: Option<&str>, - owner_id: Uuid, - ) -> Result, DomainError>; - - /// Lists folders with pagination - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - pagination: &crate::application::dtos::pagination::PaginationRequestDto, - ) -> Result, DomainError>; - - /// Lists folders with pagination, scoped to a specific owner. - async fn list_folders_for_owner_paginated( - &self, - parent_id: Option<&str>, - owner_id: Uuid, - pagination: &crate::application::dtos::pagination::PaginationRequestDto, - ) -> Result, DomainError>; - - /// Renames a folder (ownership verified against caller_id) - async fn rename_folder( - &self, - id: &str, - dto: RenameFolderDto, - caller_id: Uuid, - ) -> Result; - - /// Moves a folder to another parent (ownership verified against caller_id) - async fn move_folder( - &self, - id: &str, - dto: MoveFolderDto, - caller_id: Uuid, - ) -> Result; - - /// Deletes a folder (ownership verified against caller_id) - async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; - - /// Creates a root-level home folder for a user during registration. - async fn create_home_folder( - &self, - user_id: Uuid, - name: String, - ) -> Result; - - /// Lists every folder in a subtree rooted at `folder_id` (inclusive), - /// ordered by path. Uses ltree `<@` — single GiST-indexed query. - /// - /// Default: returns an empty vec (stubs / mocks). - async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { - let _ = folder_id; - Ok(Vec::new()) - } -} - /** * Primary port for file and folder search. * diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index d3250c1e..cccc6c8e 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -10,6 +10,7 @@ pub mod dedup_ports; pub mod favorites_ports; pub mod file_lifecycle; pub mod file_ports; +pub mod folder_ports; pub mod inbound; pub mod music_ports; pub mod outbound; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index aaecf820..01fd29da 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -5,7 +5,7 @@ use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, UserStoragePort, }; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::services::folder_service::FolderService; use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 7a12c4f2..7bbaa9ec 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -12,7 +12,7 @@ use tracing::info; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto}; use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase}; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::storage_ports::CopyFolderTreeResult; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::file_management_service::FileManagementService; @@ -145,7 +145,7 @@ impl BatchOperationService { async move { let copy_result = mgmt - .copy_file_owned(&file_id, user_id, target_folder.map(|s| s.to_string())) + .copy_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string())) .await; (file_id, copy_result) } @@ -211,7 +211,7 @@ impl BatchOperationService { async move { let move_result = mgmt - .move_file_owned(&file_id, user_id, target_folder.map(|s| s.to_string())) + .move_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string())) .await; (file_id, move_result) } @@ -270,7 +270,7 @@ impl BatchOperationService { let mgmt = self.file_management.clone(); async move { - let delete_result = mgmt.delete_file_owned(&file_id, user_id).await; + let delete_result = mgmt.delete_file_with_perms(&file_id, user_id).await; let id_for_result = file_id.clone(); (file_id, delete_result.map(|_| id_for_result)) } @@ -390,7 +390,9 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); async move { - let delete_result = folder_service.delete_folder(&folder_id, user_id).await; + let delete_result = folder_service + .delete_folder_with_perms(&folder_id, user_id) + .await; let id_for_result = folder_id.clone(); (folder_id, delete_result.map(|_| id_for_result)) } @@ -583,7 +585,9 @@ impl BatchOperationService { let dto = MoveFolderDto { parent_id: target.map(|s| s.to_string()), }; - let move_result = folder_service.move_folder(&folder_id, dto, user_id).await; + let move_result = folder_service + .move_folder_with_perms(&folder_id, dto, user_id) + .await; (folder_id, move_result) } })) @@ -644,7 +648,7 @@ impl BatchOperationService { async move { let copy_result = file_management - .copy_folder_tree_owned( + .copy_folder_tree_with_perms( &folder_id, user_id, target.map(|s| s.to_string()), @@ -732,7 +736,7 @@ impl BatchOperationService { for folder_id in &folder_ids { match self .folder_service - .get_folder_owned(folder_id, user_id) + .get_folder_with_perms(folder_id, user_id) .await { Ok(root_folder) => { @@ -979,7 +983,7 @@ impl BatchOperationService { name: name.clone(), parent_id: parent_id.clone(), }; - let create_result = folder_service.create_folder(dto, user_id).await; + let create_result = folder_service.create_folder_with_perms(dto, user_id).await; let id = format!("{}:{}", name, parent_id.unwrap_or_default()); (id, create_result) } @@ -1039,7 +1043,9 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); async move { - let get_result = folder_service.get_folder_owned(&folder_id, user_id).await; + let get_result = folder_service + .get_folder_with_perms(&folder_id, user_id) + .await; (folder_id, get_result) } })) diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 77c53795..98c1aad4 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -103,9 +103,8 @@ impl FileManagementService { }; folder_repo.verify_owner(target, caller_id).await } -} -impl FileManagementUseCase for FileManagementService { + //impl FileManagementPrivateUseCase for FileManagementService { async fn move_file( &self, file_id: &str, @@ -135,20 +134,6 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(moved_file)) } - async fn move_file_owned( - &self, - file_id: &str, - caller_id: Uuid, - folder_id: Option, - ) -> Result { - // Verify file ownership first - self.verify_owner(file_id, caller_id).await?; - // Verify target folder ownership (prevents file from "disappearing") - self.verify_target_folder_owner(folder_id.as_deref(), caller_id) - .await?; - self.move_file(file_id, folder_id).await - } - async fn copy_file( &self, file_id: &str, @@ -178,18 +163,6 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(copied_file)) } - async fn copy_file_owned( - &self, - file_id: &str, - caller_id: Uuid, - target_folder_id: Option, - ) -> Result { - self.verify_owner(file_id, caller_id).await?; - self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id) - .await?; - self.copy_file(file_id, target_folder_id).await - } - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { if let Err(reason) = validate_storage_name(new_name) { return Err(DomainError::validation_error(format!( @@ -217,65 +190,7 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(renamed_file)) } - async fn rename_file_owned( - &self, - file_id: &str, - caller_id: Uuid, - new_name: &str, - ) -> Result { - self.verify_owner(file_id, caller_id).await?; - self.rename_file(file_id, new_name).await - } - async fn delete_file(&self, id: &str) -> Result<(), DomainError> { - self.file_repository.delete_file(id).await?; - if let Some(cc) = &self.content_cache { - cc.invalidate(id).await; - } - for hook in &self.file_deleted_hooks { - hook.on_file_deleted(id).await; - } - Ok(()) - } - - async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { - self.verify_owner(id, caller_id).await?; - self.delete_file(id).await - } - - /// Smart delete: trash-first with dedup reference cleanup. - /// - /// Blob ref_count bookkeeping is handled entirely by the PG trigger - /// `trg_files_decrement_blob_ref` which fires on DELETE FROM storage.files. - /// We do NOT decrement here — trashing is a soft-delete (UPDATE, not DELETE) - /// so the blob must remain referenced until the file is permanently deleted. - async fn delete_with_cleanup(&self, id: &str, user_id: Uuid) -> Result { - // Step 1: Try trash (soft delete — file row stays, blob stays referenced) - if let Some(trash) = &self.trash_service { - info!("Moving file to trash: {}", id); - match trash.move_to_trash(id, "file", user_id).await { - Ok(_) => { - info!("File successfully moved to trash: {}", id); - // Invalidate content cache — trashed files must not be served. - if let Some(cc) = &self.content_cache { - cc.invalidate(id).await; - } - // Do NOT decrement blob ref here — the file row still exists - // (is_trashed = TRUE). The trigger will decrement when the - // row is actually DELETEd during trash emptying. - return Ok(true); // trashed - } - Err(err) => { - error!("Could not move file to trash: {:?}", err); - warn!("Falling back to permanent delete"); - // fall through - } - } - } else { - warn!("Trash service not available, using permanent delete"); - } - - // Step 2: Permanent delete — trigger handles blob ref_count warn!("Permanently deleting file: {}", id); self.file_repository.delete_file(id).await?; if let Some(cc) = &self.content_cache { @@ -285,8 +200,7 @@ impl FileManagementUseCase for FileManagementService { hook.on_file_deleted(id).await; } info!("File permanently deleted: {}", id); - - Ok(false) // permanently deleted + Ok(()) } async fn copy_folder_tree( @@ -319,8 +233,95 @@ impl FileManagementUseCase for FileManagementService { Ok(result) } +} - async fn copy_folder_tree_owned( +impl FileManagementUseCase for FileManagementService { + async fn move_file_with_perms( + &self, + file_id: &str, + caller_id: Uuid, + folder_id: Option, + ) -> Result { + // Verify file ownership first + self.verify_owner(file_id, caller_id).await?; + // Verify target folder ownership (prevents file from "disappearing") + self.verify_target_folder_owner(folder_id.as_deref(), caller_id) + .await?; + self.move_file(file_id, folder_id).await + } + + async fn copy_file_with_perms( + &self, + file_id: &str, + caller_id: Uuid, + target_folder_id: Option, + ) -> Result { + self.verify_owner(file_id, caller_id).await?; + self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id) + .await?; + self.copy_file(file_id, target_folder_id).await + } + + async fn rename_file_with_perms( + &self, + file_id: &str, + caller_id: Uuid, + new_name: &str, + ) -> Result { + self.verify_owner(file_id, caller_id).await?; + self.rename_file(file_id, new_name).await + } + + async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { + self.verify_owner(id, caller_id).await?; + self.delete_file(id).await + } + + /// Smart delete: trash-first with dedup reference cleanup. + /// + /// Blob ref_count bookkeeping is handled entirely by the PG trigger + /// `trg_files_decrement_blob_ref` which fires on DELETE FROM storage.files. + /// We do NOT decrement here — trashing is a soft-delete (UPDATE, not DELETE) + /// so the blob must remain referenced until the file is permanently deleted. + async fn delete_and_cleanup_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result { + self.verify_owner(id, caller_id).await?; + // Step 1: Try trash (soft delete — file row stays, blob stays referenced) + if let Some(trash) = &self.trash_service { + info!("Moving file to trash: {}", id); + match trash.move_to_trash(id, "file", caller_id).await { + Ok(_) => { + info!("File successfully moved to trash: {}", id); + // Invalidate content cache — trashed files must not be served. + if let Some(cc) = &self.content_cache { + cc.invalidate(id).await; + } + // Do NOT decrement blob ref here — the file row still exists + // (is_trashed = TRUE). The trigger will decrement when the + // row is actually DELETEd during trash emptying. + return Ok(true); // trashed + } + Err(err) => { + error!("Could not move file to trash: {:?}", err); + warn!("Falling back to permanent delete"); + // fall through + } + } + } else { + warn!("Trash service not available, using permanent delete"); + } + + // Step 2: Permanent delete — trigger handles blob ref_count + + self.delete_file(id).await?; + + Ok(false) // permanently deleted + } + + async fn copy_folder_tree_with_perms( &self, source_folder_id: &str, caller_id: Uuid, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index b8e910b0..7c7ee192 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -1,7 +1,7 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::services::path_service::{StoragePath, validate_storage_name}; @@ -25,7 +25,7 @@ impl FolderService { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { - async fn create_folder( + async fn create_folder_with_perms( &self, _dto: CreateFolderDto, _user_id: Uuid, @@ -37,7 +37,7 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn get_folder_owned( + async fn get_folder_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -101,7 +101,7 @@ impl FolderService { ) } - async fn rename_folder( + async fn rename_folder_with_perms( &self, _id: &str, _dto: RenameFolderDto, @@ -110,7 +110,7 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn move_folder( + async fn move_folder_with_perms( &self, _id: &str, _dto: MoveFolderDto, @@ -119,7 +119,11 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn delete_folder(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { + async fn delete_folder_with_perms( + &self, + _id: &str, + _caller_id: Uuid, + ) -> Result<(), DomainError> { Ok(()) } @@ -138,7 +142,7 @@ impl FolderService { impl FolderUseCase for FolderService { /// Creates a new folder - async fn create_folder( + async fn create_folder_with_perms( &self, dto: CreateFolderDto, caller_id: Uuid, @@ -204,7 +208,11 @@ impl FolderUseCase for FolderService { } /// Gets a folder by its ID, enforcing that `caller_id` is the owner. - async fn get_folder_owned(&self, id: &str, caller_id: Uuid) -> Result { + async fn get_folder_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result { let folder_dto = self.get_folder(id).await?; if folder_dto.owner_id.as_deref() != Some(&caller_id.to_string()) { tracing::warn!( @@ -261,10 +269,6 @@ impl FolderUseCase for FolderService { parent_id: Option<&str>, owner_id: Uuid, ) -> Result, DomainError> { - let owner_id_short = { - let s = owner_id.to_string(); - s[..8.min(s.len())].to_string() - }; let folders = self .folder_storage .list_folders_by_owner(parent_id, owner_id) @@ -286,6 +290,10 @@ impl FolderUseCase for FolderService { "No root folders found for user {}, creating home folder automatically", owner_id ); + let owner_id_short = { + let s = owner_id.to_string(); + s[..8.min(s.len())].to_string() + }; let folder_name = format!("My Folder - {}", owner_id_short); match self .folder_storage @@ -388,7 +396,7 @@ impl FolderUseCase for FolderService { } /// Renames a folder after verifying ownership. - async fn rename_folder( + async fn rename_folder_with_perms( &self, id: &str, dto: RenameFolderDto, @@ -431,7 +439,7 @@ impl FolderUseCase for FolderService { } /// Moves a folder to a new parent after verifying ownership. - async fn move_folder( + async fn move_folder_with_perms( &self, id: &str, dto: MoveFolderDto, @@ -497,7 +505,7 @@ impl FolderUseCase for FolderService { } /// Deletes a folder after verifying ownership. - async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { + async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { // Verify the folder exists and belongs to the caller let folder = self.folder_storage.get_folder(id).await?; diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 4abe8766..0a36815f 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -362,7 +362,7 @@ async fn stub_move_file_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileManagementUseCase; let result = stub - .move_file_owned("file-1", user_id, Some("folder-2".to_string())) + .move_file_with_perms("file-1", user_id, Some("folder-2".to_string())) .await; assert!(result.is_ok(), "stub should return Ok for move_file_owned"); } @@ -372,7 +372,7 @@ async fn stub_rename_file_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileManagementUseCase; let result = stub - .rename_file_owned("file-1", user_id, "new-name.txt") + .rename_file_with_perms("file-1", user_id, "new-name.txt") .await; assert!( result.is_ok(), diff --git a/src/application/services/share_browse_service.rs b/src/application/services/share_browse_service.rs index a52eceaa..fdd2ecf5 100644 --- a/src/application/services/share_browse_service.rs +++ b/src/application/services/share_browse_service.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; use crate::application::services::share_service::ShareService; diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 43fd0f5f..dd944da2 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -25,7 +25,9 @@ use crate::application::dtos::search_dto::{ use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, OptimizedFileContent, }; -use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::folder_ports::FolderUseCase; + +use crate::application::ports::inbound::SearchUseCase; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::zip_ports::ZipPort; use crate::common::errors::DomainError; @@ -353,7 +355,7 @@ impl I18nService for StubI18nService { pub struct StubFolderUseCase; impl FolderUseCase for StubFolderUseCase { - async fn create_folder( + async fn create_folder_with_perms( &self, _dto: CreateFolderDto, _user_id: Uuid, @@ -365,7 +367,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn get_folder_owned( + async fn get_folder_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -406,7 +408,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) } - async fn rename_folder( + async fn rename_folder_with_perms( &self, _id: &str, _dto: RenameFolderDto, @@ -415,7 +417,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn move_folder( + async fn move_folder_with_perms( &self, _id: &str, _dto: MoveFolderDto, @@ -424,7 +426,11 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn delete_folder(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { + async fn delete_folder_with_perms( + &self, + _id: &str, + _caller_id: Uuid, + ) -> Result<(), DomainError> { Ok(()) } @@ -617,23 +623,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { pub struct StubFileManagementUseCase; impl FileManagementUseCase for StubFileManagementUseCase { - async fn move_file( - &self, - _file_id: &str, - _folder_id: Option, - ) -> Result { - Ok(FileDto::default()) - } - - async fn copy_file( - &self, - _file_id: &str, - _folder_id: Option, - ) -> Result { - Ok(FileDto::default()) - } - - async fn copy_file_owned( + async fn copy_file_with_perms( &self, _file_id: &str, _caller_id: Uuid, @@ -642,23 +632,19 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { - Ok(FileDto::default()) - } - - async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { + async fn delete_file_with_perms(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } - async fn delete_file_owned(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { - Ok(()) - } - - async fn delete_with_cleanup(&self, _id: &str, _user_id: Uuid) -> Result { + async fn delete_and_cleanup_with_perms( + &self, + _id: &str, + _user_id: Uuid, + ) -> Result { Ok(false) } - async fn move_file_owned( + async fn move_file_with_perms( &self, _file_id: &str, _caller_id: Uuid, @@ -667,7 +653,7 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn rename_file_owned( + async fn rename_file_with_perms( &self, _file_id: &str, _caller_id: Uuid, @@ -676,7 +662,7 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn copy_folder_tree_owned( + async fn copy_folder_tree_with_perms( &self, _source_folder_id: &str, _caller_id: Uuid, diff --git a/src/infrastructure/services/zip_service.rs b/src/infrastructure/services/zip_service.rs index c92014ee..ed7fff13 100644 --- a/src/infrastructure/services/zip_service.rs +++ b/src/infrastructure/services/zip_service.rs @@ -3,7 +3,7 @@ use crate::application::services::folder_service::FolderService; use crate::{ application::dtos::file_dto::FileDto, application::ports::file_ports::FileRetrievalUseCase, - application::ports::inbound::FolderUseCase, + application::ports::folder_ports::FolderUseCase, application::ports::zip_ports::ZipPort, common::errors::{DomainError, ErrorKind, Result}, }; diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 5821e3af..0286a5b4 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -117,10 +117,10 @@ impl FileHandler { // ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ── if let Some(ref fid) = folder_id { - use crate::application::ports::inbound::FolderUseCase; + use crate::application::ports::folder_ports::FolderUseCase; let folder_service = &state.applications.folder_service; if folder_service - .get_folder_owned(fid, auth_user.id) + .get_folder_with_perms(fid, auth_user.id) .await .is_err() { @@ -875,7 +875,7 @@ impl FileHandler { // Auth required: trash-first with dedup cleanup + ownership verification let result = mgmt - .delete_with_cleanup(&id, auth_user.id) + .delete_and_cleanup_with_perms(&id, auth_user.id) .await .map(|was_trashed| { if was_trashed { @@ -917,7 +917,10 @@ impl FileHandler { tracing::info!("Renaming file {} to \"{}\"", id, new_name); let mgmt = &state.applications.file_management_service; - match mgmt.rename_file_owned(&id, auth_user.id, &new_name).await { + match mgmt + .rename_file_with_perms(&id, auth_user.id, &new_name) + .await + { Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -935,7 +938,7 @@ impl FileHandler { let mgmt = &state.applications.file_management_service; match mgmt - .move_file_owned(&id, auth_user.id, payload.folder_id) + .move_file_with_perms(&id, auth_user.id, payload.folder_id) .await { Ok(file) => (StatusCode::OK, Json(file)).into_response(), @@ -956,7 +959,10 @@ impl FileHandler { .map(|s| s.to_string()); let mgmt = &state.applications.file_management_service; - match mgmt.move_file_owned(&id, auth_user.id, folder_id).await { + match mgmt + .move_file_with_perms(&id, auth_user.id, folder_id) + .await + { Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), Err(err) => AppError::from(err).into_response(), } diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 45cc0276..bf757ce3 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -16,7 +16,7 @@ use crate::application::dtos::folder_dto::{ use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; @@ -76,7 +76,7 @@ impl FolderHandler { } } - match service.create_folder(dto, auth_user.id).await { + match service.create_folder_with_perms(dto, auth_user.id).await { Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -244,7 +244,10 @@ impl FolderHandler { Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { - match service.rename_folder(&id, dto, auth_user.id).await { + match service + .rename_folder_with_perms(&id, dto, auth_user.id) + .await + { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -257,7 +260,7 @@ impl FolderHandler { Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { - match service.move_folder(&id, dto, auth_user.id).await { + match service.move_folder_with_perms(&id, dto, auth_user.id).await { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -269,7 +272,7 @@ impl FolderHandler { auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - match service.delete_folder(&id, auth_user.id).await { + match service.delete_folder_with_perms(&id, auth_user.id).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(err) => AppError::from(err).into_response(), } @@ -304,7 +307,7 @@ impl FolderHandler { // Fallback to permanent delete if trash is unavailable or failed let folder_service = &state.applications.folder_service; - match folder_service.delete_folder(&id, user_id).await { + match folder_service.delete_folder_with_perms(&id, user_id).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 2a2f58b3..5609675d 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -22,7 +22,7 @@ use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase}; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::storage_ports::StorageUsagePort; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; @@ -1046,7 +1046,7 @@ async fn handle_mkcol( // their proper HTTP status codes (was: blanket 500 swallowed // ownership-rejection NotFound from verify_owner). let created = folder_service - .create_folder(create_dto, user.id) + .create_folder_with_perms(create_dto, user.id) .await .map_err(AppError::from)?; parent_id = Some(created.id); @@ -1092,7 +1092,7 @@ async fn handle_delete( match resolver.resolve_path_for_user(&path, user.id).await { Ok(ResolvedResource::Folder(folder)) => { folder_service - .delete_folder(&folder.id, user.id) + .delete_folder_with_perms(&folder.id, user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to delete folder: {}", e)) @@ -1100,7 +1100,7 @@ async fn handle_delete( } Ok(ResolvedResource::File(file)) => { file_management_service - .delete_file(&file.id) + .delete_file_with_perms(&file.id, user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to delete file: {}", e)) @@ -1115,7 +1115,7 @@ async fn handle_delete( if let Ok(folder) = folder_result { assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?; folder_service - .delete_folder(&folder.id, user.id) + .delete_folder_with_perms(&folder.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; } else { @@ -1126,7 +1126,7 @@ async fn handle_delete( assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; file_management_service - .delete_file(&file.id) + .delete_file_with_perms(&file.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; } @@ -1248,7 +1248,7 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto, user.id) + .move_folder_with_perms(&folder.id, move_dto, user.id) .await .map_err(AppError::from)?; @@ -1257,7 +1257,7 @@ async fn handle_move( name: dest_folder_name.to_string(), }; folder_service - .rename_folder(&folder.id, rename_dto, user.id) + .rename_folder_with_perms(&folder.id, rename_dto, user.id) .await .map_err(AppError::from)?; } @@ -1291,13 +1291,13 @@ async fn handle_move( )?; } file_management_service - .move_file(&file.id, Some(dest_parent_path.to_string())) + .move_file_with_perms(&file.id, user.id, Some(dest_parent_path.to_string())) .await .map_err(AppError::from)?; } if file.name != dest_filename { file_management_service - .rename_file(&file.id, dest_filename) + .rename_file_with_perms(&file.id, user.id, dest_filename) .await .map_err(AppError::from)?; } @@ -1349,7 +1349,7 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto, user.id) + .move_folder_with_perms(&folder.id, move_dto, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; @@ -1358,7 +1358,7 @@ async fn handle_move( name: dest_folder_name.to_string(), }; folder_service - .rename_folder(&folder.id, rename_dto, user.id) + .rename_folder_with_perms(&folder.id, rename_dto, user.id) .await .map_err(AppError::from)?; } @@ -1398,13 +1398,13 @@ async fn handle_move( )?; } file_management_service - .move_file(&file.id, Some(dest_parent_path.to_string())) + .move_file_with_perms(&file.id, user.id, Some(dest_parent_path.to_string())) .await .map_err(AppError::from)?; } if file.name != dest_filename { file_management_service - .rename_file(&file.id, dest_filename) + .rename_file_with_perms(&file.id, user.id, dest_filename) .await .map_err(AppError::from)?; } @@ -1535,8 +1535,9 @@ async fn handle_copy( if recursive { let file_management_service = &state.applications.file_management_service; file_management_service - .copy_folder_tree( + .copy_folder_tree_with_perms( &folder.id, + user.id, target_parent_id, Some(dest_folder_name.to_string()), ) @@ -1550,7 +1551,7 @@ async fn handle_copy( parent_id: target_parent_id, }; folder_service - .create_folder(create_dto, user.id) + .create_folder_with_perms(create_dto, user.id) .await .map_err(|e| { AppError::internal_error(format!( @@ -1586,7 +1587,7 @@ async fn handle_copy( let file_management_service = &state.applications.file_management_service; file_management_service - .copy_file(&file.id, target_folder_id) + .copy_file_with_perms(&file.id, user.id, target_folder_id) .await .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; } @@ -1639,8 +1640,9 @@ async fn handle_copy( if recursive { let file_management_service = &state.applications.file_management_service; file_management_service - .copy_folder_tree( + .copy_folder_tree_with_perms( &folder.id, + user.id, target_parent_id, Some(dest_folder_name.to_string()), ) @@ -1654,7 +1656,7 @@ async fn handle_copy( parent_id: target_parent_id, }; folder_service - .create_folder(create_dto, user.id) + .create_folder_with_perms(create_dto, user.id) .await .map_err(|e| { AppError::internal_error(format!( @@ -1697,7 +1699,7 @@ async fn handle_copy( let file_management_service = &state.applications.file_management_service; file_management_service - .copy_file(&file.id, target_folder_id) + .copy_file_with_perms(&file.id, user.id, target_folder_id) .await .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; } diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 525f9f80..03311557 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -18,7 +18,8 @@ use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::folder_ports::FolderUseCase; +use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index dd133112..a4a1cdb4 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -17,7 +17,7 @@ use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::common::mime_detect::{filename_from_path, refine_content_type}; @@ -656,7 +656,7 @@ async fn handle_mkcol( name: segment.to_string(), parent_id: Some(parent_id.clone()), }; - match folder_service.create_folder(dto, user.id).await { + match folder_service.create_folder_with_perms(dto, user.id).await { Ok(created) => { parent_id = created.id.clone(); } @@ -732,7 +732,7 @@ async fn handle_delete( if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await { folder_service - .delete_folder(&folder.id, user.id) + .delete_folder_with_perms(&folder.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; @@ -744,7 +744,7 @@ async fn handle_delete( if let Ok(file) = file_service.get_file_by_path(&internal_path).await { file_mgmt - .delete_file(&file.id) + .delete_file_with_perms(&file.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; @@ -798,7 +798,7 @@ async fn handle_move( if src_parent_sub == dest_parent_sub { // Same parent → rename. file_mgmt - .rename_file(&file.id, dest_name) + .rename_file_with_perms(&file.id, user.id, dest_name) .await .map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?; } else { @@ -809,14 +809,14 @@ async fn handle_move( .map_err(|_| AppError::not_found("Destination folder not found"))?; file_mgmt - .move_file(&file.id, Some(dest_parent.id.clone())) + .move_file_with_perms(&file.id, user.id, Some(dest_parent.id.clone())) .await .map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?; // If the filename changed too, rename after move. if file.name != dest_name { file_mgmt - .rename_file(&file.id, dest_name) + .rename_file_with_perms(&file.id, user.id, dest_name) .await .map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?; } @@ -851,7 +851,7 @@ async fn handle_move( // Same parent → rename. use crate::application::dtos::folder_dto::RenameFolderDto; folder_service - .rename_folder( + .rename_folder_with_perms( &folder.id, RenameFolderDto { name: dest_name.to_string(), @@ -869,7 +869,7 @@ async fn handle_move( use crate::application::dtos::folder_dto::MoveFolderDto; folder_service - .move_folder( + .move_folder_with_perms( &folder.id, MoveFolderDto { parent_id: Some(dest_parent.id.clone()), @@ -883,7 +883,7 @@ async fn handle_move( if folder.name != dest_name { use crate::application::dtos::folder_dto::RenameFolderDto; folder_service - .rename_folder( + .rename_folder_with_perms( &folder.id, RenameFolderDto { name: dest_name.to_string(),