fix(security): patch 3 vulnerabilities — IDOR, ownership bypass, XSS

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
This commit is contained in:
Dionisio
2026-02-16 00:22:42 +01:00
parent 66b4acd9d6
commit 5a679dfc90
25 changed files with 684 additions and 269 deletions
+14 -6
View File
@@ -38,15 +38,23 @@ pub trait FolderUseCase: Send + Sync + 'static {
pagination: &crate::application::dtos::pagination::PaginationRequestDto, pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>; ) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Renames a folder /// Lists folders with pagination, scoped to a specific owner.
async fn rename_folder(&self, id: &str, dto: RenameFolderDto) 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<FolderDto>, DomainError>;
/// Renames a folder (ownership verified against caller_id)
async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str)
-> Result<FolderDto, DomainError>; -> Result<FolderDto, DomainError>;
/// Moves a folder to another parent /// Moves a folder to another parent (ownership verified against caller_id)
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError>; async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError>;
/// Deletes a folder /// Deletes a folder (ownership verified against caller_id)
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>;
} }
/** /**
+6 -4
View File
@@ -408,6 +408,7 @@ impl BatchOperationService {
&self, &self,
folder_ids: Vec<String>, folder_ids: Vec<String>,
_recursive: bool, _recursive: bool,
caller_id: &str,
) -> Result<BatchResult<String>, BatchOperationError> { ) -> Result<BatchResult<String>, BatchOperationError> {
info!("Starting batch deletion of {} folders", folder_ids.len()); info!("Starting batch deletion of {} folders", folder_ids.len());
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
@@ -427,14 +428,13 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone(); let folder_service = self.folder_service.clone();
let semaphore = self.semaphore.clone(); let semaphore = self.semaphore.clone();
let id_clone = folder_id.clone(); let id_clone = folder_id.clone();
let caller = caller_id.to_string();
async move { async move {
// Acquire semaphore permit // Acquire semaphore permit
let permit = semaphore.acquire().await.unwrap(); let permit = semaphore.acquire().await.unwrap();
// For both recursive and non-recursive, use the standard delete_folder method let delete_result = folder_service.delete_folder(&folder_id, &caller).await;
// since FolderUseCase only has a single delete_folder method
let delete_result = folder_service.delete_folder(&folder_id).await;
// Release the permit explicitly // Release the permit explicitly
drop(permit); drop(permit);
@@ -616,6 +616,7 @@ impl BatchOperationService {
&self, &self,
folder_ids: Vec<String>, folder_ids: Vec<String>,
target_folder_id: Option<String>, target_folder_id: Option<String>,
caller_id: &str,
) -> Result<BatchResult<FolderDto>, BatchOperationError> { ) -> Result<BatchResult<FolderDto>, BatchOperationError> {
info!("Starting batch move of {} folders", folder_ids.len()); info!("Starting batch move of {} folders", folder_ids.len());
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
@@ -633,11 +634,12 @@ impl BatchOperationService {
let folder_service = self.folder_service.clone(); let folder_service = self.folder_service.clone();
let target = target_folder_id.clone(); let target = target_folder_id.clone();
let semaphore = self.semaphore.clone(); let semaphore = self.semaphore.clone();
let caller = caller_id.to_string();
async move { async move {
let permit = semaphore.acquire().await.unwrap(); let permit = semaphore.acquire().await.unwrap();
let dto = MoveFolderDto { parent_id: target }; 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); drop(permit);
(folder_id, move_result) (folder_id, move_result)
} }
+98 -16
View File
@@ -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<FolderDto>,
DomainError,
> {
Ok(
crate::application::dtos::pagination::PaginatedResponseDto::new(
vec![],
0,
10,
0,
),
)
}
async fn rename_folder( async fn rename_folder(
&self, &self,
_id: &str, _id: &str,
_dto: RenameFolderDto, _dto: RenameFolderDto,
_caller_id: &str,
) -> Result<FolderDto, DomainError> { ) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty()) Ok(FolderDto::empty())
} }
@@ -83,11 +103,12 @@ impl FolderService {
&self, &self,
_id: &str, _id: &str,
_dto: MoveFolderDto, _dto: MoveFolderDto,
_caller_id: &str,
) -> Result<FolderDto, DomainError> { ) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty()) 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(()) Ok(())
} }
} }
@@ -211,17 +232,15 @@ impl FolderUseCase for FolderService {
pagination: &crate::application::dtos::pagination::PaginationRequestDto, pagination: &crate::application::dtos::pagination::PaginationRequestDto,
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError> ) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
{ {
// Validate and adjust pagination
let pagination = pagination.validate_and_adjust(); let pagination = pagination.validate_and_adjust();
// Get paginated folders and total count
let (folders, total_items) = self let (folders, total_items) = self
.folder_storage .folder_storage
.list_folders_paginated( .list_folders_paginated(
parent_id, parent_id,
pagination.offset(), pagination.offset(),
pagination.limit(), pagination.limit(),
true, // Always include total for better UX true,
) )
.await .await
.map_err(|e| { .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()); let total = total_items.unwrap_or(folders.len());
// Convert to PaginatedResponseDto
let response = crate::application::dtos::pagination::PaginatedResponseDto::new( let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
folders.into_iter().map(FolderDto::from).collect(), folders.into_iter().map(FolderDto::from).collect(),
pagination.page, pagination.page,
@@ -248,11 +265,54 @@ impl FolderUseCase for FolderService {
Ok(response) 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<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, 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( async fn rename_folder(
&self, &self,
id: &str, id: &str,
dto: RenameFolderDto, dto: RenameFolderDto,
caller_id: &str,
) -> Result<FolderDto, DomainError> { ) -> Result<FolderDto, DomainError> {
// Input validation // Input validation
if dto.name.is_empty() { 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| { let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
DomainError::internal_error( DomainError::internal_error(
"FolderStorage", "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 // Create transaction for renaming
let mut transaction = StorageTransaction::new("rename_folder"); let mut transaction = StorageTransaction::new("rename_folder");
@@ -323,9 +391,9 @@ impl FolderUseCase for FolderService {
Ok(FolderDto::from(folder)) Ok(FolderDto::from(folder))
} }
/// Moves a folder to a new parent /// Moves a folder to a new parent after verifying ownership.
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError> { async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError> {
// Verify the source folder exists // Verify the source folder exists and belongs to the caller
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| { let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
DomainError::internal_error( DomainError::internal_error(
"FolderStorage", "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 a parent_id is specified, verify it exists
if let Some(parent_id) = &dto.parent_id { if let Some(parent_id) = &dto.parent_id {
// Verify we are not trying to move the folder into itself or one of its descendants // 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)) Ok(FolderDto::from(folder))
} }
/// Deletes a folder /// Deletes a folder after verifying ownership.
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError> {
// Verify the folder exists // Verify the folder exists and belongs to the caller
let _folder = self.folder_storage.get_folder(id).await.map_err(|e| { let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
DomainError::internal_error( DomainError::internal_error(
"FolderStorage", "FolderStorage",
format!("Failed to get folder with ID: {} for deletion: {}", id, e), 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 // Delete the folder
self.folder_storage.delete_folder(id).await.map_err(|e| { self.folder_storage.delete_folder(id).await.map_err(|e| {
+12
View File
@@ -544,6 +544,18 @@ mod tests {
unimplemented!() 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<crate::domain::entities::folder::Folder>, Option<usize>), DomainError>
{
unimplemented!()
}
async fn rename_folder( async fn rename_folder(
&self, &self,
_id: &str, _id: &str,
@@ -373,6 +373,17 @@ impl FolderRepository for MockFolderRepository {
Ok((vec![], Some(0))) 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<Folder>, Option<usize>), DomainError> {
Ok((vec![], Some(0)))
}
async fn rename_folder( async fn rename_folder(
&self, &self,
_id: &str, _id: &str,
+23 -2
View File
@@ -268,6 +268,17 @@ impl FolderRepository for StubFolderStoragePort {
Ok((Vec::new(), Some(0))) 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<Folder>, Option<usize>), DomainError> {
Ok((Vec::new(), Some(0)))
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, DomainError> { async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, DomainError> {
Ok(Folder::default()) Ok(Folder::default())
} }
@@ -374,19 +385,29 @@ impl FolderUseCase for StubFolderUseCase {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) 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<PaginatedResponseDto<FolderDto>, DomainError> {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
}
async fn rename_folder( async fn rename_folder(
&self, &self,
_id: &str, _id: &str,
_dto: RenameFolderDto, _dto: RenameFolderDto,
_caller_id: &str,
) -> Result<FolderDto, DomainError> { ) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default()) Ok(FolderDto::default())
} }
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result<FolderDto, DomainError> { async fn move_folder(&self, _id: &str, _dto: MoveFolderDto, _caller_id: &str) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default()) 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(()) Ok(())
} }
} }
@@ -54,6 +54,18 @@ pub trait FolderRepository: Send + Sync + 'static {
include_total: bool, include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>; ) -> Result<(Vec<Folder>, Option<usize>), 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<Folder>, Option<usize>), DomainError>;
/// Renames a folder /// Renames a folder
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>; async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
@@ -369,6 +369,82 @@ impl FolderRepository for FolderDbRepository {
Ok((folders, total)) 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<Folder>, Option<usize>), 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>, 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<Folder, DomainError> { async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
sqlx::query( sqlx::query(
r#" r#"
+4 -2
View File
@@ -258,6 +258,7 @@ pub async fn delete_files_batch(
/// Handler for deleting multiple folders in batch /// Handler for deleting multiple folders in batch
pub async fn delete_folders_batch( pub async fn delete_folders_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser,
Json(request): Json<BatchFolderOperationRequest>, Json(request): Json<BatchFolderOperationRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify there are folders to process // Verify there are folders to process
@@ -274,7 +275,7 @@ pub async fn delete_folders_batch(
// Execute batch operation // Execute batch operation
let result = state let result = state
.batch_service .batch_service
.delete_folders(request.folder_ids, request.recursive) .delete_folders(request.folder_ids, request.recursive, &auth_user.id)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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 /// Handler for moving multiple folders in batch
pub async fn move_folders_batch( pub async fn move_folders_batch(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser,
Json(request): Json<BatchFolderOperationRequest>, Json(request): Json<BatchFolderOperationRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
if request.folder_ids.is_empty() { if request.folder_ids.is_empty() {
@@ -569,7 +571,7 @@ pub async fn move_folders_batch(
let result = state let result = state
.batch_service .batch_service
.move_folders(request.folder_ids, request.target_folder_id) .move_folders(request.folder_ids, request.target_folder_id, &auth_user.id)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+32 -21
View File
@@ -13,7 +13,7 @@ use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::folder_service::FolderService; use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState; use crate::common::di::AppState as GlobalAppState;
use crate::common::errors::ErrorKind; use crate::common::errors::ErrorKind;
use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; use crate::interfaces::middleware::auth::AuthUser;
type AppState = Arc<FolderService>; type AppState = Arc<FolderService>;
@@ -136,15 +136,14 @@ impl FolderHandler {
} }
/// Lists contents of a specific folder with pagination. /// 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( pub async fn list_folder_contents_paginated(
State(service): State<AppState>, State(service): State<AppState>,
_auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
pagination: Query<PaginationRequestDto>, pagination: Query<PaginationRequestDto>,
) -> axum::response::Response { ) -> axum::response::Response {
// For sub-folder pagination, use the standard paginated path match service.list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination).await {
// (owner filtering is implicit — sub-folders inherit ownership)
match service.list_folders_paginated(Some(&id), &pagination).await {
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
Err(err) => { Err(err) => {
let status = match err.kind { 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( pub async fn rename_folder(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>, Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse { ) -> 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(), Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => { Err(err) => {
let status = match err.kind { 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( pub async fn move_folder(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>, Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse { ) -> 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(), Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => { Err(err) => {
let status = match err.kind { 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( pub async fn delete_folder(
State(service): State<AppState>, State(service): State<AppState>,
auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// For folder deletion without trash functionality match service.delete_folder(&id, &auth_user.id).await {
match service.delete_folder(&id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(), Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => { Err(err) => {
let status = match err.kind { 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( pub async fn delete_folder_with_trash(
State(state): State<GlobalAppState>, State(state): State<GlobalAppState>,
OptionalAuthUser(auth_user): OptionalAuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_id = auth_user let user_id = &auth_user.id;
.as_ref()
.map(|u| u.id.as_str())
.unwrap_or("anonymous");
// Check if trash service is available // Check if trash service is available
if let Some(trash_service) = &state.trash_service { if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id); tracing::info!("Moving folder to trash: {}", id);
@@ -282,7 +280,7 @@ impl FolderHandler {
// Fallback to permanent delete if trash is unavailable or failed // Fallback to permanent delete if trash is unavailable or failed
let folder_service = &state.applications.folder_service; let folder_service = &state.applications.folder_service;
match folder_service.delete_folder(&id).await { match folder_service.delete_folder(&id, user_id).await {
Ok(_) => { Ok(_) => {
tracing::info!("Folder permanently deleted: {}", id); tracing::info!("Folder permanently deleted: {}", id);
StatusCode::NO_CONTENT.into_response() 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( pub async fn download_folder_zip(
State(state): State<GlobalAppState>, State(state): State<GlobalAppState>,
auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
Query(_params): Query<HashMap<String, String>>, Query(_params): Query<HashMap<String, String>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
tracing::info!("Downloading folder as ZIP: {}", id); 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; let folder_service = &state.applications.folder_service;
match folder_service.get_folder(&id).await { match folder_service.get_folder(&id).await {
Ok(folder) => { 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); tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
// Use ZIP service from DI container // Use ZIP service from DI container
@@ -645,9 +645,10 @@ async fn handle_delete(
let folder_result = folder_service.get_folder_by_path(&path).await; let folder_result = folder_service.get_folder_by_path(&path).await;
if let Ok(folder) = folder_result { 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 folder_service
.delete_folder(&folder.id) .delete_folder(&folder.id, caller_id)
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
} else { } else {
@@ -761,7 +762,7 @@ async fn handle_move(
}; };
folder_service folder_service
.move_folder(&folder.id, move_dto) .move_folder(&folder.id, move_dto, folder.owner_id.as_deref().unwrap_or("webdav"))
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
@@ -771,7 +772,7 @@ async fn handle_move(
}; };
folder_service folder_service
.rename_folder(&folder.id, rename_dto) .rename_folder(&folder.id, rename_dto, folder.owner_id.as_deref().unwrap_or("webdav"))
.await .await
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
} }
+27 -2
View File
@@ -3919,7 +3919,27 @@ html[dir='rtl'] .fa-sign-out-alt {
/* Checked via JS */ /* 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 { .batch-action-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4720,11 +4740,16 @@ html[dir='rtl'] .fa-sign-out-alt {
color: #e2e8f0; color: #e2e8f0;
} }
/* Batch bar dark mode */ /* Batch bar dark mode */
[data-theme="dark"] .batch-bar { [data-theme="dark"] .batch-action-bar {
background-color: #1e293b; background-color: #1e293b;
border-color: #334155; border-color: #334155;
color: #e2e8f0; color: #e2e8f0;
} }
[data-theme="dark"] .list-header.selection-mode {
background-color: #0f172a;
border-bottom-color: #334155;
color: #e2e8f0;
}
/* Search results dark mode */ /* Search results dark mode */
[data-theme="dark"] .search-results-header { [data-theme="dark"] .search-results-header {
color: #f1f5f9; color: #f1f5f9;
+3 -2
View File
@@ -678,10 +678,11 @@ function setupEventListeners() {
} }
// Deselect all cards when clicking empty area (not on a card, menu, or modal) // 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-card.selected').forEach(c => c.classList.remove('selected'));
document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected')); document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected'));
if (window.multiSelect) window.multiSelect.clear();
} }
}); });
} }
+7 -1
View File
@@ -292,7 +292,13 @@ const sharedView = {
const nameCell = document.createElement('td'); const nameCell = document.createElement('td');
nameCell.className = 'shared-item-name'; nameCell.className = 'shared-item-name';
nameCell.innerHTML = `<span class="item-icon">${item.item_type === 'file' ? '📄' : '📁'}</span><span>${displayName}</span>`; 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'); const typeCell = document.createElement('td');
typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder'); typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder');
+38 -15
View File
@@ -543,21 +543,44 @@ const contextMenus = {
`Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` : `Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` :
'No expiration'; 'No expiration';
shareEl.innerHTML = ` // Share URL
<div class="share-url">${share.url}</div> const urlDiv = document.createElement('div');
<div class="share-info"> urlDiv.className = 'share-url';
${share.has_password ? '<span class="share-protected"><i class="fas fa-lock"></i> Password protected</span>' : ''} urlDiv.textContent = share.url;
<span class="share-expiration">${expiresText}</span> shareEl.appendChild(urlDiv);
</div>
<div class="share-actions"> // Share info
<button class="btn btn-small copy-link-btn" data-share-url="${share.url}"> const infoDiv = document.createElement('div');
<i class="fas fa-copy"></i> Copy infoDiv.className = 'share-info';
</button> if (share.has_password) {
<button class="btn btn-small btn-danger delete-link-btn" data-share-id="${share.id}"> const protectedSpan = document.createElement('span');
<i class="fas fa-trash"></i> Delete protectedSpan.className = 'share-protected';
</button> protectedSpan.innerHTML = '<i class="fas fa-lock"></i> Password protected';
</div> 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 = '<i class="fas fa-copy"></i> 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 = '<i class="fas fa-trash"></i> Delete';
actionsDiv.appendChild(deleteBtn);
shareEl.appendChild(actionsDiv);
existingSharesContainer.appendChild(shareEl); existingSharesContainer.appendChild(shareEl);
}); });
+239 -193
View File
@@ -1,89 +1,75 @@
/** /**
* OxiCloud - Multi-Select & Batch Actions Module * 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 = { const multiSelect = {
/** Currently selected items: { id, name, type: 'file'|'folder', parentId } */ /** Currently selected items: Map<id, { id, name, type, parentId }> */
_selected: new Map(), _selected: new Map(),
/** Last clicked index for Shift-range selection */ /** Last clicked index for Shift-range selection */
_lastClickedIndex: -1, _lastClickedIndex: -1,
/** Whether the batch bar is currently visible */ /** Whether the selection bar is currently visible */
_barVisible: false, _barVisible: false,
/** Saved original list-header HTML so we can restore it */
_savedHeaderHTML: '',
// ── Public API ────────────────────────────────────────── // ── Public API ──────────────────────────────────────────
/** Number of selected items */ get count() { return this._selected.size; },
get count() { return this._selected.size; }, get items() { return Array.from(this._selected.values()); },
/** All selected items as an array */
get items() { return Array.from(this._selected.values()); },
/** True when at least one item is selected */
get hasSelection() { return this._selected.size > 0; }, 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 */ // ── Helpers for i18n ────────────────────────────────────
get files() { return this.items.filter(i => i.type === 'file'); },
/** Get selected folders only */ _t(key, vars) {
get folders() { return this.items.filter(i => i.type === 'folder'); }, 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 ────────────────────────── // ── 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) { toggle(id, name, type, parentId) {
if (this._selected.has(id)) { if (this._selected.has(id)) { this._selected.delete(id); return false; }
this._selected.delete(id);
return false;
}
this._selected.set(id, { id, name, type, parentId }); this._selected.set(id, { id, name, type, parentId });
return true; return true;
}, },
/** Select a single item (add if not present) */
select(id, name, type, parentId) { select(id, name, type, parentId) {
this._selected.set(id, { 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() { clear() {
this._selected.clear(); this._selected.clear();
this._lastClickedIndex = -1; this._lastClickedIndex = -1;
// Remove visual state from DOM document.querySelectorAll('.file-card.selected, .file-item.selected')
document.querySelectorAll('.file-card.selected, .file-item.selected').forEach(el => { .forEach(el => el.classList.remove('selected'));
el.classList.remove('selected');
});
// Uncheck all item checkboxes
document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false); document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false);
this._syncUI(); this._syncUI();
}, },
/** Select all visible items */
selectAll() { selectAll() {
this._selectAllInContainer('files-grid', '.file-card'); this._selectAllInContainer('files-grid', '.file-card');
this._selectAllInContainer('files-list-view', '.file-item'); this._selectAllInContainer('files-list-view', '.file-item');
this._syncUI(); this._syncUI();
}, },
/** Deselect/select all toggle */
toggleAll() { toggleAll() {
const allItems = this._getAllVisibleItems(); const allItems = this._getAllVisibleItems();
if (this._selected.size === allItems.length && allItems.length > 0) { if (this._selected.size >= allItems.length && allItems.length > 0) {
this.clear(); this.clear();
} else { } else {
this.selectAll(); this.selectAll();
@@ -92,7 +78,6 @@ const multiSelect = {
// ── DOM helpers ───────────────────────────────────────── // ── DOM helpers ─────────────────────────────────────────
/** Gather info from a DOM element and add to selection */
_selectElement(el) { _selectElement(el) {
const info = this._extractInfo(el); const info = this._extractInfo(el);
if (info) { if (info) {
@@ -108,62 +93,41 @@ const multiSelect = {
}, },
_getAllVisibleItems() { _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'); const grid = document.getElementById('files-grid');
if (grid && grid.style.display !== 'none') return gridItems; if (grid && grid.style.display !== 'none') {
return listItems; return [...grid.querySelectorAll('.file-card')];
}
return [...document.querySelectorAll('#files-list-view .file-item')];
}, },
/** Extract item info from a DOM element */
_extractInfo(el) { _extractInfo(el) {
if (el.dataset.folderId && el.dataset.folderName !== undefined) { if (el.dataset.folderId && el.dataset.folderName !== undefined) {
return { return { id: el.dataset.folderId, name: el.dataset.folderName, type: 'folder', parentId: el.dataset.parentId || '' };
id: el.dataset.folderId,
name: el.dataset.folderName,
type: 'folder',
parentId: el.dataset.parentId || ''
};
} }
if (el.dataset.fileId) { if (el.dataset.fileId) {
return { return { id: el.dataset.fileId, name: el.dataset.fileName, type: 'file', parentId: el.dataset.folderId || '' };
id: el.dataset.fileId,
name: el.dataset.fileName,
type: 'file',
parentId: el.dataset.folderId || ''
};
} }
return null; return null;
}, },
// ── Click handler (shared by grid + list) ─────────────── // ── Click handler (shared by grid + list) ───────────────
/**
* Handle a checkbox/selection click on an item element.
* Supports Shift-click for range selection.
*/
handleItemClick(el, event) { handleItemClick(el, event) {
const items = this._getAllVisibleItems(); const items = this._getAllVisibleItems();
const index = items.indexOf(el); const index = items.indexOf(el);
// Also find the matching element in the other view
const info = this._extractInfo(el); const info = this._extractInfo(el);
if (!info) return; if (!info) return;
const selectorOther = info.type === 'folder' const selectorOther = info.type === 'folder'
? `[data-folder-id="${info.id}"]` ? `[data-folder-id="${info.id}"]`
: `[data-file-id="${info.id}"]`; : `[data-file-id="${info.id}"]`;
const otherEl = [...document.querySelectorAll(selectorOther)] const otherEl = [...document.querySelectorAll(selectorOther)].find(e => e !== el);
.find(e => e !== el);
if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) { if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) {
// Range selection
const start = Math.min(this._lastClickedIndex, index); const start = Math.min(this._lastClickedIndex, index);
const end = Math.max(this._lastClickedIndex, index); const end = Math.max(this._lastClickedIndex, index);
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
this._selectElement(items[i]); this._selectElement(items[i]);
// Mirror to other view
const iInfo = this._extractInfo(items[i]); const iInfo = this._extractInfo(items[i]);
if (iInfo) { if (iInfo) {
const sel = iInfo.type === 'folder' const sel = iInfo.type === 'folder'
@@ -173,96 +137,184 @@ const multiSelect = {
} }
} }
} else { } else {
// Normal toggle
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId); const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
el.classList.toggle('selected', nowSelected); el.classList.toggle('selected', nowSelected);
if (otherEl) otherEl.classList.toggle('selected', nowSelected); if (otherEl) otherEl.classList.toggle('selected', nowSelected);
} }
this._lastClickedIndex = index; this._lastClickedIndex = index;
this._syncUI(); this._syncUI();
}, },
// ── Batch action bar ──────────────────────────────────── // ── Selection bar (replaces list-header when items selected) ────
/** Create the batch action bar if it doesn't exist */ /**
_ensureBar() { * Build the inner HTML for the selection bar that replaces the
if (document.getElementById('batch-action-bar')) return; * 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'); const favLabel = this._t('batch.add_favorites') || 'Add to favorites';
bar.id = 'batch-action-bar'; const moveLabel = this._t('batch.move_copy') || 'Move or copy';
bar.className = 'batch-action-bar'; const dlLabel = this._t('actions.download') || 'Download';
bar.innerHTML = ` const delLabel = this._t('actions.delete') || 'Delete';
<div class="batch-bar-left">
<button class="batch-bar-close" id="batch-bar-close" title="Cancel selection"> return `
<i class="fas fa-times"></i> <div class="list-header-checkbox">
</button> <input type="checkbox" id="select-all-checkbox" title="Toggle all" checked>
<span class="batch-bar-count" id="batch-bar-count">0 selected</span>
</div> </div>
<div class="batch-bar-actions"> <div class="batch-selection-info">
<button class="batch-btn" id="batch-download" title="Download"> <span class="batch-bar-count">${countText}</span>
<i class="fas fa-download"></i> <div class="batch-bar-actions">
<span data-i18n="actions.download">Download</span> <button class="batch-btn" id="batch-fav" title="${favLabel}">
</button> <i class="fas fa-star"></i>
<button class="batch-btn" id="batch-move" title="Move"> <span>${favLabel}</span>
<i class="fas fa-arrows-alt"></i> </button>
<span data-i18n="actions.move">Move</span> <button class="batch-btn" id="batch-move" title="${moveLabel}">
</button> <i class="fas fa-arrows-alt"></i>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete"> <span>${moveLabel}</span>
<i class="fas fa-trash-alt"></i> </button>
<span data-i18n="actions.delete">Delete</span> <button class="batch-btn" id="batch-download" title="${dlLabel}">
</button> <i class="fas fa-download"></i>
<span>${dlLabel}</span>
</button>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="${delLabel}">
<i class="fas fa-trash-alt"></i>
<span>${delLabel}</span>
</button>
</div>
</div> </div>
`; `;
// 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() { _syncUI() {
this._ensureBar(); const listHeader = document.querySelector('.list-header');
const bar = document.getElementById('batch-action-bar'); const n = this._selected.size;
const count = document.getElementById('batch-bar-count');
if (this._selected.size > 0) { // ── Save original header HTML on first use ──
bar.classList.add('visible'); if (listHeader && !this._savedHeaderHTML) {
this._barVisible = true; this._savedHeaderHTML = listHeader.innerHTML;
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;
} }
// Update select-all checkbox state if (n > 0) {
this._syncSelectAllCheckbox(); 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 = `
<div class="batch-bar-left">
<button class="batch-bar-close" id="batch-grid-close" title="Cancel selection">
<i class="fas fa-times"></i>
</button>
<span class="batch-bar-count">${
n === 1
? (this._t('batch.one_selected') || '1 item selected')
: (this._t('batch.n_selected', { count: n }) || `${n} items selected`)
}</span>
</div>
<div class="batch-bar-actions">
<button class="batch-btn" id="batch-fav" title="Add to favorites">
<i class="fas fa-star"></i>
<span>${this._t('batch.add_favorites') || 'Add to favorites'}</span>
</button>
<button class="batch-btn" id="batch-move" title="Move or copy">
<i class="fas fa-arrows-alt"></i>
<span>${this._t('batch.move_copy') || 'Move or copy'}</span>
</button>
<button class="batch-btn" id="batch-download" title="Download">
<i class="fas fa-download"></i>
<span>${this._t('actions.download') || 'Download'}</span>
</button>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete">
<i class="fas fa-trash-alt"></i>
<span>${this._t('actions.delete') || 'Delete'}</span>
</button>
</div>
`;
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(); 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() { _syncItemCheckboxes() {
document.querySelectorAll('.file-item').forEach(el => { document.querySelectorAll('.file-item').forEach(el => {
const cb = el.querySelector('.item-checkbox'); const cb = el.querySelector('.item-checkbox');
if (cb) { if (cb) cb.checked = el.classList.contains('selected');
cb.checked = el.classList.contains('selected');
}
}); });
}, },
@@ -273,7 +325,7 @@ const multiSelect = {
if (all.length === 0) { if (all.length === 0) {
cb.checked = false; cb.checked = false;
cb.indeterminate = false; cb.indeterminate = false;
} else if (this._selected.size === all.length) { } else if (this._selected.size >= all.length) {
cb.checked = true; cb.checked = true;
cb.indeterminate = false; cb.indeterminate = false;
} else if (this._selected.size > 0) { } else if (this._selected.size > 0) {
@@ -294,21 +346,19 @@ const multiSelect = {
const n = items.length; const n = items.length;
const msg = n === 1 const msg = n === 1
? (window.i18n ? (this._t('dialogs.confirm_delete_file', { name: items[0].name })
? window.i18n.t('dialogs.confirm_delete_file', { name: items[0].name }) || `Are you sure you want to move "${items[0].name}" to trash?`)
: `Are you sure you want to move "${items[0].name}" to trash?`) : (this._t('batch.confirm_delete', { count: n })
: (window.i18n || `Are you sure you want to move ${n} items to trash?`);
? window.i18n.t('batch.confirm_delete', { count: n })
: `Are you sure you want to move ${n} items to trash?`);
const confirmed = await showConfirmDialog({ 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, message: msg,
confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Delete', confirmText: this._t('actions.delete') || 'Delete',
}); });
if (!confirmed) return; 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); const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
try { try {
@@ -317,21 +367,17 @@ const multiSelect = {
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
}); });
const data = await response.json(); const data = await response.json();
const success = data.stats?.successful || 0; const success = data.stats?.successful || 0;
const errors = data.stats?.failed || 0; const errors = data.stats?.failed || 0;
this.clear(); this.clear();
window.loadFiles(); window.loadFiles();
if (errors > 0) { 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 { } else {
window.ui.showNotification('Moved to trash', window.ui.showNotification('Moved to trash', `${success} item${success !== 1 ? 's' : ''} moved to trash`);
`${success} item${success !== 1 ? 's' : ''} moved to trash`);
} }
} catch (e) { } catch (e) {
console.error('Batch trash error:', e); console.error('Batch trash error:', e);
@@ -346,26 +392,19 @@ const multiSelect = {
const items = this.items; const items = this.items;
if (items.length === 0) return; if (items.length === 0) return;
// Set a special batch mode flag
window.app.moveDialogMode = 'batch'; window.app.moveDialogMode = 'batch';
window.app.batchMoveItems = items; window.app.batchMoveItems = items;
// Reset selection
window.app.selectedTargetFolderId = ""; window.app.selectedTargetFolderId = "";
// Update dialog title
const dialog = document.getElementById('move-file-dialog'); const dialog = document.getElementById('move-file-dialog');
const dialogHeader = dialog.querySelector('.rename-dialog-header'); const dialogHeader = dialog.querySelector('.rename-dialog-header');
const n = items.length; const n = items.length;
const titleText = window.i18n const titleText = this._t('batch.move_title', { count: n })
? window.i18n.t('batch.move_title', { count: n }) || `Move ${n} item${n !== 1 ? 's' : ''}`;
: `Move ${n} item${n !== 1 ? 's' : ''}`;
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`; dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
// Load folders, excluding selected folder IDs
const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id); const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch'); await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
dialog.style.display = 'flex'; dialog.style.display = 'flex';
}, },
@@ -377,7 +416,7 @@ const multiSelect = {
window.ui.showNotification('Preparing download', 'Creating ZIP archive...'); window.ui.showNotification('Preparing download', 'Creating ZIP archive...');
try { 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 folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
const response = await fetch('/api/batch/download', { const response = await fetch('/api/batch/download', {
@@ -386,12 +425,10 @@ const multiSelect = {
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Server returned ${response.status}`);
throw new Error(`Server returned ${response.status}`);
}
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `oxicloud-download-${Date.now()}.zip`; 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 ────────────────────────────────────── // ── Initialization ──────────────────────────────────────
init() { init() {
// Inject the select-all checkbox into the list header // Wire the initial select-all checkbox
this._injectListHeaderCheckbox(); this._injectListHeaderCheckbox();
// Override the deselect-on-empty-area handler to also clear our state // Global deselect on empty-area click
this._hookGlobalDeselect(); this._hookGlobalDeselect();
// Hook into the move dialog confirm to handle batch mode // Keyboard shortcuts
// (handled in contextMenus.js — moveDialogMode === 'batch')
// Keyboard shortcut: Ctrl+A to select all, Escape to clear
document.addEventListener('keydown', (e) => { 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.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
// Only when in file view (not favorites, trash etc.)
const grid = document.getElementById('files-grid'); const grid = document.getElementById('files-grid');
if (grid && grid.closest('.files-container')) { if (grid && grid.closest('.files-container')) {
e.preventDefault(); e.preventDefault();
this.selectAll(); this.selectAll();
} }
} }
if (e.key === 'Escape' && this.hasSelection) this.clear();
if (e.key === 'Escape' && this.hasSelection) { if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
this.clear();
}
if (e.key === 'Delete' && this.hasSelection) {
this.batchDelete();
}
}); });
}, },
/** Inject a checkbox into the list-header (or wire existing one) */
_injectListHeaderCheckbox() { _injectListHeaderCheckbox() {
const cb = document.getElementById('select-all-checkbox'); const cb = document.getElementById('select-all-checkbox');
if (!cb) return; if (!cb) return;
cb.addEventListener('change', () => this.toggleAll());
cb.addEventListener('change', () => {
this.toggleAll();
});
}, },
/** Override global click deselect to also clear our internal state */
_hookGlobalDeselect() { _hookGlobalDeselect() {
document.addEventListener('click', (e) => { 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, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
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 (this.hasSelection) {
this.clear();
}
}); });
} }
}; };
+12
View File
@@ -1250,8 +1250,18 @@ function initRubberBandSelection() {
if (intersects) { if (intersects) {
card.classList.add('selected'); 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 { } else {
card.classList.remove('selected'); 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; if (!active) return;
active = false; active = false;
selRect.style.display = 'none'; selRect.style.display = 'none';
// Update the batch bar after rubber band selection completes
if (window.multiSelect) window.multiSelect._syncUI();
}); });
} }
+8
View File
@@ -339,5 +339,13 @@
"folder_deleted": "Ordner in Papierkorb verschoben", "folder_deleted": "Ordner in Papierkorb verschoben",
"item_deleted_permanently": "Element endgültig gelöscht", "item_deleted_permanently": "Element endgültig gelöscht",
"trash_emptied": "Papierkorb erfolgreich geleert" "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"
} }
} }
+8
View File
@@ -341,5 +341,13 @@
"trash_emptied": "Trash emptied successfully", "trash_emptied": "Trash emptied successfully",
"title": "Notifications", "title": "Notifications",
"empty": "No 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"
} }
} }
+8
View File
@@ -341,5 +341,13 @@
"trash_emptied": "Papelera vaciada correctamente", "trash_emptied": "Papelera vaciada correctamente",
"title": "Notificaciones", "title": "Notificaciones",
"empty": "Sin 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"
} }
} }
+8
View File
@@ -314,5 +314,13 @@
"accessed": "دسترسی یافته", "accessed": "دسترسی یافته",
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد", "empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
"empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند" "empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند"
},
"batch": {
"one_selected": "۱ مورد انتخاب شده",
"n_selected": "{{count}} مورد انتخاب شده",
"confirm_delete": "آیا مطمئنید که می‌خواهید {{count}} مورد را به سطل زباله منتقل کنید؟",
"move_title": "انتقال {{count}} مورد",
"add_favorites": "افزودن به موارد علاقه‌مند",
"move_copy": "انتقال یا کپی"
} }
} }
+8
View File
@@ -339,5 +339,13 @@
"folder_deleted": "Dossier déplacé vers la corbeille", "folder_deleted": "Dossier déplacé vers la corbeille",
"item_deleted_permanently": "Élément supprimé définitivement", "item_deleted_permanently": "Élément supprimé définitivement",
"trash_emptied": "Corbeille vidée avec succès" "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"
} }
} }
+9 -1
View File
@@ -313,7 +313,7 @@
"fa": "Persiano", "fa": "Persiano",
"fr": "Francese", "fr": "Francese",
"de": "Tedesco", "de": "Tedesco",
"pt": "Portoghese" "pt": "Portoghese",
"it": "Italiano" "it": "Italiano"
} }
}, },
@@ -340,5 +340,13 @@
"folder_deleted": "Cartella spostata nel cestino", "folder_deleted": "Cartella spostata nel cestino",
"item_deleted_permanently": "Elemento eliminato definitivamente", "item_deleted_permanently": "Elemento eliminato definitivamente",
"trash_emptied": "Cestino svuotato con successo" "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"
} }
} }
+8
View File
@@ -339,5 +339,13 @@
"folder_deleted": "Pasta movida para a lixeira", "folder_deleted": "Pasta movida para a lixeira",
"item_deleted_permanently": "Item excluído permanentemente", "item_deleted_permanently": "Item excluído permanentemente",
"trash_emptied": "Lixeira esvaziada com sucesso" "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"
} }
} }
+8
View File
@@ -276,5 +276,13 @@
"accessed": "访问于", "accessed": "访问于",
"empty_state": "没有最近文件", "empty_state": "没有最近文件",
"empty_hint": "您打开的文件将显示在这里" "empty_hint": "您打开的文件将显示在这里"
},
"batch": {
"one_selected": "已选择 1 个项目",
"n_selected": "已选择 {{count}} 个项目",
"confirm_delete": "确定要将 {{count}} 个项目移至回收站吗?",
"move_title": "移动 {{count}} 个项目",
"add_favorites": "添加到收藏夹",
"move_copy": "移动或复制"
} }
} }