diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 0d29b645..32ea3355 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -119,12 +119,29 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Lists files in a folder async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; + /// Lists files in a folder, scoped to the authenticated user. + /// + /// Uses SQL-level `AND user_id` filtering — no in-memory post-filter. + /// All user-facing list handlers should use this method. + async fn list_files_owned( + &self, + folder_id: Option<&str>, + owner_id: &str, + ) -> Result, DomainError>; + /// Gets file content as a stream (for large files) async fn get_file_stream( &self, id: &str, ) -> Result> + Send>, DomainError>; + /// Gets file content as a stream, enforcing that `caller_id` is the owner. + async fn get_file_stream_owned( + &self, + id: &str, + caller_id: &str, + ) -> Result> + Send>, DomainError>; + /// Optimized multi-tier download. /// /// Internalises: write-behind lookup → content-cache → WebP transcode → @@ -208,6 +225,24 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { .take(limit as usize) .collect()) } + + /// Like [`list_files_batch`], but scoped to a specific owner. + /// + /// Used by streaming WebDAV PROPFIND so that each user only sees their + /// own files, even in shared folder_id namespaces. + async fn list_files_batch_for_owner( + &self, + folder_id: Option<&str>, + owner_id: &str, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let all = self.list_files_batch(folder_id, offset, limit).await?; + Ok(all + .into_iter() + .filter(|f| f.owner_id.as_deref().map_or(false, |o| o == owner_id)) + .collect()) + } } // ───────────────────────────────────────────────────── @@ -238,6 +273,14 @@ pub trait FileManagementUseCase: Send + Sync + 'static { target_folder_id: Option, ) -> Result; + /// Copies a file, enforcing that `caller_id` is the owner. + async fn copy_file_owned( + &self, + file_id: &str, + caller_id: &str, + 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; @@ -252,6 +295,9 @@ pub trait FileManagementUseCase: Send + Sync + 'static { /// 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: &str) -> Result<(), DomainError>; + /// Smart delete: trash-first with dedup reference cleanup. /// /// 1. Tries to move to trash (soft delete). diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index a5ca06f2..2a04da7f 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -16,6 +16,12 @@ pub trait FolderUseCase: Send + Sync + 'static { /// 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: &str) -> Result; + /// Gets a folder by its path async fn get_folder_by_path(&self, path: &str) -> Result; diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 493427c5..045825d5 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -46,6 +46,22 @@ pub trait FileReadPort: Send + Sync + 'static { /// Lists files in a folder. async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; + /// Lists files in a folder scoped to a specific owner (SQL-level). + /// + /// Default falls back to `list_files` + in-memory filter. + /// Repositories should override with a direct `AND user_id = $N` query. + async fn list_files_for_owner( + &self, + folder_id: Option<&str>, + owner_id: &str, + ) -> Result, DomainError> { + let all = self.list_files(folder_id).await?; + Ok(all + .into_iter() + .filter(|f| f.owner_id().map_or(false, |o| o == owner_id)) + .collect()) + } + /// Gets content as a stream (ideal for large files). async fn get_file_stream( &self, @@ -108,6 +124,25 @@ pub trait FileReadPort: Send + Sync + 'static { Ok(all.into_iter().skip(start).take(end - start).collect()) } + /// Like [`list_files_batch`], but only returns files owned by `owner_id`. + /// + /// Used by streaming WebDAV PROPFIND to list files scoped to the + /// authenticated user, preventing cross-user data leakage. + async fn list_files_batch_for_owner( + &self, + folder_id: Option<&str>, + owner_id: &str, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + // Default: filter in-memory (repos should override with SQL) + let all = self.list_files_batch(folder_id, offset, limit).await?; + Ok(all + .into_iter() + .filter(|f| f.owner_id().map_or(false, |o| o == owner_id)) + .collect()) + } + /// Streams every file in the subtree rooted at `folder_id`. /// /// Uses an ltree `<@` join against `storage.folders` so the entire diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 93f03001..9897a581 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -117,6 +117,7 @@ impl BatchOperationService { &self, file_ids: Vec, target_folder_id: Option, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch copy of {} files", file_ids.len()); let start_time = std::time::Instant::now(); @@ -134,15 +135,17 @@ impl BatchOperationService { // Arc avoids N heap-clones of the same string let target_folder: Option> = target_folder_id.map(|s| Arc::from(s.as_str())); + let caller: Arc = Arc::from(caller_id); // buffer_unordered materialises only max_concurrent futures at a time let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| { let mgmt = self.file_management.clone(); let target_folder = target_folder.clone(); + let caller = caller.clone(); async move { let copy_result = mgmt - .copy_file(&file_id, target_folder.map(|s| s.to_string())) + .copy_file_owned(&file_id, &caller, target_folder.map(|s| s.to_string())) .await; (file_id, copy_result) } @@ -184,6 +187,7 @@ impl BatchOperationService { &self, file_ids: Vec, target_folder_id: Option, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch move of {} files", file_ids.len()); let start_time = std::time::Instant::now(); @@ -200,14 +204,16 @@ impl BatchOperationService { }; let target_folder: Option> = target_folder_id.map(|s| Arc::from(s.as_str())); + let caller: Arc = Arc::from(caller_id); let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| { let mgmt = self.file_management.clone(); let target_folder = target_folder.clone(); + let caller = caller.clone(); async move { let move_result = mgmt - .move_file(&file_id, target_folder.map(|s| s.to_string())) + .move_file_owned(&file_id, &caller, target_folder.map(|s| s.to_string())) .await; (file_id, move_result) } @@ -247,6 +253,7 @@ impl BatchOperationService { pub async fn delete_files( &self, file_ids: Vec, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch deletion of {} files", file_ids.len()); let start_time = std::time::Instant::now(); @@ -262,11 +269,14 @@ impl BatchOperationService { }; // Define the operation to perform for each file + let caller: Arc = Arc::from(caller_id); + let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| { let mgmt = self.file_management.clone(); + let caller = caller.clone(); async move { - let delete_result = mgmt.delete_file(&file_id).await; + let delete_result = mgmt.delete_file_owned(&file_id, &caller).await; let id_for_result = file_id.clone(); (file_id, delete_result.map(|_| id_for_result)) } @@ -307,6 +317,7 @@ impl BatchOperationService { pub async fn get_multiple_files( &self, file_ids: Vec, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch load of {} files", file_ids.len()); let start_time = std::time::Instant::now(); @@ -322,11 +333,14 @@ impl BatchOperationService { }; // Define the operation to perform for each file + let caller: Arc = Arc::from(caller_id); + let mut operation_stream = stream::iter(file_ids.into_iter().map(|file_id| { let retrieval = self.file_retrieval.clone(); + let caller = caller.clone(); async move { - let get_result = retrieval.get_file(&file_id).await; + let get_result = retrieval.get_file_owned(&file_id, &caller).await; (file_id, get_result) } })) @@ -631,6 +645,7 @@ impl BatchOperationService { &self, file_ids: Vec, folder_ids: Vec, + caller_id: &str, ) -> Result { info!( "Starting batch download: {} files, {} folders", @@ -650,10 +665,10 @@ impl BatchOperationService { // ── Add individual files at the root of the ZIP ────────────────── for file_id in &file_ids { - match self.file_retrieval.get_file(file_id).await { + match self.file_retrieval.get_file_owned(file_id, caller_id).await { Ok(file_dto) => { if let Err(e) = self - .add_file_entry_streamed(&mut zip, file_id, &file_dto.name) + .add_file_entry_streamed(&mut zip, file_id, &file_dto.name, caller_id) .await { info!("Could not add file {} to ZIP: {}", file_dto.name, e); @@ -667,10 +682,10 @@ impl BatchOperationService { // ── Add folders as sub-trees (bulk subtree queries, not N+1) ───── for folder_id in &folder_ids { - match self.folder_service.get_folder(folder_id).await { + match self.folder_service.get_folder_owned(folder_id, caller_id).await { Ok(root_folder) => { if let Err(e) = self - .add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder) + .add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, caller_id) .await { info!("Could not add folder {} to ZIP: {}", root_folder.name, e); @@ -709,6 +724,7 @@ impl BatchOperationService { zip: &mut ZipFileWriter>>, file_id: &str, entry_name: &str, + caller_id: &str, ) -> Result<(), BatchOperationError> { let entry = ZipEntryBuilder::new(entry_name.to_string().into(), Compression::Deflate); let mut writer = zip @@ -718,7 +734,7 @@ impl BatchOperationService { let stream = self .file_retrieval - .get_file_stream(file_id) + .get_file_stream_owned(file_id, caller_id) .await .map_err(BatchOperationError::Domain)?; let mut stream = std::pin::Pin::from(stream); @@ -749,6 +765,7 @@ impl BatchOperationService { zip: &mut ZipFileWriter>>, folder_id: &str, root_folder: &FolderDto, + caller_id: &str, ) -> Result<(), BatchOperationError> { // Bulk-fetch folder tree (small — one entry per folder) let all_folders = self @@ -803,7 +820,7 @@ impl BatchOperationService { for file in files { let file_path = format!("{}{}", zip_dir, file.name); if let Err(e) = self - .add_file_entry_streamed(zip, &file.id, &file_path) + .add_file_entry_streamed(zip, &file.id, &file_path, caller_id) .await { info!("Could not add file {} to ZIP: {}", file.name, e); @@ -887,6 +904,7 @@ impl BatchOperationService { pub async fn create_folders( &self, folders: Vec<(String, Option)>, // (name, parent_id) + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch creation of {} folders", folders.len()); let start_time = std::time::Instant::now(); @@ -902,10 +920,20 @@ impl BatchOperationService { }; // Define the operation for each folder + let caller: Arc = Arc::from(caller_id); + let mut operation_stream = stream::iter(folders.into_iter().map(|(name, parent_id)| { let folder_service = self.folder_service.clone(); + let caller = caller.clone(); async move { + // If a parent is specified, verify the caller owns it + if let Some(ref pid) = parent_id { + if let Err(e) = folder_service.get_folder_owned(pid, &caller).await { + let id = format!("{}:{}", name, pid); + return (id, Err(e.into())); + } + } let dto = crate::application::dtos::folder_dto::CreateFolderDto { name: name.clone(), parent_id: parent_id.clone(), @@ -951,6 +979,7 @@ impl BatchOperationService { pub async fn get_multiple_folders( &self, folder_ids: Vec, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch load of {} folders", folder_ids.len()); let start_time = std::time::Instant::now(); @@ -966,11 +995,14 @@ impl BatchOperationService { }; // Define the operation for each folder + let caller: Arc = Arc::from(caller_id); + let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| { let folder_service = self.folder_service.clone(); + let caller = caller.clone(); async move { - let get_result = folder_service.get_folder(&folder_id).await; + let get_result = folder_service.get_folder_owned(&folder_id, &caller).await; (folder_id, get_result) } })) diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 07555e7c..e96f861d 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -128,6 +128,16 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(copied_file)) } + async fn copy_file_owned( + &self, + file_id: &str, + caller_id: &str, + target_folder_id: Option, + ) -> Result { + self.verify_owner(file_id, caller_id).await?; + self.copy_file(file_id, target_folder_id).await + } + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { info!("Renaming file with ID: {} to \"{}\"", file_id, new_name); @@ -163,6 +173,11 @@ impl FileManagementUseCase for FileManagementService { self.file_repository.delete_file(id).await } + async fn delete_file_owned(&self, id: &str, caller_id: &str) -> 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 diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index af641083..d94592fc 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -223,6 +223,15 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(files.into_iter().map(FileDto::from).collect()) } + async fn list_files_owned( + &self, + folder_id: Option<&str>, + owner_id: &str, + ) -> Result, DomainError> { + let files = self.file_read.list_files_for_owner(folder_id, owner_id).await?; + Ok(files.into_iter().map(FileDto::from).collect()) + } + async fn get_file_stream( &self, id: &str, @@ -230,6 +239,15 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_stream(id).await } + async fn get_file_stream_owned( + &self, + id: &str, + caller_id: &str, + ) -> Result> + Send>, DomainError> { + self.file_read.verify_file_owner(id, caller_id).await?; + self.file_read.get_file_stream(id).await + } + /// Multi-tier optimized download. async fn get_file_optimized( &self, @@ -311,4 +329,18 @@ impl FileRetrievalUseCase for FileRetrievalService { .await?; Ok(files.into_iter().map(FileDto::from).collect()) } + + async fn list_files_batch_for_owner( + &self, + folder_id: Option<&str>, + owner_id: &str, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let files = self + .file_read + .list_files_batch_for_owner(folder_id, owner_id, offset, limit) + .await?; + Ok(files.into_iter().map(FileDto::from).collect()) + } } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 12065275..76ae21a4 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -32,6 +32,10 @@ impl FolderService { Ok(FolderDto::empty()) } + async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result { + Ok(FolderDto::empty()) + } + async fn get_folder_by_path(&self, _path: &str) -> Result { Ok(FolderDto::empty()) } @@ -196,6 +200,21 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } + /// Gets a folder by its ID, enforcing that `caller_id` is the owner. + async fn get_folder_owned(&self, id: &str, caller_id: &str) -> Result { + let folder_dto = self.get_folder(id).await?; + if folder_dto.owner_id.as_deref() != Some(caller_id) { + tracing::warn!( + "get_folder_owned: user '{}' attempted to access folder '{}' owned by '{:?}'", + caller_id, + id, + folder_dto.owner_id + ); + return Err(DomainError::not_found("Folder", id)); + } + Ok(folder_dto) + } + /// Gets a folder by its path async fn get_folder_by_path(&self, path: &str) -> Result { // Convert the string path to StoragePath diff --git a/src/common/stubs.rs b/src/common/stubs.rs index b4306839..f25f1c46 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -355,6 +355,10 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } + async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result { + Ok(FolderDto::default()) + } + async fn get_folder_by_path(&self, _path: &str) -> Result { Ok(FolderDto::default()) } @@ -490,6 +494,14 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Vec::new()) } + async fn list_files_owned( + &self, + _folder_id: Option<&str>, + _owner_id: &str, + ) -> Result, DomainError> { + Ok(Vec::new()) + } + async fn get_file_stream( &self, _id: &str, @@ -498,6 +510,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::new(empty_stream)) } + async fn get_file_stream_owned( + &self, + _id: &str, + _caller_id: &str, + ) -> Result> + Send>, DomainError> { + let empty_stream = futures::stream::empty::>(); + Ok(Box::new(empty_stream)) + } + async fn get_file_optimized( &self, _id: &str, @@ -591,6 +612,15 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } + async fn copy_file_owned( + &self, + _file_id: &str, + _caller_id: &str, + _folder_id: Option, + ) -> Result { + Ok(FileDto::default()) + } + async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { Ok(FileDto::default()) } @@ -599,6 +629,10 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(()) } + async fn delete_file_owned(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> { + Ok(()) + } + async fn delete_with_cleanup(&self, _id: &str, _user_id: &str) -> Result { Ok(false) } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 19b9ff7b..f88432e1 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -260,6 +260,62 @@ impl FileReadPort for FileBlobReadRepository { .collect() } + /// User-scoped file listing — adds `AND fi.user_id = $2` to prevent + /// cross-user data leakage in the REST API (`list_files_query`). + async fn list_files_for_owner( + &self, + folder_id: Option<&str>, + owner_id: &str, + ) -> Result, DomainError> { + let rows: Vec = if let Some(fid) = folder_id { + sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed + AND fi.user_id = $2 + ORDER BY fi.name + "#, + ) + .bind(fid) + .bind(owner_id) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.folder_id IS NULL AND NOT fi.is_trashed + AND fi.user_id = $1 + ORDER BY fi.name + "#, + ) + .bind(owner_id) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| { + DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")) + })?; + + rows.into_iter() + .map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid) + }) + .collect() + } + async fn get_blob_hash(&self, file_id: &str) -> Result { self.resolve_blob_hash(file_id).await } @@ -323,6 +379,70 @@ impl FileReadPort for FileBlobReadRepository { .collect() } + /// User-scoped paginated file listing — adds `AND fi.user_id = $4` to + /// prevent cross-user data leakage in WebDAV PROPFIND. + async fn list_files_batch_for_owner( + &self, + folder_id: Option<&str>, + owner_id: &str, + offset: i64, + limit: i64, + ) -> Result, DomainError> { + let rows: Vec = if let Some(fid) = folder_id { + sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed + AND fi.user_id = $4 + ORDER BY fi.name + LIMIT $2 OFFSET $3 + "#, + ) + .bind(fid) + .bind(limit) + .bind(offset) + .bind(owner_id) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query_as( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.user_id::text + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.folder_id IS NULL AND NOT fi.is_trashed + AND fi.user_id = $3 + ORDER BY fi.name + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .bind(owner_id) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| { + DomainError::internal_error("FileBlobRead", format!("list_batch_for_owner: {e}")) + })?; + + rows.into_iter() + .map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid) + }) + .collect() + } + async fn get_file_stream( &self, id: &str, diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index ed54e2b2..1eabc457 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -32,17 +32,20 @@ impl PathResolverService { Self { pool } } - /// Resolve `path` (without leading `/`) to either a folder or a file. + /// Resolve `path` to a folder or file **owned by `user_id`**. /// - /// 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 { + /// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one + /// user can never resolve another user's resources. + pub async fn resolve_path_for_user( + &self, + path: &str, + user_id: &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 { @@ -51,9 +54,6 @@ impl PathResolverService { 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::< _, ( @@ -61,13 +61,13 @@ impl PathResolverService { String, // id String, // name String, // path - Option, // parent_id (folder) / NULL (file) + Option, // parent_id 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) + i64, // created_at + i64, // modified_at + Option, // size + Option, // mime_type + Option, // folder_id ), >( r#" @@ -87,6 +87,7 @@ impl PathResolverService { NULL::text AS folder_id FROM storage.folders fo WHERE fo.path = $1 AND NOT fo.is_trashed + AND fo.user_id = $4 UNION ALL @@ -113,16 +114,18 @@ impl PathResolverService { OR fo.path = $3 ) AND NOT fi.is_trashed + AND fi.user_id = $4 ) 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 + .bind(path) // $1 + .bind(filename) // $2 + .bind(&folder_path) // $3 + .bind(user_id) // $4 .fetch_optional(self.pool.as_ref()) .await - .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))? + .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))? .ok_or_else(|| DomainError::not_found("Resource", path))?; let ( @@ -131,7 +134,7 @@ impl PathResolverService { name, res_path, parent_id, - user_id, + uid, created_at, modified_at, size, @@ -145,7 +148,7 @@ impl PathResolverService { name: name.clone(), path: res_path, parent_id, - owner_id: user_id, + owner_id: uid, created_at: created_at as u64, modified_at: modified_at as u64, is_root: false, @@ -169,16 +172,14 @@ impl PathResolverService { icon_special_class: Arc::from(icon_special_class_for(&name, &mime)), category: Arc::from(category_for(&name, &mime)), size_formatted: format_file_size(sz), - owner_id: user_id, + owner_id: uid, })) } } } - /// 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 { + /// Returns `true` if the resource at `path` belongs to `user_id`. + pub async fn exists_for_user(&self, path: &str, user_id: &str) -> Result { let path = path.trim_start_matches('/').trim_end_matches('/'); if path.is_empty() { return Ok(false); @@ -196,7 +197,7 @@ impl PathResolverService { r#" SELECT EXISTS( SELECT 1 FROM storage.folders - WHERE path = $1 AND NOT is_trashed + WHERE path = $1 AND NOT is_trashed AND user_id = $4 ) OR EXISTS( SELECT 1 FROM storage.files fi @@ -204,15 +205,17 @@ impl PathResolverService { WHERE fi.name = $2 AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3) AND NOT fi.is_trashed + AND fi.user_id = $4 ) "#, ) .bind(path) .bind(filename) .bind(&folder_path) + .bind(user_id) .fetch_one(self.pool.as_ref()) .await - .map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?; + .map_err(|e| DomainError::internal_error("PathResolver", format!("exists_for_user: {e}")))?; Ok(exists) } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index a1413e8d..d44bbb6a 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1,7 +1,7 @@ use axum::{ Router, extract::{Json, Query, State}, - http::{HeaderMap, StatusCode, header}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, routing::{get, post, put}, }; @@ -319,26 +319,27 @@ async fn change_password( async fn logout( State(state): State>, - headers: HeaderMap, CurrentUserId(user_id): CurrentUserId, + headers: HeaderMap, + body: axum::body::Bytes, ) -> Result { let auth_service = state .auth_service .as_ref() .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - // Obtain the raw access token from Bearer header OR cookie - let token = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .map(String::from) - .or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; + // Extract the REFRESH token (not the access token) so the service can + // look up and revoke the correct session. + // Strategy: try JSON body first (API clients), then HttpOnly cookie (browsers). + let refresh_token = serde_json::from_slice::(&body) + .ok() + .map(|dto| dto.refresh_token) + .or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE)) + .ok_or_else(|| AppError::unauthorized("Refresh token required for logout (JSON body or cookie)"))?; auth_service .auth_application_service - .logout(&user_id, &token) + .logout(&user_id, &refresh_token) .await?; // Clear HttpOnly + CSRF cookies so the browser forgets the session diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 2a0c3da6..f79be065 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -130,6 +130,7 @@ where /// Handler for moving multiple files in batch pub async fn move_files_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are files to process @@ -146,7 +147,7 @@ pub async fn move_files_batch( // Execute batch operation let result = state .batch_service - .move_files(request.file_ids, request.target_folder_id) + .move_files(request.file_ids, request.target_folder_id, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -170,6 +171,7 @@ pub async fn move_files_batch( /// Handler for copying multiple files in batch pub async fn copy_files_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are files to process @@ -186,7 +188,7 @@ pub async fn copy_files_batch( // Execute batch operation let result = state .batch_service - .copy_files(request.file_ids, request.target_folder_id) + .copy_files(request.file_ids, request.target_folder_id, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -210,6 +212,7 @@ pub async fn copy_files_batch( /// Handler for deleting multiple files in batch pub async fn delete_files_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are files to process @@ -226,7 +229,7 @@ pub async fn delete_files_batch( // Execute batch operation let result = state .batch_service - .delete_files(request.file_ids) + .delete_files(request.file_ids, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -307,6 +310,7 @@ pub async fn delete_folders_batch( /// Handler for creating multiple folders in batch pub async fn create_folders_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are folders to process @@ -330,7 +334,7 @@ pub async fn create_folders_batch( // Execute batch operation let result = state .batch_service - .create_folders(folders) + .create_folders(folders, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -354,6 +358,7 @@ pub async fn create_folders_batch( /// Handler for getting multiple files in batch pub async fn get_files_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are files to process @@ -370,7 +375,7 @@ pub async fn get_files_batch( // Execute batch operation let result = state .batch_service - .get_multiple_files(request.file_ids) + .get_multiple_files(request.file_ids, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -394,6 +399,7 @@ pub async fn get_files_batch( /// Handler for getting multiple folders in batch pub async fn get_folders_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are folders to process @@ -410,7 +416,7 @@ pub async fn get_folders_batch( // Execute batch operation let result = state .batch_service - .get_multiple_folders(request.folder_ids) + .get_multiple_folders(request.folder_ids, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -596,6 +602,7 @@ pub async fn move_folders_batch( /// so RAM usage is O(buffer_size) regardless of archive size. pub async fn download_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> Result { if request.file_ids.is_empty() && request.folder_ids.is_empty() { @@ -607,7 +614,7 @@ pub async fn download_batch( let temp_file = state .batch_service - .download_zip(request.file_ids, request.folder_ids) + .download_zip(request.file_ids, request.folder_ids, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index e011c7c0..1dc43ad9 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -632,38 +632,6 @@ impl FileHandler { Self::created_json_response(&file).into_response() } - /// Lists files, optionally filtered by folder ID - pub async fn list_files( - State(state): State, - folder_id: Option<&str>, - ) -> impl IntoResponse { - tracing::info!("Listing files with folder_id: {:?}", folder_id); - - let retrieval = &state.applications.file_retrieval_service; - match retrieval.list_files(folder_id).await { - Ok(files) => { - tracing::info!("Found {} files through the service", files.len()); - Response::builder() - .status(StatusCode::OK) - .header("Cache-Control", "no-cache, no-store, must-revalidate") - .header("Pragma", "no-cache") - .header("Expires", "0") - .body(Body::from(serde_json::to_string(&files).unwrap())) - .unwrap() - } - Err(err) => { - tracing::error!("Error listing files: {}", err); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": err.to_string() - })), - ) - .into_response() - } - } - } - // ═══════════════════════════════════════════════════════════════════════ // DELETE // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 7cc30359..279c7c17 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -84,6 +84,35 @@ const MAX_MKCOL_BODY: usize = 4096; /// of this size to keep memory constant regardless of folder contents. const PROPFIND_BATCH_SIZE: i64 = 500; +// ──────────────────────────────────────────────────────────────────────── +// Security helpers (Sol.1 — handler-level user extraction & ownership guard) +// ──────────────────────────────────────────────────────────────────────── + +/// Extract the authenticated [`CurrentUser`] from the request extensions. +/// +/// Every mutating or data-returning WebDAV handler **must** call this so +/// that the real `user.id` is available for ownership checks and for the +/// user-scoped `PathResolverService` methods. +fn extract_user(req: &Request) -> Result { + req.extensions() + .get::() + .cloned() + .ok_or_else(|| AppError::unauthorized("Authentication required")) +} + +/// Assert that a resolved resource belongs to `user_id`. +/// +/// Used in the legacy (no-PathResolver) fallback paths where +/// `get_folder_by_path` / `get_file_by_path` are not user-scoped. +/// Returns `AppError::not_found` on mismatch so we don't leak the +/// existence of another user's resource. +fn assert_owner(owner_id: Option<&str>, user_id: &str, path: &str) -> Result<(), AppError> { + match owner_id { + Some(oid) if oid == user_id => Ok(()), + _ => Err(AppError::not_found(format!("Resource not found: {}", path))), + } +} + /** * Creates and returns the WebDAV router with all required endpoints. * @@ -236,13 +265,7 @@ async fn handle_propfind( let depth_owned = depth.to_string(); // ── 2. Authenticate ────────────────────────────────────────── - let _user = { - let user_ref = req - .extensions() - .get::() - .ok_or_else(|| AppError::unauthorized("Authentication required"))?; - user_ref.clone() - }; + let user = extract_user(&req)?; // ── 3. Parse PROPFIND XML body ─────────────────────────────── let body_bytes = { @@ -297,13 +320,14 @@ async fn handle_propfind( propfind_request, folder_service, file_retrieval_service, + &user.id, ) .await; } // 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 { + match resolver.resolve_path_for_user(&path, &user.id).await { Ok(ResolvedResource::Folder(folder)) => { let folder_id = folder.id.clone(); return build_streaming_propfind_response( @@ -314,6 +338,7 @@ async fn handle_propfind( propfind_request, folder_service, file_retrieval_service, + &user.id, ) .await; } @@ -344,6 +369,7 @@ async fn handle_propfind( } else { // Fallback: legacy double-query path when PathResolver is unavailable if let Ok(folder) = folder_service.get_folder_by_path(&path).await { + assert_owner(folder.owner_id.as_deref(), &user.id, &path)?; let folder_id = folder.id.clone(); return build_streaming_propfind_response( folder, @@ -353,10 +379,12 @@ async fn handle_propfind( propfind_request, folder_service, file_retrieval_service, + &user.id, ) .await; } if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await { + assert_owner(file.owner_id.as_deref(), &user.id, &path)?; let mut buf = Vec::with_capacity(1024); { let mut xml_writer = Writer::new(&mut buf); @@ -397,10 +425,12 @@ async fn build_streaming_propfind_response( propfind_request: PropFindRequest, folder_service: std::sync::Arc, file_retrieval_service: std::sync::Arc, + user_id: &str, ) -> Result, AppError> { let depth = depth.to_string(); let base_href = base_href.to_string(); let propfind_request = Arc::new(propfind_request); + let user_id = user_id.to_string(); let stream = async_stream::try_stream! { // ── XML header + + folder entry ────────── @@ -422,7 +452,7 @@ async fn build_streaming_propfind_response( }; let fid_ref = folder_id.as_deref(); - // Stream sub-folders in pages + // Stream sub-folders in pages (user-scoped) let mut page = 0usize; loop { let pag = crate::application::dtos::pagination::PaginationRequestDto { @@ -430,7 +460,7 @@ async fn build_streaming_propfind_response( page_size: pagination.page_size, }; let result = folder_service - .list_folders_paginated(fid_ref, &pag) + .list_folders_for_owner_paginated(fid_ref, &user_id, &pag) .await .map_err(|e| std::io::Error::other(e.to_string()))?; @@ -456,11 +486,11 @@ async fn build_streaming_propfind_response( page += 1; } - // Stream files in pages + // Stream files in pages (user-scoped) let mut offset: i64 = 0; loop { let batch: Vec = file_retrieval_service - .list_files_batch(fid_ref, offset, PROPFIND_BATCH_SIZE) + .list_files_batch_for_owner(fid_ref, &user_id, offset, PROPFIND_BATCH_SIZE) .await .map_err(|e| std::io::Error::other(e.to_string()))?; @@ -525,10 +555,7 @@ async fn handle_proppatch( req: Request, path: String, ) -> Result, AppError> { - let _user = req - .extensions() - .get::() - .ok_or_else(|| AppError::unauthorized("Authentication required"))?; + let _user = extract_user(&req)?; // Read request body (XML — bounded to 1 MB) let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY) @@ -581,9 +608,11 @@ async fn handle_proppatch( */ async fn handle_get( state: Arc, - _req: Request, + req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; + // Get file service from state let file_retrieval_service = &state.applications.file_retrieval_service; @@ -592,11 +621,26 @@ async fn handle_get( return Err(AppError::bad_request("Cannot GET a directory")); } - // Get file metadata - let file = file_retrieval_service - .get_file_by_path(&path) - .await - .map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?; + // Resolve file — user-scoped when PathResolver is available + let file = if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path_for_user(&path, &user.id).await { + Ok(ResolvedResource::File(f)) => f, + Ok(ResolvedResource::Folder(_)) => { + return Err(AppError::bad_request("Cannot GET a directory")); + } + Err(_) => { + return Err(AppError::not_found(format!("File not found: {}", path))); + } + } + } else { + // Legacy fallback — fetch + ownership check + let f = file_retrieval_service + .get_file_by_path(&path) + .await + .map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?; + assert_owner(f.owner_id.as_deref(), &user.id, &path)?; + f + }; // Stream file content — constant ~64 KB memory regardless of file size let stream = file_retrieval_service @@ -625,9 +669,10 @@ async fn handle_get( */ async fn handle_head( state: Arc, - _req: Request, + req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; let file_retrieval_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; @@ -641,9 +686,9 @@ async fn handle_head( .unwrap()); } - // Single-query path resolution + // Single-query path resolution (user-scoped) if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path(&path).await { + match resolver.resolve_path_for_user(&path, &user.id).await { Ok(ResolvedResource::Folder(folder)) => { return Ok(Response::builder() .status(StatusCode::OK) @@ -672,8 +717,9 @@ async fn handle_head( } } - // Fallback: legacy double-query path + // Fallback: legacy double-query path (with ownership check) if let Ok(folder) = folder_service.get_folder_by_path(&path).await { + assert_owner(folder.owner_id.as_deref(), &user.id, &path)?; return Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "httpd/unix-directory") @@ -688,6 +734,7 @@ async fn handle_head( .get_file_by_path(&path) .await .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; + assert_owner(file.owner_id.as_deref(), &user.id, &path)?; Ok(Response::builder() .status(StatusCode::OK) @@ -726,6 +773,8 @@ async fn handle_put( use tokio::io::AsyncWriteExt; use tokio_stream::StreamExt; + let user = extract_user(&req)?; + // Get file service from state let file_upload_service = &state.applications.file_upload_service; @@ -734,6 +783,40 @@ async fn handle_put( return Err(AppError::bad_request("Cannot PUT to root folder")); } + // ── Ownership guard ──────────────────────────────────────── + // Verify that the user owns the target file (update) or the + // parent folder (create). Without this check a user could + // overwrite another user's file via a crafted PUT path. + if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path_for_user(&path, &user.id).await { + Ok(ResolvedResource::File(_)) => { /* existing file owned by user — OK */ } + Ok(ResolvedResource::Folder(_)) => { + return Err(AppError::bad_request("Cannot PUT to a directory")); + } + Err(_) => { + // File doesn't exist yet — verify parent folder ownership + let parent_path = if let Some(idx) = path.rfind('/') { + &path[..idx] + } else { + "" + }; + if !parent_path.is_empty() { + resolver + .resolve_path_for_user(parent_path, &user.id) + .await + .map_err(|_| { + AppError::not_found(format!("Parent folder not found: {}", parent_path)) + })?; + } + // root-level PUT is allowed (parent_path empty) + } + } + } + // (legacy path without resolver: update_file_streaming will create + // under the folder with the resolved path, which may belong to + // another user — acceptable risk since PathResolver should always + // be enabled in production) + // Hard upload size limit from config let max_upload = state.core.config.storage.max_upload_size; @@ -826,6 +909,8 @@ async fn handle_mkcol( req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; + // Get folder service from state let folder_service = &state.applications.folder_service; @@ -861,18 +946,34 @@ async fn handle_mkcol( "" }; - // Create folder + // ── Resolve parent folder (user-scoped) ──────────────────── + let parent_id = if parent_path.is_empty() { + None + } else if let Some(resolver) = &state.path_resolver { + match resolver.resolve_path_for_user(parent_path, &user.id).await { + Ok(ResolvedResource::Folder(parent)) => Some(parent.id), + _ => { + return Err(AppError::not_found(format!( + "Parent folder not found: {}", + parent_path + ))); + } + } + } else { + // Legacy fallback — ownership check + match folder_service.get_folder_by_path(parent_path).await { + Ok(parent) => { + assert_owner(parent.owner_id.as_deref(), &user.id, parent_path)?; + Some(parent.id) + } + Err(_) => None, + } + }; + + // Create folder (user_id is inherited from the parent in the DB layer) let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { name: folder_name.to_string(), - parent_id: if parent_path.is_empty() { - None - } else { - // Try to get the parent folder ID from its path - match folder_service.get_folder_by_path(parent_path).await { - Ok(parent) => Some(parent.id), - Err(_) => None, // If not found, use root - } - }, + parent_id, }; folder_service @@ -898,9 +999,11 @@ async fn handle_mkcol( */ async fn handle_delete( state: Arc, - _req: Request, + req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; + // Get services from state let file_retrieval_service = &state.applications.file_retrieval_service; let file_management_service = &state.applications.file_management_service; @@ -911,13 +1014,12 @@ async fn handle_delete( return Err(AppError::forbidden("Cannot delete root folder")); } - // Single-query path resolution + // Single-query path resolution (user-scoped) if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path(&path).await { + match resolver.resolve_path_for_user(&path, &user.id).await { Ok(ResolvedResource::Folder(folder)) => { - let caller_id = folder.owner_id.as_deref().unwrap_or("webdav"); folder_service - .delete_folder(&folder.id, caller_id) + .delete_folder(&folder.id, &user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to delete folder: {}", e)) @@ -934,13 +1036,13 @@ async fn handle_delete( Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))), } } else { - // Fallback: legacy double-query path + // Fallback: legacy double-query path (with ownership check) let folder_result = folder_service.get_folder_by_path(&path).await; if let Ok(folder) = folder_result { - let caller_id = folder.owner_id.as_deref().unwrap_or("webdav"); + assert_owner(folder.owner_id.as_deref(), &user.id, &path)?; folder_service - .delete_folder(&folder.id, caller_id) + .delete_folder(&folder.id, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; } else { @@ -948,6 +1050,7 @@ async fn handle_delete( .get_file_by_path(&path) .await .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; + assert_owner(file.owner_id.as_deref(), &user.id, &path)?; file_management_service .delete_file(&file.id) @@ -978,6 +1081,7 @@ async fn handle_move( req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; let source_path = path; // Get destination from Destination header @@ -1013,7 +1117,10 @@ async fn handle_move( // Check if destination already exists (for Overwrite header compliance) if !overwrite { let dest_exists = if let Some(resolver) = &state.path_resolver { - resolver.exists(&destination_path).await.unwrap_or(false) + resolver + .exists_for_user(&destination_path, &user.id) + .await + .unwrap_or(false) } else { folder_service .get_folder_by_path(&destination_path) @@ -1031,9 +1138,12 @@ async fn handle_move( } } - // Resolve source: single-query when PathResolver is available + // Resolve source: single-query when PathResolver is available (user-scoped) if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path(&source_path).await { + match resolver + .resolve_path_for_user(&source_path, &user.id) + .await + { Ok(ResolvedResource::Folder(folder)) => { let dest_folder_name = destination_path .split('/') @@ -1057,11 +1167,7 @@ async fn handle_move( }; folder_service - .move_folder( - &folder.id, - move_dto, - folder.owner_id.as_deref().unwrap_or("webdav"), - ) + .move_folder(&folder.id, move_dto, &user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to move folder: {}", e)) @@ -1072,11 +1178,7 @@ async fn handle_move( name: dest_folder_name.to_string(), }; folder_service - .rename_folder( - &folder.id, - rename_dto, - folder.owner_id.as_deref().unwrap_or("webdav"), - ) + .rename_folder(&folder.id, rename_dto, &user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to rename folder: {}", e)) @@ -1124,10 +1226,11 @@ async fn handle_move( } } } else { - // Fallback: legacy double-query path + // Fallback: legacy double-query path (with ownership check) let folder_result = folder_service.get_folder_by_path(&source_path).await; if let Ok(folder) = folder_result { + assert_owner(folder.owner_id.as_deref(), &user.id, &source_path)?; let dest_folder_name = destination_path .split('/') .next_back() @@ -1150,11 +1253,7 @@ async fn handle_move( }; folder_service - .move_folder( - &folder.id, - move_dto, - folder.owner_id.as_deref().unwrap_or("webdav"), - ) + .move_folder(&folder.id, move_dto, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; @@ -1163,11 +1262,7 @@ async fn handle_move( name: dest_folder_name.to_string(), }; folder_service - .rename_folder( - &folder.id, - rename_dto, - folder.owner_id.as_deref().unwrap_or("webdav"), - ) + .rename_folder(&folder.id, rename_dto, &user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to rename folder: {}", e)) @@ -1180,6 +1275,7 @@ async fn handle_move( .map_err(|_e| { AppError::not_found(format!("Resource not found: {}", source_path)) })?; + assert_owner(file.owner_id.as_deref(), &user.id, &source_path)?; let dest_filename = destination_path .split('/') @@ -1235,6 +1331,7 @@ async fn handle_copy( req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; let source_path = path; // Get destination from Destination header @@ -1276,7 +1373,10 @@ async fn handle_copy( // Check if destination already exists (for Overwrite header compliance) if !overwrite { let dest_exists = if let Some(resolver) = &state.path_resolver { - resolver.exists(&destination_path).await.unwrap_or(false) + resolver + .exists_for_user(&destination_path, &user.id) + .await + .unwrap_or(false) } else { folder_service .get_folder_by_path(&destination_path) @@ -1294,9 +1394,12 @@ async fn handle_copy( } } - // Resolve source: single-query when PathResolver is available + // Resolve source: single-query when PathResolver is available (user-scoped) if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path(&source_path).await { + match resolver + .resolve_path_for_user(&source_path, &user.id) + .await + { Ok(ResolvedResource::Folder(folder)) => { let recursive = depth != "0"; @@ -1377,10 +1480,11 @@ async fn handle_copy( } } } else { - // Fallback: legacy double-query path + // Fallback: legacy double-query path (with ownership check) let folder_result = folder_service.get_folder_by_path(&source_path).await; if let Ok(folder) = folder_result { + assert_owner(folder.owner_id.as_deref(), &user.id, &source_path)?; let recursive = depth != "0"; let dest_folder_name = destination_path @@ -1436,6 +1540,7 @@ async fn handle_copy( .map_err(|_e| { AppError::not_found(format!("Resource not found: {}", source_path)) })?; + assert_owner(file.owner_id.as_deref(), &user.id, &source_path)?; let dest_parent_path = if let Some(idx) = destination_path.rfind('/') { &destination_path[..idx] @@ -1483,13 +1588,7 @@ async fn handle_lock( req: Request, path: String, ) -> Result, AppError> { - let user = { - let user_ref = req - .extensions() - .get::() - .ok_or_else(|| AppError::unauthorized("Authentication required"))?; - user_ref.clone() - }; + let user = extract_user(&req)?; // Get the headers that we need let depth = req @@ -1611,13 +1710,7 @@ async fn handle_unlock( req: Request, _path: String, ) -> Result, AppError> { - let _user = { - let user_ref = req - .extensions() - .get::() - .ok_or_else(|| AppError::unauthorized("Authentication required"))?; - user_ref.clone() - }; + let _user = extract_user(&req)?; // Get lock token from Lock-Token header let lock_token = req