feat: folder ownership scoping, batch operations integration, frontend audit fixes
Backend: - Add owner_id to Folder entity + FolderDto (DB user_id column) - Add list_folders_by_owner to FolderRepository trait + PG impl - Add list_folders_for_owner to FolderUseCase + FolderService - Rewrite FolderHandler: all endpoints now scope by AuthUser - Remove dead handler methods (list_folders_inner, list_folders_for_user, is_user_home_folder, folder_belongs_to_user) - Add ownership check in get_folder (returns 404 on mismatch) Batch operations: - Add trash_service + zip_service to BatchOperationService - New methods: trash_files, trash_folders, move_folders, download_zip - New handlers: trash_batch, move_folders_batch, download_batch - New routes: POST /api/batch/trash, /api/batch/folders/move, /api/batch/download Frontend: - Replace findUserHomeFolder (~130 lines) with resolveHomeFolder (~35 lines) - Remove client-side folder filtering in loadFiles (backend now scopes) - Rewrite batchDelete: N requests -> 1 POST /api/batch/trash - Rewrite batchMove: N requests -> 2 POST max (files + folders) - Rewrite batchDownload: N requests -> 1 POST /api/batch/download (ZIP) - Search moved to backend, share system uses backend API - Dark mode fixes, frontend audit improvements
This commit is contained in:
@@ -5,9 +5,11 @@ use tokio::sync::Semaphore;
|
||||
use tracing::info;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
|
||||
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::ports::zip_ports::ZipPort;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -62,6 +64,8 @@ pub struct BatchOperationService {
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
zip_service: Option<Arc<dyn ZipPort>>,
|
||||
config: AppConfig,
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
@@ -81,6 +85,8 @@ impl BatchOperationService {
|
||||
file_retrieval,
|
||||
file_management,
|
||||
folder_service,
|
||||
trash_service: None,
|
||||
zip_service: None,
|
||||
config,
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrency)),
|
||||
}
|
||||
@@ -100,6 +106,18 @@ impl BatchOperationService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the optional trash service (enables batch trash operations)
|
||||
pub fn with_trash_service(mut self, trash_service: Arc<dyn TrashUseCase>) -> Self {
|
||||
self.trash_service = Some(trash_service);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the optional zip service (enables batch download)
|
||||
pub fn with_zip_service(mut self, zip_service: Arc<dyn ZipPort>) -> Self {
|
||||
self.zip_service = Some(zip_service);
|
||||
self
|
||||
}
|
||||
|
||||
/// Copies multiple files in parallel
|
||||
pub async fn copy_files(
|
||||
&self,
|
||||
@@ -459,6 +477,358 @@ impl BatchOperationService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves multiple files to trash in parallel (soft delete)
|
||||
pub async fn trash_files(
|
||||
&self,
|
||||
file_ids: Vec<String>,
|
||||
user_id: &str,
|
||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||
let trash_service = self
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| BatchOperationError::Internal("Trash service not available".into()))?;
|
||||
|
||||
info!("Starting batch trash of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: file_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let trash = trash_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let uid = user_id.to_string();
|
||||
let id_clone = file_id.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let trash_result = trash.move_to_trash(&file_id, "file", &uid).await;
|
||||
drop(permit);
|
||||
(id_clone.clone(), trash_result.map(|_| id_clone))
|
||||
}
|
||||
});
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (file_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((file_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch trash files completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves multiple folders to trash in parallel (soft delete)
|
||||
pub async fn trash_folders(
|
||||
&self,
|
||||
folder_ids: Vec<String>,
|
||||
user_id: &str,
|
||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||
let trash_service = self
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| BatchOperationError::Internal("Trash service not available".into()))?;
|
||||
|
||||
info!("Starting batch trash of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: folder_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let trash = trash_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let uid = user_id.to_string();
|
||||
let id_clone = folder_id.clone();
|
||||
|
||||
async move {
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
let trash_result = trash.move_to_trash(&folder_id, "folder", &uid).await;
|
||||
drop(permit);
|
||||
(id_clone.clone(), trash_result.map(|_| id_clone))
|
||||
}
|
||||
});
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
Ok(id) => {
|
||||
result.successful.push(id);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((folder_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch trash folders completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves multiple folders to a target parent in parallel
|
||||
pub async fn move_folders(
|
||||
&self,
|
||||
folder_ids: Vec<String>,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||
info!("Starting batch move of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
failed: Vec::new(),
|
||||
stats: BatchStats {
|
||||
total: folder_ids.len(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let target = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
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;
|
||||
drop(permit);
|
||||
(folder_id, move_result)
|
||||
}
|
||||
});
|
||||
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
Ok(folder) => {
|
||||
result.successful.push(folder);
|
||||
result.stats.successful += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
result.failed.push((folder_id, e.to_string()));
|
||||
result.stats.failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
info!(
|
||||
"Batch folder move completed: {}/{} successful in {}ms",
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Downloads multiple files/folders as a single ZIP archive
|
||||
pub async fn download_zip(
|
||||
&self,
|
||||
file_ids: Vec<String>,
|
||||
folder_ids: Vec<String>,
|
||||
) -> Result<Vec<u8>, BatchOperationError> {
|
||||
use std::io::{Cursor, Write};
|
||||
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
let zip_service = self.zip_service.as_ref();
|
||||
|
||||
info!(
|
||||
"Starting batch download: {} files, {} folders",
|
||||
file_ids.len(),
|
||||
folder_ids.len()
|
||||
);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut zip = ZipWriter::new(buf);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
|
||||
// Add individual files at the root of the ZIP
|
||||
for file_id in &file_ids {
|
||||
match self.file_retrieval.get_file(file_id).await {
|
||||
Ok(file_dto) => {
|
||||
match self.file_retrieval.get_file_content(file_id).await {
|
||||
Ok(content) => {
|
||||
if let Err(e) = zip.start_file(&file_dto.name, options) {
|
||||
info!("Could not start zip entry for {}: {}", file_dto.name, e);
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = zip.write_all(&content) {
|
||||
info!("Could not write zip entry for {}: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not read file content {}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not get file metadata {}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add folders as sub-trees using the existing ZipPort if available
|
||||
// Otherwise fall back to manual folder traversal
|
||||
if let Some(zip_svc) = zip_service {
|
||||
// For each folder, create a separate zip and merge its contents
|
||||
// Actually, we need to build the tree ourselves for a single zip
|
||||
// Use manual approach for consistency within one archive
|
||||
for folder_id in &folder_ids {
|
||||
match self.folder_service.get_folder(folder_id).await {
|
||||
Ok(folder) => {
|
||||
self.add_folder_to_zip(&mut zip, folder_id, &folder.name, &options)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not get folder {}: {}", folder_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress unused variable warning
|
||||
let _ = zip_svc;
|
||||
} else {
|
||||
for folder_id in &folder_ids {
|
||||
match self.folder_service.get_folder(folder_id).await {
|
||||
Ok(folder) => {
|
||||
self.add_folder_to_zip(&mut zip, folder_id, &folder.name, &options)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not get folder {}: {}", folder_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut zip_buf = zip
|
||||
.finish()
|
||||
.map_err(|e| BatchOperationError::Internal(format!("ZIP finalize error: {}", e)))?;
|
||||
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
zip_buf
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| BatchOperationError::Internal(format!("ZIP read error: {}", e)))?;
|
||||
|
||||
info!(
|
||||
"Batch download ZIP created: {} bytes in {}ms",
|
||||
bytes.len(),
|
||||
start_time.elapsed().as_millis()
|
||||
);
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Recursively add a folder and its contents to a ZipWriter
|
||||
async fn add_folder_to_zip(
|
||||
&self,
|
||||
zip: &mut zip::ZipWriter<std::io::Cursor<Vec<u8>>>,
|
||||
folder_id: &str,
|
||||
path: &str,
|
||||
options: &zip::write::SimpleFileOptions,
|
||||
) {
|
||||
use std::io::Write;
|
||||
|
||||
struct PendingFolder {
|
||||
id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
let mut queue = vec![PendingFolder {
|
||||
id: folder_id.to_string(),
|
||||
path: path.to_string(),
|
||||
}];
|
||||
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
|
||||
while let Some(current) = queue.pop() {
|
||||
if visited.contains(¤t.id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(current.id.clone());
|
||||
|
||||
let dir_path = format!("{}/", current.path);
|
||||
let _ = zip.add_directory(&dir_path, *options);
|
||||
|
||||
// Add files
|
||||
if let Ok(files) = self.file_retrieval.list_files(Some(¤t.id)).await {
|
||||
for file in files {
|
||||
let file_path = format!("{}{}", dir_path, file.name);
|
||||
if let Ok(content) = self.file_retrieval.get_file_content(&file.id).await {
|
||||
if zip.start_file(&file_path, *options).is_ok() {
|
||||
let _ = zip.write_all(&content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue subfolders
|
||||
if let Ok(subfolders) = self.folder_service.list_folders(Some(¤t.id)).await {
|
||||
for sub in subfolders {
|
||||
queue.push(PendingFolder {
|
||||
id: sub.id.clone(),
|
||||
path: format!("{}/{}", current.path, sub.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic batch operation for any type of async function
|
||||
pub async fn generic_batch_operation<T, F, Fut>(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user