From 5a679dfc904bc80c1ffc57268037926668a82a71 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Mon, 16 Feb 2026 00:22:42 +0100 Subject: [PATCH] =?UTF-8?q?fix(security):=20patch=203=20vulnerabilities=20?= =?UTF-8?q?=E2=80=94=20IDOR,=20ownership=20bypass,=20XSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1: Add owner-scoped folder pagination (list_folders_by_owner_paginated) - New method in FolderRepository trait, PG implementation, service & handler - Prevents IDOR by filtering folder listings to authenticated user V2: Enforce ownership checks on folder mutations - rename_folder, move_folder, delete_folder now require caller_id - Service verifies folder.owner_id == caller_id (returns 404 on mismatch) - Propagated to folder_handler, batch_handler, batch_operations, webdav_handler - delete_folder_with_trash upgraded from OptionalAuthUser to AuthUser - download_folder_zip now checks ownership before streaming V3: Fix XSS in frontend via DOM APIs - sharedView.js: innerHTML → createElement + textContent - contextMenus.js: innerHTML → DOM construction for share dialog Cleanup: removed unused OptionalAuthUser import, updated all stubs/mocks --- src/application/ports/inbound.rs | 20 +- src/application/services/batch_operations.rs | 10 +- src/application/services/folder_service.rs | 114 ++++- src/application/services/share_service.rs | 12 + .../services/trash_service_test.rs | 11 + src/common/stubs.rs | 25 +- src/domain/repositories/folder_repository.rs | 12 + .../repositories/pg/folder_db_repository.rs | 76 +++ src/interfaces/api/handlers/batch_handler.rs | 6 +- src/interfaces/api/handlers/folder_handler.rs | 53 ++- src/interfaces/api/handlers/webdav_handler.rs | 9 +- static/css/style.css | 29 +- static/js/app.js | 5 +- static/js/components/sharedView.js | 8 +- static/js/contextMenus.js | 53 ++- static/js/multiSelect.js | 432 ++++++++++-------- static/js/ui.js | 12 + static/locales/de.json | 8 + static/locales/en.json | 8 + static/locales/es.json | 8 + static/locales/fa.json | 8 + static/locales/fr.json | 8 + static/locales/it.json | 10 +- static/locales/pt.json | 8 + static/locales/zh.json | 8 + 25 files changed, 684 insertions(+), 269 deletions(-) diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 2ae28f95..2328fc0d 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -38,15 +38,23 @@ pub trait FolderUseCase: Send + Sync + 'static { pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result, DomainError>; - /// Renames a folder - async fn rename_folder(&self, id: &str, dto: RenameFolderDto) + /// Lists folders with pagination, scoped to a specific owner. + async fn list_folders_for_owner_paginated( + &self, + parent_id: Option<&str>, + owner_id: &str, + 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: &str) -> Result; - /// Moves a folder to another parent - async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result; + /// Moves a folder to another parent (ownership verified against caller_id) + async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result; - /// Deletes a folder - async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; + /// Deletes a folder (ownership verified against caller_id) + async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>; } /** diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index a1361ace..95b55345 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -408,6 +408,7 @@ impl BatchOperationService { &self, folder_ids: Vec, _recursive: bool, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch deletion of {} folders", folder_ids.len()); let start_time = std::time::Instant::now(); @@ -427,14 +428,13 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); let semaphore = self.semaphore.clone(); let id_clone = folder_id.clone(); + let caller = caller_id.to_string(); async move { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - // For both recursive and non-recursive, use the standard delete_folder method - // since FolderUseCase only has a single delete_folder method - let delete_result = folder_service.delete_folder(&folder_id).await; + let delete_result = folder_service.delete_folder(&folder_id, &caller).await; // Release the permit explicitly drop(permit); @@ -616,6 +616,7 @@ impl BatchOperationService { &self, folder_ids: Vec, target_folder_id: Option, + caller_id: &str, ) -> Result, BatchOperationError> { info!("Starting batch move of {} folders", folder_ids.len()); let start_time = std::time::Instant::now(); @@ -633,11 +634,12 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); let target = target_folder_id.clone(); let semaphore = self.semaphore.clone(); + let caller = caller_id.to_string(); async move { let permit = semaphore.acquire().await.unwrap(); let dto = MoveFolderDto { parent_id: target }; - let move_result = folder_service.move_folder(&folder_id, dto).await; + let move_result = folder_service.move_folder(&folder_id, dto, &caller).await; drop(permit); (folder_id, move_result) } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 9365b05b..978583c5 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -71,10 +71,30 @@ impl FolderService { ) } + async fn list_folders_for_owner_paginated( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + _pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result< + crate::application::dtos::pagination::PaginatedResponseDto, + DomainError, + > { + Ok( + crate::application::dtos::pagination::PaginatedResponseDto::new( + vec![], + 0, + 10, + 0, + ), + ) + } + async fn rename_folder( &self, _id: &str, _dto: RenameFolderDto, + _caller_id: &str, ) -> Result { Ok(FolderDto::empty()) } @@ -83,11 +103,12 @@ impl FolderService { &self, _id: &str, _dto: MoveFolderDto, + _caller_id: &str, ) -> Result { Ok(FolderDto::empty()) } - async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { + async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> { Ok(()) } } @@ -211,17 +232,15 @@ impl FolderUseCase for FolderService { pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result, DomainError> { - // Validate and adjust pagination let pagination = pagination.validate_and_adjust(); - // Get paginated folders and total count let (folders, total_items) = self .folder_storage .list_folders_paginated( parent_id, pagination.offset(), pagination.limit(), - true, // Always include total for better UX + true, ) .await .map_err(|e| { @@ -234,10 +253,8 @@ impl FolderUseCase for FolderService { ) })?; - // The total is needed to calculate pagination let total = total_items.unwrap_or(folders.len()); - // Convert to PaginatedResponseDto let response = crate::application::dtos::pagination::PaginatedResponseDto::new( folders.into_iter().map(FolderDto::from).collect(), pagination.page, @@ -248,11 +265,54 @@ impl FolderUseCase for FolderService { Ok(response) } - /// Renames a folder + /// Lists folders with pagination, scoped to a specific owner. + async fn list_folders_for_owner_paginated( + &self, + parent_id: Option<&str>, + owner_id: &str, + pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result, DomainError> + { + let pagination = pagination.validate_and_adjust(); + + let (folders, total_items) = self + .folder_storage + .list_folders_by_owner_paginated( + parent_id, + owner_id, + pagination.offset(), + pagination.limit(), + true, + ) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!( + "Failed to list folders for owner '{}' with pagination in parent {:?}: {}", + owner_id, parent_id, e + ), + ) + })?; + + let total = total_items.unwrap_or(folders.len()); + + let response = crate::application::dtos::pagination::PaginatedResponseDto::new( + folders.into_iter().map(FolderDto::from).collect(), + pagination.page, + pagination.page_size, + total, + ); + + Ok(response) + } + + /// Renames a folder after verifying ownership. async fn rename_folder( &self, id: &str, dto: RenameFolderDto, + caller_id: &str, ) -> Result { // Input validation if dto.name.is_empty() { @@ -263,7 +323,7 @@ impl FolderUseCase for FolderService { )); } - // Verify the folder exists + // Verify the folder exists and belongs to the caller let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| { DomainError::internal_error( "FolderStorage", @@ -271,6 +331,14 @@ impl FolderUseCase for FolderService { ) })?; + if existing_folder.owner_id() != Some(caller_id) { + tracing::warn!( + "rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'", + caller_id, id, existing_folder.owner_id() + ); + return Err(DomainError::not_found("Folder", id)); + } + // Create transaction for renaming let mut transaction = StorageTransaction::new("rename_folder"); @@ -323,9 +391,9 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } - /// Moves a folder to a new parent - async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result { - // Verify the source folder exists + /// Moves a folder to a new parent after verifying ownership. + async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result { + // Verify the source folder exists and belongs to the caller let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| { DomainError::internal_error( "FolderStorage", @@ -333,6 +401,14 @@ impl FolderUseCase for FolderService { ) })?; + if source_folder.owner_id() != Some(caller_id) { + tracing::warn!( + "move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'", + caller_id, id, source_folder.owner_id() + ); + return Err(DomainError::not_found("Folder", id)); + } + // If a parent_id is specified, verify it exists if let Some(parent_id) = &dto.parent_id { // Verify we are not trying to move the folder into itself or one of its descendants @@ -408,17 +484,23 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } - /// Deletes a folder - async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { - // Verify the folder exists - let _folder = self.folder_storage.get_folder(id).await.map_err(|e| { + /// Deletes a folder after verifying ownership. + async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError> { + // Verify the folder exists and belongs to the caller + let folder = self.folder_storage.get_folder(id).await.map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to get folder with ID: {} for deletion: {}", id, e), ) })?; - // In a real implementation, we could verify permissions, dependencies, etc. + if folder.owner_id() != Some(caller_id) { + tracing::warn!( + "delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'", + caller_id, id, folder.owner_id() + ); + return Err(DomainError::not_found("Folder", id)); + } // Delete the folder self.folder_storage.delete_folder(id).await.map_err(|e| { diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index df6ab80e..3c1e9d02 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -544,6 +544,18 @@ mod tests { unimplemented!() } + async fn list_folders_by_owner_paginated( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + _offset: usize, + _limit: usize, + _include_total: bool, + ) -> Result<(Vec, Option), DomainError> + { + unimplemented!() + } + async fn rename_folder( &self, _id: &str, diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 88de9d1e..be1d89e5 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -373,6 +373,17 @@ impl FolderRepository for MockFolderRepository { Ok((vec![], Some(0))) } + async fn list_folders_by_owner_paginated( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + _offset: usize, + _limit: usize, + _include_total: bool, + ) -> std::result::Result<(Vec, Option), DomainError> { + Ok((vec![], Some(0))) + } + async fn rename_folder( &self, _id: &str, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index fea17ea2..2b209f6a 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -268,6 +268,17 @@ impl FolderRepository for StubFolderStoragePort { Ok((Vec::new(), Some(0))) } + async fn list_folders_by_owner_paginated( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + _offset: usize, + _limit: usize, + _include_total: bool, + ) -> Result<(Vec, Option), DomainError> { + Ok((Vec::new(), Some(0))) + } + async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { Ok(Folder::default()) } @@ -374,19 +385,29 @@ impl FolderUseCase for StubFolderUseCase { Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) } + async fn list_folders_for_owner_paginated( + &self, + _parent_id: Option<&str>, + _owner_id: &str, + _pagination: &PaginationRequestDto, + ) -> Result, DomainError> { + Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) + } + async fn rename_folder( &self, _id: &str, _dto: RenameFolderDto, + _caller_id: &str, ) -> Result { Ok(FolderDto::default()) } - async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result { + async fn move_folder(&self, _id: &str, _dto: MoveFolderDto, _caller_id: &str) -> Result { Ok(FolderDto::default()) } - async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> { + async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> { Ok(()) } } diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index bcd66b49..b7fba4ce 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -54,6 +54,18 @@ pub trait FolderRepository: Send + Sync + 'static { include_total: bool, ) -> Result<(Vec, Option), DomainError>; + /// Lists folders with pagination, scoped to a specific owner. + /// Combines the owner filtering of `list_folders_by_owner` with + /// the pagination of `list_folders_paginated`. + async fn list_folders_by_owner_paginated( + &self, + parent_id: Option<&str>, + owner_id: &str, + offset: usize, + limit: usize, + include_total: bool, + ) -> Result<(Vec, Option), DomainError>; + /// Renames a folder async fn rename_folder(&self, id: &str, new_name: String) -> Result; diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index dca75b37..eb7eb1f2 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -369,6 +369,82 @@ impl FolderRepository for FolderDbRepository { Ok((folders, total)) } + async fn list_folders_by_owner_paginated( + &self, + parent_id: Option<&str>, + owner_id: &str, + offset: usize, + limit: usize, + include_total: bool, + ) -> Result<(Vec, Option), DomainError> { + let total = if include_total { + let count: i64 = if let Some(pid) = parent_id { + sqlx::query_scalar( + "SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed", + ) + .bind(pid) + .bind(owner_id) + .fetch_one(self.pool()) + .await + } else { + sqlx::query_scalar( + "SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed", + ) + .bind(owner_id) + .fetch_one(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("count_by_owner: {e}")))?; + Some(count as usize) + } else { + None + }; + + let rows: Vec<(String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, user_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed + ORDER BY name + LIMIT $3 OFFSET $4 + "#, + ) + .bind(pid) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, user_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed + ORDER BY name + LIMIT $2 OFFSET $3 + "#, + ) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; + + let mut folders = Vec::with_capacity(rows.len()); + for (id, name, pid, uid, ca, ma) in rows { + folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?); + } + Ok((folders, total)) + } + async fn rename_folder(&self, id: &str, new_name: String) -> Result { sqlx::query( r#" diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 00cef92f..22dfcd5e 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -258,6 +258,7 @@ pub async fn delete_files_batch( /// Handler for deleting multiple folders in batch pub async fn delete_folders_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { // Verify there are folders to process @@ -274,7 +275,7 @@ pub async fn delete_folders_batch( // Execute batch operation let result = state .batch_service - .delete_folders(request.folder_ids, request.recursive) + .delete_folders(request.folder_ids, request.recursive, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -555,6 +556,7 @@ pub async fn trash_batch( /// Handler for moving multiple folders in batch pub async fn move_folders_batch( State(state): State, + auth_user: AuthUser, Json(request): Json, ) -> ApiResult { if request.folder_ids.is_empty() { @@ -569,7 +571,7 @@ pub async fn move_folders_batch( let result = state .batch_service - .move_folders(request.folder_ids, request.target_folder_id) + .move_folders(request.folder_ids, request.target_folder_id, &auth_user.id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index a1d8de83..3a5dcb71 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -13,7 +13,7 @@ use crate::application::ports::inbound::FolderUseCase; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; use crate::common::errors::ErrorKind; -use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; +use crate::interfaces::middleware::auth::AuthUser; type AppState = Arc; @@ -136,15 +136,14 @@ impl FolderHandler { } /// Lists contents of a specific folder with pagination. + /// Scoped to the authenticated user — only returns folders owned by this user. pub async fn list_folder_contents_paginated( State(service): State, - _auth_user: AuthUser, + auth_user: AuthUser, Path(id): Path, pagination: Query, ) -> axum::response::Response { - // For sub-folder pagination, use the standard paginated path - // (owner filtering is implicit — sub-folders inherit ownership) - match service.list_folders_paginated(Some(&id), &pagination).await { + match service.list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination).await { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => { let status = match err.kind { @@ -184,13 +183,14 @@ impl FolderHandler { } } - /// Renames a folder + /// Renames a folder (ownership enforced by service layer) pub async fn rename_folder( State(service): State, + auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { - match service.rename_folder(&id, dto).await { + match service.rename_folder(&id, dto, &auth_user.id).await { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => { let status = match err.kind { @@ -211,13 +211,14 @@ impl FolderHandler { } } - /// Moves a folder to a new parent + /// Moves a folder to a new parent (ownership enforced by service layer) pub async fn move_folder( State(service): State, + auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { - match service.move_folder(&id, dto).await { + match service.move_folder(&id, dto, &auth_user.id).await { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => { let status = match err.kind { @@ -231,13 +232,13 @@ impl FolderHandler { } } - /// Deletes a folder (with trash support) + /// Deletes a folder (ownership enforced by service layer) pub async fn delete_folder( State(service): State, + auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - // For folder deletion without trash functionality - match service.delete_folder(&id).await { + match service.delete_folder(&id, &auth_user.id).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(err) => { let status = match err.kind { @@ -250,16 +251,13 @@ impl FolderHandler { } } - /// Deletes a folder with trash functionality + /// Deletes a folder with trash functionality (ownership enforced by service layer) pub async fn delete_folder_with_trash( State(state): State, - OptionalAuthUser(auth_user): OptionalAuthUser, + auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = auth_user - .as_ref() - .map(|u| u.id.as_str()) - .unwrap_or("anonymous"); + let user_id = &auth_user.id; // Check if trash service is available if let Some(trash_service) = &state.trash_service { tracing::info!("Moving folder to trash: {}", id); @@ -282,7 +280,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).await { + match folder_service.delete_folder(&id, user_id).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() @@ -306,19 +304,32 @@ impl FolderHandler { } } - /// Downloads a folder as a ZIP file + /// Downloads a folder as a ZIP file (ownership enforced) pub async fn download_folder_zip( State(state): State, + auth_user: AuthUser, Path(id): Path, Query(_params): Query>, ) -> impl IntoResponse { tracing::info!("Downloading folder as ZIP: {}", id); - // Get folder information first to check it exists and get name + // Get folder information and verify ownership let folder_service = &state.applications.folder_service; match folder_service.get_folder(&id).await { Ok(folder) => { + // Access check: folder must belong to the requesting user + if folder.owner_id.as_deref() != Some(&auth_user.id) { + tracing::warn!( + "download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'", + auth_user.id, id, folder.owner_id + ); + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Folder not found" })), + ) + .into_response(); + } tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); // Use ZIP service from DI container diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index f573d5b4..1e4ac251 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -645,9 +645,10 @@ async fn handle_delete( let folder_result = folder_service.get_folder_by_path(&path).await; if let Ok(folder) = folder_result { - // Delete folder + // Delete folder — use the folder's own owner as caller_id + let caller_id = folder.owner_id.as_deref().unwrap_or("webdav"); folder_service - .delete_folder(&folder.id) + .delete_folder(&folder.id, caller_id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; } else { @@ -761,7 +762,7 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto) + .move_folder(&folder.id, move_dto, folder.owner_id.as_deref().unwrap_or("webdav")) .await .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; @@ -771,7 +772,7 @@ async fn handle_move( }; folder_service - .rename_folder(&folder.id, rename_dto) + .rename_folder(&folder.id, rename_dto, folder.owner_id.as_deref().unwrap_or("webdav")) .await .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; } diff --git a/static/css/style.css b/static/css/style.css index 10871adc..50a64d1a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -3919,7 +3919,27 @@ html[dir='rtl'] .fa-sign-out-alt { /* Checked via JS */ } -/* -- Batch action bar -- */ +/* ── Selection-mode header (replaces Name/Type/Size/Modified) ── */ +.list-header.selection-mode { + grid-template-columns: 36px 1fr; + background-color: #1e293b; + color: #fff; + border-bottom-color: #334155; +} + +.list-header.selection-mode .list-header-checkbox input[type="checkbox"] { + accent-color: #ff5e3a; +} + +.batch-selection-info { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; +} + +/* -- Batch action bar (grid view floating bar) -- */ .batch-action-bar { display: flex; align-items: center; @@ -4720,11 +4740,16 @@ html[dir='rtl'] .fa-sign-out-alt { color: #e2e8f0; } /* Batch bar dark mode */ -[data-theme="dark"] .batch-bar { +[data-theme="dark"] .batch-action-bar { background-color: #1e293b; border-color: #334155; color: #e2e8f0; } +[data-theme="dark"] .list-header.selection-mode { + background-color: #0f172a; + border-bottom-color: #334155; + color: #e2e8f0; +} /* Search results dark mode */ [data-theme="dark"] .search-results-header { color: #f1f5f9; diff --git a/static/js/app.js b/static/js/app.js index fa017111..f258989b 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -678,10 +678,11 @@ function setupEventListeners() { } // Deselect all cards when clicking empty area (not on a card, menu, or modal) - if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar')) { + // Note: multiSelect._hookGlobalDeselect() handles clearing the internal + // selection state; this handler only covers the legacy CSS class removal. + if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar') && !e.target.closest('.list-header.selection-mode')) { document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected')); document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected')); - if (window.multiSelect) window.multiSelect.clear(); } }); } diff --git a/static/js/components/sharedView.js b/static/js/components/sharedView.js index caf5bd8d..afce34da 100644 --- a/static/js/components/sharedView.js +++ b/static/js/components/sharedView.js @@ -292,7 +292,13 @@ const sharedView = { const nameCell = document.createElement('td'); nameCell.className = 'shared-item-name'; - nameCell.innerHTML = `${item.item_type === 'file' ? '📄' : '📁'}${displayName}`; + const iconSpan = document.createElement('span'); + iconSpan.className = 'item-icon'; + iconSpan.textContent = item.item_type === 'file' ? '📄' : '📁'; + const nameSpan = document.createElement('span'); + nameSpan.textContent = displayName; + nameCell.appendChild(iconSpan); + nameCell.appendChild(nameSpan); const typeCell = document.createElement('td'); typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder'); diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index 54bba865..23bb1253 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -543,21 +543,44 @@ const contextMenus = { `Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` : 'No expiration'; - shareEl.innerHTML = ` - - - - `; + // Share URL + const urlDiv = document.createElement('div'); + urlDiv.className = 'share-url'; + urlDiv.textContent = share.url; + shareEl.appendChild(urlDiv); + + // Share info + const infoDiv = document.createElement('div'); + infoDiv.className = 'share-info'; + if (share.has_password) { + const protectedSpan = document.createElement('span'); + protectedSpan.className = 'share-protected'; + protectedSpan.innerHTML = ' Password protected'; + infoDiv.appendChild(protectedSpan); + } + const expirationSpan = document.createElement('span'); + expirationSpan.className = 'share-expiration'; + expirationSpan.textContent = expiresText; + infoDiv.appendChild(expirationSpan); + shareEl.appendChild(infoDiv); + + // Share actions + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'share-actions'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'btn btn-small copy-link-btn'; + copyBtn.dataset.shareUrl = share.url; + copyBtn.innerHTML = ' Copy'; + actionsDiv.appendChild(copyBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-small btn-danger delete-link-btn'; + deleteBtn.dataset.shareId = share.id; + deleteBtn.innerHTML = ' Delete'; + actionsDiv.appendChild(deleteBtn); + + shareEl.appendChild(actionsDiv); existingSharesContainer.appendChild(shareEl); }); diff --git a/static/js/multiSelect.js b/static/js/multiSelect.js index 49efe6e4..c5e69a9c 100644 --- a/static/js/multiSelect.js +++ b/static/js/multiSelect.js @@ -1,89 +1,75 @@ /** * OxiCloud - Multi-Select & Batch Actions Module - * Adds checkboxes to both grid and list views, a batch action bar, - * and batch delete / move / download operations. + * + * Adds checkboxes to grid and list views, replaces the list-view header + * with a NextCloud-style selection bar when items are selected, and + * provides batch delete / move / download / favorites operations. */ const multiSelect = { - /** Currently selected items: { id, name, type: 'file'|'folder', parentId } */ + /** Currently selected items: Map */ _selected: new Map(), /** Last clicked index for Shift-range selection */ _lastClickedIndex: -1, - /** Whether the batch bar is currently visible */ + /** Whether the selection bar is currently visible */ _barVisible: false, + /** Saved original list-header HTML so we can restore it */ + _savedHeaderHTML: '', + // ── Public API ────────────────────────────────────────── - /** Number of selected items */ - get count() { return this._selected.size; }, - - /** All selected items as an array */ - get items() { return Array.from(this._selected.values()); }, - - /** True when at least one item is selected */ + get count() { return this._selected.size; }, + get items() { return Array.from(this._selected.values()); }, get hasSelection() { return this._selected.size > 0; }, + get files() { return this.items.filter(i => i.type === 'file'); }, + get folders() { return this.items.filter(i => i.type === 'folder'); }, - /** Get selected files only */ - get files() { return this.items.filter(i => i.type === 'file'); }, + // ── Helpers for i18n ──────────────────────────────────── - /** Get selected folders only */ - get folders() { return this.items.filter(i => i.type === 'folder'); }, + _t(key, vars) { + if (window.i18n && typeof window.i18n.t === 'function') { + const val = window.i18n.t(key, vars); + // If i18n returned the key itself, it's missing → fall back + if (val && val !== key) return val; + } + return null; + }, // ── Selection state management ────────────────────────── - /** - * Toggle an item in the selection. - * @param {string} id - * @param {string} name - * @param {'file'|'folder'} type - * @param {string} parentId parent / folder id - * @returns {boolean} new selected state - */ toggle(id, name, type, parentId) { - if (this._selected.has(id)) { - this._selected.delete(id); - return false; - } + if (this._selected.has(id)) { this._selected.delete(id); return false; } this._selected.set(id, { id, name, type, parentId }); return true; }, - /** Select a single item (add if not present) */ select(id, name, type, parentId) { this._selected.set(id, { id, name, type, parentId }); }, - /** Deselect a single item */ - deselect(id) { - this._selected.delete(id); - }, + deselect(id) { this._selected.delete(id); }, - /** Clear the whole selection */ clear() { this._selected.clear(); this._lastClickedIndex = -1; - // Remove visual state from DOM - document.querySelectorAll('.file-card.selected, .file-item.selected').forEach(el => { - el.classList.remove('selected'); - }); - // Uncheck all item checkboxes + document.querySelectorAll('.file-card.selected, .file-item.selected') + .forEach(el => el.classList.remove('selected')); document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false); this._syncUI(); }, - /** Select all visible items */ selectAll() { this._selectAllInContainer('files-grid', '.file-card'); this._selectAllInContainer('files-list-view', '.file-item'); this._syncUI(); }, - /** Deselect/select all toggle */ toggleAll() { const allItems = this._getAllVisibleItems(); - if (this._selected.size === allItems.length && allItems.length > 0) { + if (this._selected.size >= allItems.length && allItems.length > 0) { this.clear(); } else { this.selectAll(); @@ -92,7 +78,6 @@ const multiSelect = { // ── DOM helpers ───────────────────────────────────────── - /** Gather info from a DOM element and add to selection */ _selectElement(el) { const info = this._extractInfo(el); if (info) { @@ -108,62 +93,41 @@ const multiSelect = { }, _getAllVisibleItems() { - const gridItems = [...document.querySelectorAll('#files-grid .file-card')]; - const listItems = [...document.querySelectorAll('#files-list-view .file-item')]; - // Only return items from the currently visible view const grid = document.getElementById('files-grid'); - if (grid && grid.style.display !== 'none') return gridItems; - return listItems; + if (grid && grid.style.display !== 'none') { + return [...grid.querySelectorAll('.file-card')]; + } + return [...document.querySelectorAll('#files-list-view .file-item')]; }, - /** Extract item info from a DOM element */ _extractInfo(el) { if (el.dataset.folderId && el.dataset.folderName !== undefined) { - return { - id: el.dataset.folderId, - name: el.dataset.folderName, - type: 'folder', - parentId: el.dataset.parentId || '' - }; + return { id: el.dataset.folderId, name: el.dataset.folderName, type: 'folder', parentId: el.dataset.parentId || '' }; } if (el.dataset.fileId) { - return { - id: el.dataset.fileId, - name: el.dataset.fileName, - type: 'file', - parentId: el.dataset.folderId || '' - }; + return { id: el.dataset.fileId, name: el.dataset.fileName, type: 'file', parentId: el.dataset.folderId || '' }; } return null; }, // ── Click handler (shared by grid + list) ─────────────── - /** - * Handle a checkbox/selection click on an item element. - * Supports Shift-click for range selection. - */ handleItemClick(el, event) { const items = this._getAllVisibleItems(); const index = items.indexOf(el); - - // Also find the matching element in the other view const info = this._extractInfo(el); if (!info) return; const selectorOther = info.type === 'folder' ? `[data-folder-id="${info.id}"]` : `[data-file-id="${info.id}"]`; - const otherEl = [...document.querySelectorAll(selectorOther)] - .find(e => e !== el); + const otherEl = [...document.querySelectorAll(selectorOther)].find(e => e !== el); if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) { - // Range selection const start = Math.min(this._lastClickedIndex, index); const end = Math.max(this._lastClickedIndex, index); for (let i = start; i <= end; i++) { this._selectElement(items[i]); - // Mirror to other view const iInfo = this._extractInfo(items[i]); if (iInfo) { const sel = iInfo.type === 'folder' @@ -173,96 +137,184 @@ const multiSelect = { } } } else { - // Normal toggle const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId); el.classList.toggle('selected', nowSelected); if (otherEl) otherEl.classList.toggle('selected', nowSelected); } - this._lastClickedIndex = index; this._syncUI(); }, - // ── Batch action bar ──────────────────────────────────── + // ── Selection bar (replaces list-header when items selected) ──── - /** Create the batch action bar if it doesn't exist */ - _ensureBar() { - if (document.getElementById('batch-action-bar')) return; + /** + * Build the inner HTML for the selection bar that replaces the + * normal list-header columns (Name / Type / Size / Modified). + */ + _buildSelectionBarHTML(n) { + const countText = n === 1 + ? (this._t('batch.one_selected') || '1 item selected') + : (this._t('batch.n_selected', { count: n }) || `${n} items selected`); - const bar = document.createElement('div'); - bar.id = 'batch-action-bar'; - bar.className = 'batch-action-bar'; - bar.innerHTML = ` -
- - 0 selected + const favLabel = this._t('batch.add_favorites') || 'Add to favorites'; + const moveLabel = this._t('batch.move_copy') || 'Move or copy'; + const dlLabel = this._t('actions.download') || 'Download'; + const delLabel = this._t('actions.delete') || 'Delete'; + + return ` +
+
-
- - - +
+ ${countText} +
+ + + + +
`; - - // Insert before the files-container (inside main-content) - const filesContainer = document.querySelector('.files-container'); - if (filesContainer && filesContainer.parentNode) { - filesContainer.parentNode.insertBefore(bar, filesContainer); - } else { - document.body.appendChild(bar); - } - - // Wire up events - document.getElementById('batch-bar-close').addEventListener('click', () => this.clear()); - document.getElementById('batch-delete').addEventListener('click', () => this.batchDelete()); - document.getElementById('batch-move').addEventListener('click', () => this.batchMove()); - document.getElementById('batch-download').addEventListener('click', () => this.batchDownload()); }, - /** Show/hide the bar and update the count */ + /** Ensure the grid-view batch bar exists (shown only when grid is visible) */ + _ensureGridBar() { + if (document.getElementById('batch-grid-bar')) return; + const bar = document.createElement('div'); + bar.id = 'batch-grid-bar'; + bar.className = 'batch-action-bar'; // reuse same styles + const container = document.querySelector('.files-container'); + if (container) { + container.insertBefore(bar, container.firstChild); + } + }, + + /** Main UI sync — called after every selection change */ _syncUI() { - this._ensureBar(); - const bar = document.getElementById('batch-action-bar'); - const count = document.getElementById('batch-bar-count'); + const listHeader = document.querySelector('.list-header'); + const n = this._selected.size; - if (this._selected.size > 0) { - bar.classList.add('visible'); - this._barVisible = true; - const n = this._selected.size; - const itemsText = n === 1 - ? (window.i18n ? window.i18n.t('batch.one_selected') : '1 item selected') - : (window.i18n ? window.i18n.t('batch.n_selected', { count: n }) : `${n} items selected`); - count.textContent = itemsText; - } else { - bar.classList.remove('visible'); - this._barVisible = false; + // ── Save original header HTML on first use ── + if (listHeader && !this._savedHeaderHTML) { + this._savedHeaderHTML = listHeader.innerHTML; } - // Update select-all checkbox state - this._syncSelectAllCheckbox(); + if (n > 0) { + this._barVisible = true; - // Sync individual list-view checkboxes + // ── List view: replace header with selection bar ── + if (listHeader) { + listHeader.classList.add('selection-mode'); + listHeader.innerHTML = this._buildSelectionBarHTML(n); + + // Wire checkbox + const cb = document.getElementById('select-all-checkbox'); + if (cb) cb.addEventListener('change', () => this.toggleAll()); + + // Wire action buttons + this._wireBarButtons(); + } + + // ── Grid view: show floating bar ── + this._ensureGridBar(); + const gridBar = document.getElementById('batch-grid-bar'); + if (gridBar) { + const grid = document.getElementById('files-grid'); + const gridVisible = grid && grid.style.display !== 'none'; + if (gridVisible) { + gridBar.classList.add('visible'); + gridBar.innerHTML = ` +
+ + ${ + n === 1 + ? (this._t('batch.one_selected') || '1 item selected') + : (this._t('batch.n_selected', { count: n }) || `${n} items selected`) + } +
+
+ + + + +
+ `; + const closeBtn = document.getElementById('batch-grid-close'); + if (closeBtn) closeBtn.addEventListener('click', () => this.clear()); + this._wireBarButtons(); + } else { + gridBar.classList.remove('visible'); + } + } + } else { + this._barVisible = false; + + // Restore original list header + if (listHeader) { + listHeader.classList.remove('selection-mode'); + if (this._savedHeaderHTML) { + listHeader.innerHTML = this._savedHeaderHTML; + } + // Re-wire the select-all checkbox + const cb = document.getElementById('select-all-checkbox'); + if (cb) cb.addEventListener('change', () => this.toggleAll()); + // Translate restored header + if (window.i18n && window.i18n.translatePage) window.i18n.translatePage(); + } + + // Hide grid bar + const gridBar = document.getElementById('batch-grid-bar'); + if (gridBar) gridBar.classList.remove('visible'); + } + + // Sync individual item checkboxes this._syncItemCheckboxes(); + // Sync select-all checkbox state (for non-selection-mode) + if (!this._barVisible) this._syncSelectAllCheckbox(); + }, + + /** Wire click handlers on batch action buttons (idempotent per render) */ + _wireBarButtons() { + const del = document.getElementById('batch-delete'); + const move = document.getElementById('batch-move'); + const dl = document.getElementById('batch-download'); + const fav = document.getElementById('batch-fav'); + if (del) del.onclick = () => this.batchDelete(); + if (move) move.onclick = () => this.batchMove(); + if (dl) dl.onclick = () => this.batchDownload(); + if (fav) fav.onclick = () => this.batchFavorites(); }, - /** Sync individual item checkboxes with selection state */ _syncItemCheckboxes() { document.querySelectorAll('.file-item').forEach(el => { const cb = el.querySelector('.item-checkbox'); - if (cb) { - cb.checked = el.classList.contains('selected'); - } + if (cb) cb.checked = el.classList.contains('selected'); }); }, @@ -273,7 +325,7 @@ const multiSelect = { if (all.length === 0) { cb.checked = false; cb.indeterminate = false; - } else if (this._selected.size === all.length) { + } else if (this._selected.size >= all.length) { cb.checked = true; cb.indeterminate = false; } else if (this._selected.size > 0) { @@ -294,21 +346,19 @@ const multiSelect = { const n = items.length; const msg = n === 1 - ? (window.i18n - ? window.i18n.t('dialogs.confirm_delete_file', { name: items[0].name }) - : `Are you sure you want to move "${items[0].name}" to trash?`) - : (window.i18n - ? window.i18n.t('batch.confirm_delete', { count: n }) - : `Are you sure you want to move ${n} items to trash?`); + ? (this._t('dialogs.confirm_delete_file', { name: items[0].name }) + || `Are you sure you want to move "${items[0].name}" to trash?`) + : (this._t('batch.confirm_delete', { count: n }) + || `Are you sure you want to move ${n} items to trash?`); const confirmed = await showConfirmDialog({ - title: window.i18n ? window.i18n.t('dialogs.confirm_delete') : 'Move to trash', + title: this._t('dialogs.confirm_delete') || 'Move to trash', message: msg, - confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Delete', + confirmText: this._t('actions.delete') || 'Delete', }); if (!confirmed) return; - const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); try { @@ -317,21 +367,17 @@ const multiSelect = { headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) }); - const data = await response.json(); const success = data.stats?.successful || 0; - const errors = data.stats?.failed || 0; + const errors = data.stats?.failed || 0; this.clear(); window.loadFiles(); if (errors > 0) { - const failedNames = (data.failed || []).map(f => f.id).join(', '); - window.ui.showNotification('Batch delete', - `${success} moved to trash, ${errors} failed`); + window.ui.showNotification('Batch delete', `${success} moved to trash, ${errors} failed`); } else { - window.ui.showNotification('Moved to trash', - `${success} item${success !== 1 ? 's' : ''} moved to trash`); + window.ui.showNotification('Moved to trash', `${success} item${success !== 1 ? 's' : ''} moved to trash`); } } catch (e) { console.error('Batch trash error:', e); @@ -346,26 +392,19 @@ const multiSelect = { const items = this.items; if (items.length === 0) return; - // Set a special batch mode flag window.app.moveDialogMode = 'batch'; window.app.batchMoveItems = items; - - // Reset selection window.app.selectedTargetFolderId = ""; - // Update dialog title const dialog = document.getElementById('move-file-dialog'); const dialogHeader = dialog.querySelector('.rename-dialog-header'); const n = items.length; - const titleText = window.i18n - ? window.i18n.t('batch.move_title', { count: n }) - : `Move ${n} item${n !== 1 ? 's' : ''}`; + const titleText = this._t('batch.move_title', { count: n }) + || `Move ${n} item${n !== 1 ? 's' : ''}`; dialogHeader.innerHTML = ` ${titleText}`; - // Load folders, excluding selected folder IDs const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id); await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch'); - dialog.style.display = 'flex'; }, @@ -377,7 +416,7 @@ const multiSelect = { window.ui.showNotification('Preparing download', 'Creating ZIP archive...'); try { - const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); const response = await fetch('/api/batch/download', { @@ -386,12 +425,10 @@ const multiSelect = { body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) }); - if (!response.ok) { - throw new Error(`Server returned ${response.status}`); - } + if (!response.ok) throw new Error(`Server returned ${response.status}`); const blob = await response.blob(); - const url = URL.createObjectURL(blob); + const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `oxicloud-download-${Date.now()}.zip`; @@ -405,61 +442,70 @@ const multiSelect = { } }, + /** Batch add to favorites */ + async batchFavorites() { + const items = this.items; + if (items.length === 0 || !window.favorites) return; + + let added = 0; + for (const item of items) { + const alreadyFav = window.favorites.isFavorite(item.id, item.type); + if (!alreadyFav) { + await window.favorites.addToFavorites(item.id, item.name, item.type, item.parentId); + added++; + } + } + + this.clear(); + if (typeof window.loadFiles === 'function') window.loadFiles(); + if (added > 0) { + window.ui.showNotification( + this._t('favorites.add') || 'Added to favorites', + `${added} item${added !== 1 ? 's' : ''} added to favorites` + ); + } else { + window.ui.showNotification( + this._t('favorites.add') || 'Favorites', + 'All selected items are already favorites' + ); + } + }, + // ── Initialization ────────────────────────────────────── init() { - // Inject the select-all checkbox into the list header + // Wire the initial select-all checkbox this._injectListHeaderCheckbox(); - // Override the deselect-on-empty-area handler to also clear our state + // Global deselect on empty-area click this._hookGlobalDeselect(); - // Hook into the move dialog confirm to handle batch mode - // (handled in contextMenus.js — moveDialogMode === 'batch') - - // Keyboard shortcut: Ctrl+A to select all, Escape to clear + // Keyboard shortcuts document.addEventListener('keydown', (e) => { - // Don't trigger when inside an input/textarea/modal if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return; if ((e.ctrlKey || e.metaKey) && e.key === 'a') { - // Only when in file view (not favorites, trash etc.) const grid = document.getElementById('files-grid'); if (grid && grid.closest('.files-container')) { e.preventDefault(); this.selectAll(); } } - - if (e.key === 'Escape' && this.hasSelection) { - this.clear(); - } - - if (e.key === 'Delete' && this.hasSelection) { - this.batchDelete(); - } + if (e.key === 'Escape' && this.hasSelection) this.clear(); + if (e.key === 'Delete' && this.hasSelection) this.batchDelete(); }); }, - /** Inject a checkbox into the list-header (or wire existing one) */ _injectListHeaderCheckbox() { const cb = document.getElementById('select-all-checkbox'); if (!cb) return; - - cb.addEventListener('change', () => { - this.toggleAll(); - }); + cb.addEventListener('change', () => this.toggleAll()); }, - /** Override global click deselect to also clear our internal state */ _hookGlobalDeselect() { document.addEventListener('click', (e) => { - // Don't deselect if clicking on batch bar, context menu, modal, or any file item - if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return; - - if (this.hasSelection) { - this.clear(); - } + if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return; + if (this.hasSelection) this.clear(); }); } }; diff --git a/static/js/ui.js b/static/js/ui.js index c6c7c3e9..4178e10c 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -1250,8 +1250,18 @@ function initRubberBandSelection() { if (intersects) { card.classList.add('selected'); + // Sync with multiSelect module + if (window.multiSelect) { + const info = window.multiSelect._extractInfo(card); + if (info) window.multiSelect.select(info.id, info.name, info.type, info.parentId); + } } else { card.classList.remove('selected'); + // Deselect from multiSelect module + if (window.multiSelect) { + const info = window.multiSelect._extractInfo(card); + if (info) window.multiSelect.deselect(info.id); + } } }); }); @@ -1260,6 +1270,8 @@ function initRubberBandSelection() { if (!active) return; active = false; selRect.style.display = 'none'; + // Update the batch bar after rubber band selection completes + if (window.multiSelect) window.multiSelect._syncUI(); }); } diff --git a/static/locales/de.json b/static/locales/de.json index b6d242ca..c519b73a 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -339,5 +339,13 @@ "folder_deleted": "Ordner in Papierkorb verschoben", "item_deleted_permanently": "Element endgültig gelöscht", "trash_emptied": "Papierkorb erfolgreich geleert" + }, + "batch": { + "one_selected": "1 Element ausgewählt", + "n_selected": "{{count}} Elemente ausgewählt", + "confirm_delete": "Möchten Sie wirklich {{count}} Elemente in den Papierkorb verschieben?", + "move_title": "{{count}} Element(e) verschieben", + "add_favorites": "Zu Favoriten hinzufügen", + "move_copy": "Verschieben oder kopieren" } } diff --git a/static/locales/en.json b/static/locales/en.json index 80de65e0..d60f876b 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -341,5 +341,13 @@ "trash_emptied": "Trash emptied successfully", "title": "Notifications", "empty": "No notifications" + }, + "batch": { + "one_selected": "1 item selected", + "n_selected": "{{count}} items selected", + "confirm_delete": "Are you sure you want to move {{count}} items to trash?", + "move_title": "Move {{count}} item(s)", + "add_favorites": "Add to favorites", + "move_copy": "Move or copy" } } \ No newline at end of file diff --git a/static/locales/es.json b/static/locales/es.json index 59d0c4d4..6df334c3 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -341,5 +341,13 @@ "trash_emptied": "Papelera vaciada correctamente", "title": "Notificaciones", "empty": "Sin notificaciones" + }, + "batch": { + "one_selected": "1 elemento seleccionado", + "n_selected": "{{count}} elementos seleccionados", + "confirm_delete": "¿Estás seguro de que quieres mover {{count}} elementos a la papelera?", + "move_title": "Mover {{count}} elemento(s)", + "add_favorites": "Añadir a favoritos", + "move_copy": "Mover o copiar" } } \ No newline at end of file diff --git a/static/locales/fa.json b/static/locales/fa.json index b41b109b..451f6b1f 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -314,5 +314,13 @@ "accessed": "دسترسی یافته", "empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد", "empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند" + }, + "batch": { + "one_selected": "۱ مورد انتخاب شده", + "n_selected": "{{count}} مورد انتخاب شده", + "confirm_delete": "آیا مطمئنید که می‌خواهید {{count}} مورد را به سطل زباله منتقل کنید؟", + "move_title": "انتقال {{count}} مورد", + "add_favorites": "افزودن به موارد علاقه‌مند", + "move_copy": "انتقال یا کپی" } } \ No newline at end of file diff --git a/static/locales/fr.json b/static/locales/fr.json index f3e7a341..7427ced2 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -339,5 +339,13 @@ "folder_deleted": "Dossier déplacé vers la corbeille", "item_deleted_permanently": "Élément supprimé définitivement", "trash_emptied": "Corbeille vidée avec succès" + }, + "batch": { + "one_selected": "1 élément sélectionné", + "n_selected": "{{count}} éléments sélectionnés", + "confirm_delete": "Voulez-vous vraiment déplacer {{count}} éléments vers la corbeille ?", + "move_title": "Déplacer {{count}} élément(s)", + "add_favorites": "Ajouter aux favoris", + "move_copy": "Déplacer ou copier" } } diff --git a/static/locales/it.json b/static/locales/it.json index b5e7c2a4..550800b8 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -313,7 +313,7 @@ "fa": "Persiano", "fr": "Francese", "de": "Tedesco", - "pt": "Portoghese" + "pt": "Portoghese", "it": "Italiano" } }, @@ -340,5 +340,13 @@ "folder_deleted": "Cartella spostata nel cestino", "item_deleted_permanently": "Elemento eliminato definitivamente", "trash_emptied": "Cestino svuotato con successo" + }, + "batch": { + "one_selected": "1 elemento selezionato", + "n_selected": "{{count}} elementi selezionati", + "confirm_delete": "Sei sicuro di voler spostare {{count}} elementi nel cestino?", + "move_title": "Sposta {{count}} elemento/i", + "add_favorites": "Aggiungi ai preferiti", + "move_copy": "Sposta o copia" } } diff --git a/static/locales/pt.json b/static/locales/pt.json index ca67b7c3..d23fd252 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -339,5 +339,13 @@ "folder_deleted": "Pasta movida para a lixeira", "item_deleted_permanently": "Item excluído permanentemente", "trash_emptied": "Lixeira esvaziada com sucesso" + }, + "batch": { + "one_selected": "1 item selecionado", + "n_selected": "{{count}} itens selecionados", + "confirm_delete": "Tem certeza de que deseja mover {{count}} itens para a lixeira?", + "move_title": "Mover {{count}} item(ns)", + "add_favorites": "Adicionar aos favoritos", + "move_copy": "Mover ou copiar" } } diff --git a/static/locales/zh.json b/static/locales/zh.json index 3cef27a1..4e8c959e 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -276,5 +276,13 @@ "accessed": "访问于", "empty_state": "没有最近文件", "empty_hint": "您打开的文件将显示在这里" + }, + "batch": { + "one_selected": "已选择 1 个项目", + "n_selected": "已选择 {{count}} 个项目", + "confirm_delete": "确定要将 {{count}} 个项目移至回收站吗?", + "move_title": "移动 {{count}} 个项目", + "add_favorites": "添加到收藏夹", + "move_copy": "移动或复制" } }