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:
@@ -40,6 +40,10 @@ pub struct FolderDto {
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
/// Owner user ID (scopes visibility per user)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
|
||||
@@ -59,6 +63,7 @@ impl From<Folder> for FolderDto {
|
||||
name: folder.name().to_string(),
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
owner_id: folder.owner_id().map(String::from),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
is_root,
|
||||
@@ -90,6 +95,7 @@ impl FolderDto {
|
||||
name: "stub-folder".to_string(),
|
||||
path: "/stub/path".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: true,
|
||||
|
||||
@@ -56,6 +56,10 @@ pub struct SearchCriteriaDto {
|
||||
/// Offset for pagination
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
|
||||
/// Sort order for results: "relevance", "name", "name_desc", "date", "date_desc", "size", "size_desc"
|
||||
#[serde(default = "default_sort_by")]
|
||||
pub sort_by: String,
|
||||
}
|
||||
|
||||
/// Default value for recursive search (true)
|
||||
@@ -68,6 +72,11 @@ fn default_limit() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
/// Default sort_by value
|
||||
fn default_sort_by() -> String {
|
||||
"relevance".to_string()
|
||||
}
|
||||
|
||||
impl Default for SearchCriteriaDto {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -83,23 +92,75 @@ impl Default for SearchCriteriaDto {
|
||||
recursive: default_recursive(),
|
||||
limit: default_limit(),
|
||||
offset: 0,
|
||||
sort_by: default_sort_by(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file search result enriched with server-computed metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchFileResultDto {
|
||||
/// File ID
|
||||
pub id: String,
|
||||
/// File name
|
||||
pub name: String,
|
||||
/// Path to the file (relative)
|
||||
pub path: String,
|
||||
/// Size in bytes
|
||||
pub size: u64,
|
||||
/// MIME type
|
||||
pub mime_type: String,
|
||||
/// Parent folder ID
|
||||
pub folder_id: Option<String>,
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
/// Last modification timestamp
|
||||
pub modified_at: u64,
|
||||
/// Relevance score (0-100) computed server-side
|
||||
pub relevance_score: u32,
|
||||
/// Human-readable file size (e.g., "2.5 MB")
|
||||
pub size_formatted: String,
|
||||
/// CSS icon class for the file type (e.g., "fas fa-file-pdf")
|
||||
pub icon_class: String,
|
||||
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// A folder search result enriched with server-computed metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchFolderResultDto {
|
||||
/// Folder ID
|
||||
pub id: String,
|
||||
/// Folder name
|
||||
pub name: String,
|
||||
/// Path to the folder (relative)
|
||||
pub path: String,
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
/// Last modification timestamp
|
||||
pub modified_at: u64,
|
||||
/// Whether it is a root folder
|
||||
pub is_root: bool,
|
||||
/// Relevance score (0-100) computed server-side
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Transfer Object for search results.
|
||||
*
|
||||
* This structure encapsulates the results of a search operation, including
|
||||
* both files and folders that match the search criteria, along with pagination information.
|
||||
* both files and folders that match the search criteria, along with pagination
|
||||
* information and server-computed metadata.
|
||||
*/
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResultsDto {
|
||||
/// Files matching the search criteria
|
||||
pub files: Vec<crate::application::dtos::file_dto::FileDto>,
|
||||
/// Files matching the search criteria (enriched with metadata)
|
||||
pub files: Vec<SearchFileResultDto>,
|
||||
|
||||
/// Folders matching the search criteria
|
||||
pub folders: Vec<crate::application::dtos::folder_dto::FolderDto>,
|
||||
/// Folders matching the search criteria (enriched with metadata)
|
||||
pub folders: Vec<SearchFolderResultDto>,
|
||||
|
||||
/// Total count of matching items (for pagination)
|
||||
pub total_count: Option<usize>,
|
||||
@@ -112,6 +173,12 @@ pub struct SearchResultsDto {
|
||||
|
||||
/// Whether there are more results available
|
||||
pub has_more: bool,
|
||||
|
||||
/// Query execution time in milliseconds (server-side)
|
||||
pub query_time_ms: u64,
|
||||
|
||||
/// Sort order used
|
||||
pub sort_by: String,
|
||||
}
|
||||
|
||||
impl SearchResultsDto {
|
||||
@@ -124,16 +191,20 @@ impl SearchResultsDto {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
query_time_ms: 0,
|
||||
sort_by: "relevance".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new search results object from files and folders
|
||||
pub fn new(
|
||||
files: Vec<crate::application::dtos::file_dto::FileDto>,
|
||||
folders: Vec<crate::application::dtos::folder_dto::FolderDto>,
|
||||
files: Vec<SearchFileResultDto>,
|
||||
folders: Vec<SearchFolderResultDto>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
total_count: Option<usize>,
|
||||
query_time_ms: u64,
|
||||
sort_by: String,
|
||||
) -> Self {
|
||||
let has_more = match total_count {
|
||||
Some(total) => (offset + files.len() + folders.len()) < total,
|
||||
@@ -147,6 +218,34 @@ impl SearchResultsDto {
|
||||
limit,
|
||||
offset,
|
||||
has_more,
|
||||
query_time_ms,
|
||||
sort_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO for search suggestion results (quick prefix search)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchSuggestionsDto {
|
||||
/// Suggested file/folder names matching the query prefix
|
||||
pub suggestions: Vec<SearchSuggestionItem>,
|
||||
/// Query execution time in milliseconds
|
||||
pub query_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Individual search suggestion item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchSuggestionItem {
|
||||
/// The suggested name
|
||||
pub name: String,
|
||||
/// Type: "file" or "folder"
|
||||
pub item_type: String,
|
||||
/// Item ID for navigation
|
||||
pub id: String,
|
||||
/// Path for context
|
||||
pub path: String,
|
||||
/// CSS icon class
|
||||
pub icon_class: String,
|
||||
/// Relevance score
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::domain::entities::share::{Share, SharePermissions};
|
||||
pub struct ShareDto {
|
||||
pub id: String,
|
||||
pub item_id: String,
|
||||
pub item_name: Option<String>,
|
||||
pub item_type: String,
|
||||
pub token: String,
|
||||
pub url: String,
|
||||
@@ -27,6 +28,7 @@ pub struct SharePermissionsDto {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateShareDto {
|
||||
pub item_id: String,
|
||||
pub item_name: Option<String>,
|
||||
pub item_type: String,
|
||||
pub password: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
@@ -48,6 +50,7 @@ impl ShareDto {
|
||||
Self {
|
||||
id: share.id().to_string(),
|
||||
item_id: share.item_id().to_string(),
|
||||
item_name: share.item_name().map(|s| s.to_string()),
|
||||
item_type: share.item_type().to_string(),
|
||||
token: share.token().to_string(),
|
||||
url,
|
||||
|
||||
@@ -3,7 +3,9 @@ use async_trait::async_trait;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Primary port for folder operations
|
||||
@@ -21,6 +23,14 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
|
||||
|
||||
/// Lists folders scoped to a specific owner (for user-facing endpoints).
|
||||
/// At root level, only returns folders belonging to this user.
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError>;
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
@@ -40,25 +50,24 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary port for file and folder search
|
||||
* Primary port for file and folder search.
|
||||
*
|
||||
* Defines the operations related to advanced search of
|
||||
* files and folders based on various criteria.
|
||||
* All search processing (filtering, scoring, sorting, categorization)
|
||||
* is handled server-side in Rust for maximum efficiency.
|
||||
*/
|
||||
#[async_trait]
|
||||
pub trait SearchUseCase: Send + Sync + 'static {
|
||||
/**
|
||||
* Performs a search based on the specified criteria
|
||||
*
|
||||
* @param criteria Search criteria including text, dates, sizes, etc.
|
||||
* @return Search results containing matching files and folders
|
||||
*/
|
||||
/// Performs a full search based on the specified criteria.
|
||||
async fn search(&self, criteria: SearchCriteriaDto) -> Result<SearchResultsDto, DomainError>;
|
||||
|
||||
/**
|
||||
* Clears the search results cache
|
||||
*
|
||||
* @return Result indicating success or error
|
||||
*/
|
||||
/// Returns quick suggestions for autocomplete (lightweight, fast).
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SearchSuggestionsDto, DomainError>;
|
||||
|
||||
/// Clears the search results cache.
|
||||
async fn clear_search_cache(&self) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,14 @@ impl FolderService {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
@@ -173,6 +181,29 @@ impl FolderUseCase for FolderService {
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
|
||||
/// Lists folders scoped to a specific owner.
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner(parent_id, owner_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||
owner_id, parent_id, e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
|
||||
@@ -7,19 +7,30 @@ use tokio::time;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
||||
SearchSuggestionItem, SearchSuggestionsDto,
|
||||
};
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::Result;
|
||||
|
||||
/**
|
||||
* Search service implementation for files and folders.
|
||||
* High-performance search service implementation for files and folders.
|
||||
*
|
||||
* This service implements the advanced search functionality that allows
|
||||
* users to find files and folders based on various criteria
|
||||
* such as name, type, date and size. It also includes a cache to improve
|
||||
* the performance of repeated searches.
|
||||
* All search processing (filtering, scoring, sorting, categorization,
|
||||
* formatting) is performed server-side in Rust for maximum efficiency.
|
||||
* The frontend acts as a thin rendering client only.
|
||||
*
|
||||
* Features:
|
||||
* - Parallel recursive folder traversal using tokio tasks
|
||||
* - Relevance scoring (exact match > starts-with > contains)
|
||||
* - Content categorization and icon mapping
|
||||
* - Multiple sort options (relevance, name, date, size)
|
||||
* - Server-side formatted file sizes
|
||||
* - Quick suggestions endpoint for autocomplete
|
||||
* - TTL-based result caching
|
||||
*/
|
||||
pub struct SearchService {
|
||||
/// Repository for file operations
|
||||
@@ -57,14 +68,149 @@ struct CachedSearchResult {
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
// ─── Utility functions (pure, no self — computed on the server) ─────────
|
||||
|
||||
/// Compute relevance score (0–100) for a name against a query.
|
||||
/// Exact match = 100, starts-with = 80, contains = 50, no match = 0.
|
||||
fn compute_relevance(name: &str, query: &str) -> u32 {
|
||||
let name_lower = name.to_lowercase();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
if name_lower == query_lower {
|
||||
100
|
||||
} else if name_lower.starts_with(&query_lower) {
|
||||
80
|
||||
} else if name_lower.contains(&query_lower) {
|
||||
// Bonus for shorter names (more specific match)
|
||||
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string (e.g. "2.5 MB").
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
if bytes == 0 {
|
||||
return "0 B".to_string();
|
||||
}
|
||||
let exp = (bytes as f64).log(1024.0).floor() as usize;
|
||||
let exp = exp.min(UNITS.len() - 1);
|
||||
let value = bytes as f64 / 1024_f64.powi(exp as i32);
|
||||
if exp == 0 {
|
||||
format!("{} B", bytes)
|
||||
} else {
|
||||
format!("{:.1} {}", value, UNITS[exp])
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine content category from MIME type.
|
||||
fn categorize_mime(mime: &str) -> &'static str {
|
||||
let m = mime.to_lowercase();
|
||||
if m.starts_with("image/") {
|
||||
"image"
|
||||
} else if m.starts_with("video/") {
|
||||
"video"
|
||||
} else if m.starts_with("audio/") {
|
||||
"audio"
|
||||
} else if m.starts_with("text/")
|
||||
|| m.contains("pdf")
|
||||
|| m.contains("document")
|
||||
|| m.contains("spreadsheet")
|
||||
|| m.contains("presentation")
|
||||
|| m.contains("msword")
|
||||
|| m.contains("officedocument")
|
||||
{
|
||||
"document"
|
||||
} else if m.contains("zip")
|
||||
|| m.contains("tar")
|
||||
|| m.contains("gzip")
|
||||
|| m.contains("bzip")
|
||||
|| m.contains("7z")
|
||||
|| m.contains("rar")
|
||||
|| m.contains("compress")
|
||||
{
|
||||
"archive"
|
||||
} else if m.contains("json")
|
||||
|| m.contains("xml")
|
||||
|| m.contains("javascript")
|
||||
|| m.contains("typescript")
|
||||
|| m.contains("x-python")
|
||||
|| m.contains("x-rust")
|
||||
|| m.contains("x-c")
|
||||
|| m.contains("x-java")
|
||||
|| m.contains("x-shellscript")
|
||||
|| m.contains("x-httpd-php")
|
||||
|| m.contains("yaml")
|
||||
|| m.contains("toml")
|
||||
{
|
||||
"code"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
||||
fn get_icon_class(name: &str, mime: &str) -> String {
|
||||
// Try extension first
|
||||
if let Some(ext) = name.rsplit('.').next() {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
let icon = match ext_lower.as_str() {
|
||||
// Documents
|
||||
"pdf" => "fas fa-file-pdf",
|
||||
"doc" | "docx" => "fas fa-file-word",
|
||||
"xls" | "xlsx" => "fas fa-file-excel",
|
||||
"ppt" | "pptx" => "fas fa-file-powerpoint",
|
||||
"txt" | "rtf" | "md" => "fas fa-file-alt",
|
||||
"csv" => "fas fa-file-csv",
|
||||
// Images
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" => {
|
||||
"fas fa-file-image"
|
||||
}
|
||||
// Video
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => {
|
||||
"fas fa-file-video"
|
||||
}
|
||||
// Audio
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" => "fas fa-file-audio",
|
||||
// Archives
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "fas fa-file-archive",
|
||||
// Code
|
||||
"js" | "ts" | "jsx" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs"
|
||||
| "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "sh" | "bash"
|
||||
| "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-file-code",
|
||||
"html" | "htm" | "css" | "scss" | "sass" | "less" => "fas fa-file-code",
|
||||
"json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => {
|
||||
"fas fa-file-code"
|
||||
}
|
||||
"sql" => "fas fa-database",
|
||||
_ => "",
|
||||
};
|
||||
if !icon.is_empty() {
|
||||
return icon.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to MIME type
|
||||
let category = categorize_mime(mime);
|
||||
match category {
|
||||
"image" => "fas fa-file-image",
|
||||
"video" => "fas fa-file-video",
|
||||
"audio" => "fas fa-file-audio",
|
||||
"document" => "fas fa-file-alt",
|
||||
"archive" => "fas fa-file-archive",
|
||||
"code" => "fas fa-file-code",
|
||||
_ => "fas fa-file",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ─── SearchService implementation ───────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
/**
|
||||
* Creates a new instance of the search service.
|
||||
*
|
||||
* @param file_repository Repository for file operations
|
||||
* @param folder_repository Repository for folder operations
|
||||
* @param cache_ttl Cache time-to-live in seconds (0 to disable)
|
||||
* @param max_cache_size Maximum cache size
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
@@ -88,12 +234,7 @@ impl SearchService {
|
||||
search_service
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an asynchronous task to clean up expired cache entries.
|
||||
*
|
||||
* @param cache_ref Reference to the shared cache
|
||||
* @param ttl_seconds TTL in seconds
|
||||
*/
|
||||
/// Starts an asynchronous task to clean up expired cache entries.
|
||||
fn start_cache_cleanup_task(
|
||||
cache_ref: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
||||
ttl_seconds: u64,
|
||||
@@ -105,18 +246,13 @@ impl SearchService {
|
||||
loop {
|
||||
time::sleep(cleanup_interval).await;
|
||||
|
||||
// Acquire lock and clean up expired entries
|
||||
if let Ok(mut cache) = cache_ref.lock() {
|
||||
let now = Instant::now();
|
||||
|
||||
// Identify expired entries
|
||||
let expired_keys: Vec<SearchCacheKey> = cache
|
||||
.iter()
|
||||
.filter(|(_, result)| now.duration_since(result.timestamp) > ttl)
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
|
||||
// Remove expired entries
|
||||
for key in expired_keys {
|
||||
cache.remove(&key);
|
||||
}
|
||||
@@ -125,31 +261,17 @@ impl SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache key from the search criteria.
|
||||
*
|
||||
* @param criteria Search criteria
|
||||
* @param user_id User ID (to isolate cache between users)
|
||||
* @return Cache key
|
||||
*/
|
||||
/// Creates a cache key from the search criteria.
|
||||
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey {
|
||||
// Serialize criteria to generate a hash
|
||||
let criteria_str = serde_json::to_string(criteria).unwrap_or_default();
|
||||
|
||||
SearchCacheKey {
|
||||
criteria_hash: criteria_str,
|
||||
user_id: user_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve results from the cache.
|
||||
*
|
||||
* @param key Cache key
|
||||
* @return Optionally, the results if they exist and have not expired
|
||||
*/
|
||||
/// Attempts to retrieve results from the cache.
|
||||
fn get_from_cache(&self, key: &SearchCacheKey) -> Option<SearchResultsDto> {
|
||||
// If TTL is 0, the cache is disabled
|
||||
if self.cache_ttl == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -159,8 +281,6 @@ impl SearchService {
|
||||
{
|
||||
let now = Instant::now();
|
||||
let ttl = Duration::from_secs(self.cache_ttl);
|
||||
|
||||
// Check if the entry has expired
|
||||
if now.duration_since(cached_result.timestamp) < ttl {
|
||||
return Some(cached_result.results.clone());
|
||||
}
|
||||
@@ -169,20 +289,13 @@ impl SearchService {
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores results in the cache.
|
||||
*
|
||||
* @param key Cache key
|
||||
* @param results Results to store
|
||||
*/
|
||||
/// Stores results in the cache.
|
||||
fn store_in_cache(&self, key: SearchCacheKey, results: SearchResultsDto) {
|
||||
// If TTL is 0, the cache is disabled
|
||||
if self.cache_ttl == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut cache) = self.search_cache.lock() {
|
||||
// If the cache is full, remove the oldest entry
|
||||
if cache.len() >= self.max_cache_size
|
||||
&& let Some((oldest_key, _)) =
|
||||
cache.iter().min_by_key(|(_, result)| result.timestamp)
|
||||
@@ -191,7 +304,6 @@ impl SearchService {
|
||||
cache.remove(&key_to_remove);
|
||||
}
|
||||
|
||||
// Store the new result
|
||||
cache.insert(
|
||||
key,
|
||||
CachedSearchResult {
|
||||
@@ -202,268 +314,407 @@ impl SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters files according to the search criteria.
|
||||
*
|
||||
* @param files List of files to filter
|
||||
* @param criteria Search criteria
|
||||
* @return Files that match the criteria
|
||||
*/
|
||||
fn filter_files(&self, files: Vec<FileDto>, criteria: &SearchCriteriaDto) -> Vec<FileDto> {
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
// Filter by name
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !file
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
|
||||
fn enrich_file(file: &FileDto, query: &str) -> SearchFileResultDto {
|
||||
let relevance = if query.is_empty() {
|
||||
50
|
||||
} else {
|
||||
compute_relevance(&file.name, query)
|
||||
};
|
||||
|
||||
// Filter by file type (extension)
|
||||
if let Some(file_types) = &criteria.file_types {
|
||||
if let Some(extension) = file.name.split('.').next_back() {
|
||||
if !file_types
|
||||
.iter()
|
||||
.any(|ext| ext.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Has no extension
|
||||
return false;
|
||||
}
|
||||
}
|
||||
SearchFileResultDto {
|
||||
id: file.id.clone(),
|
||||
name: file.name.clone(),
|
||||
path: file.path.clone(),
|
||||
size: file.size,
|
||||
mime_type: file.mime_type.clone(),
|
||||
folder_id: file.folder_id.clone(),
|
||||
created_at: file.created_at,
|
||||
modified_at: file.modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: format_bytes(file.size),
|
||||
icon_class: get_icon_class(&file.name, &file.mime_type),
|
||||
category: categorize_mime(&file.mime_type).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by creation date
|
||||
if let Some(created_after) = criteria.created_after
|
||||
&& file.created_at < created_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
||||
fn enrich_folder(folder: &FolderDto, query: &str) -> SearchFolderResultDto {
|
||||
let relevance = if query.is_empty() {
|
||||
50
|
||||
} else {
|
||||
compute_relevance(&folder.name, query)
|
||||
};
|
||||
|
||||
if let Some(created_before) = criteria.created_before
|
||||
&& file.created_at > created_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by modification date
|
||||
if let Some(modified_after) = criteria.modified_after
|
||||
&& file.modified_at < modified_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(modified_before) = criteria.modified_before
|
||||
&& file.modified_at > modified_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by size
|
||||
if let Some(min_size) = criteria.min_size
|
||||
&& file.size < min_size
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(max_size) = criteria.max_size
|
||||
&& file.size > max_size
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
SearchFolderResultDto {
|
||||
id: folder.id.clone(),
|
||||
name: folder.name.clone(),
|
||||
path: folder.path.clone(),
|
||||
parent_id: folder.parent_id.clone(),
|
||||
created_at: folder.created_at,
|
||||
modified_at: folder.modified_at,
|
||||
is_root: folder.is_root,
|
||||
relevance_score: relevance,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters folders according to the search criteria.
|
||||
* Parallel recursive search through folders using tokio tasks.
|
||||
*
|
||||
* @param folders List of folders to filter
|
||||
* @param criteria Search criteria
|
||||
* @return Folders that match the criteria
|
||||
* Instead of searching subfolders sequentially, we spawn a task
|
||||
* per subfolder and join them all concurrently.
|
||||
*/
|
||||
fn filter_folders(
|
||||
&self,
|
||||
folders: Vec<FolderDto>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
) -> Vec<FolderDto> {
|
||||
folders
|
||||
.into_iter()
|
||||
.filter(|folder| {
|
||||
// Filter by name
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !folder
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by creation date
|
||||
if let Some(created_after) = criteria.created_after
|
||||
&& folder.created_at < created_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(created_before) = criteria.created_before
|
||||
&& folder.created_at > created_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by modification date
|
||||
if let Some(modified_after) = criteria.modified_after
|
||||
&& folder.modified_at < modified_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(modified_before) = criteria.modified_before
|
||||
&& folder.modified_at > modified_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of recursive search through folders.
|
||||
*
|
||||
* @param current_folder_id ID of the current folder
|
||||
* @param criteria Search criteria
|
||||
* @param found_files Files found so far
|
||||
* @param found_folders Folders found so far
|
||||
*/
|
||||
async fn search_recursive(
|
||||
&self,
|
||||
current_folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
found_files: &mut Vec<FileDto>,
|
||||
found_folders: &mut Vec<FolderDto>,
|
||||
) -> Result<()> {
|
||||
fn search_parallel(
|
||||
file_repo: Arc<dyn FileReadPort>,
|
||||
folder_repo: Arc<dyn FolderStoragePort>,
|
||||
current_folder_id: Option<String>,
|
||||
criteria: Arc<SearchCriteriaDto>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + Send>> {
|
||||
Box::pin(async move {
|
||||
// List files in the current folder
|
||||
let files = self.file_repository.list_files(current_folder_id).await?;
|
||||
// List files in the current folder
|
||||
let files = file_repo
|
||||
.list_files(current_folder_id.as_deref())
|
||||
.await?;
|
||||
|
||||
// Filter files according to criteria and add them to the results
|
||||
let filtered_files =
|
||||
self.filter_files(files.into_iter().map(FileDto::from).collect(), criteria);
|
||||
found_files.extend(filtered_files);
|
||||
let filtered_files: Vec<FileDto> = files
|
||||
.into_iter()
|
||||
.map(FileDto::from)
|
||||
.filter(|file| passes_file_filter(file, &criteria))
|
||||
.collect();
|
||||
|
||||
// If the search is recursive, process subfolders
|
||||
if criteria.recursive {
|
||||
// List subfolders
|
||||
let folders = self
|
||||
.folder_repository
|
||||
.list_folders(current_folder_id)
|
||||
.await?;
|
||||
let mut all_files = filtered_files;
|
||||
let mut all_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
// Filter folders according to criteria and add them to the results
|
||||
let filtered_folders: Vec<FolderDto> = self
|
||||
.filter_folders(folders.into_iter().map(FolderDto::from).collect(), criteria);
|
||||
// If recursive, process subfolders in parallel
|
||||
if criteria.recursive {
|
||||
let folders = folder_repo
|
||||
.list_folders(current_folder_id.as_deref())
|
||||
.await?;
|
||||
|
||||
// Add filtered folders to the results
|
||||
found_folders.extend(filtered_folders.iter().cloned());
|
||||
let folder_dtos: Vec<FolderDto> = folders
|
||||
.into_iter()
|
||||
.map(FolderDto::from)
|
||||
.filter(|f| passes_folder_filter(f, &criteria))
|
||||
.collect();
|
||||
|
||||
// Search recursively in each subfolder
|
||||
for folder in filtered_folders {
|
||||
self.search_recursive(Some(&folder.id), criteria, found_files, found_folders)
|
||||
.await?;
|
||||
}
|
||||
all_folders.extend(folder_dtos.iter().cloned());
|
||||
|
||||
// Spawn parallel tasks for each subfolder
|
||||
let mut handles = Vec::with_capacity(folder_dtos.len());
|
||||
for subfolder in &folder_dtos {
|
||||
let fr = file_repo.clone();
|
||||
let fdr = folder_repo.clone();
|
||||
let crit = criteria.clone();
|
||||
let folder_id = subfolder.id.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
Self::search_parallel(fr, fdr, Some(folder_id), crit).await
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Collect results from all parallel tasks
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok((sub_files, sub_folders))) => {
|
||||
all_files.extend(sub_files);
|
||||
all_folders.extend(sub_folders);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Parallel search subtask error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Parallel search task join error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((all_files, all_folders))
|
||||
}) // end Box::pin
|
||||
}
|
||||
|
||||
/// Quick suggestions search — returns up to `limit` name suggestions
|
||||
/// matching the query prefix. Uses cache-friendly shallow search.
|
||||
pub async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
let start = Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut suggestions: Vec<SearchSuggestionItem> = Vec::new();
|
||||
|
||||
// List files in the folder
|
||||
let files = self.file_repository.list_files(folder_id).await?;
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
if file_dto.name.to_lowercase().contains(&query_lower) {
|
||||
let score = compute_relevance(&file_dto.name, query);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: file_dto.name.clone(),
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id.clone(),
|
||||
path: file_dto.path.clone(),
|
||||
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
if suggestions.len() >= limit * 2 {
|
||||
break; // Collect enough candidates
|
||||
}
|
||||
}
|
||||
|
||||
// List folders
|
||||
let folders = self.folder_repository.list_folders(folder_id).await?;
|
||||
for folder in folders {
|
||||
let folder_dto = FolderDto::from(folder);
|
||||
if folder_dto.name.to_lowercase().contains(&query_lower) {
|
||||
let score = compute_relevance(&folder_dto.name, query);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: folder_dto.name.clone(),
|
||||
item_type: "folder".to_string(),
|
||||
id: folder_dto.id.clone(),
|
||||
path: folder_dto.path.clone(),
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance and truncate
|
||||
suggestions.sort_by(|a, b| b.relevance_score.cmp(&a.relevance_score));
|
||||
suggestions.truncate(limit);
|
||||
|
||||
let elapsed = start.elapsed().as_millis() as u64;
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions,
|
||||
query_time_ms: elapsed,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Standalone filter functions for use in parallel tasks ──────────────
|
||||
|
||||
/// Check if a file passes all filter criteria (standalone, no &self needed).
|
||||
fn passes_file_filter(file: &FileDto, criteria: &SearchCriteriaDto) -> bool {
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !file
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(file_types) = &criteria.file_types {
|
||||
if let Some(extension) = file.name.split('.').next_back() {
|
||||
if !file_types
|
||||
.iter()
|
||||
.any(|ext| ext.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.created_after {
|
||||
if file.created_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.created_before {
|
||||
if file.created_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_after {
|
||||
if file.modified_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_before {
|
||||
if file.modified_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.min_size {
|
||||
if file.size < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.max_size {
|
||||
if file.size > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if a folder passes all filter criteria (standalone).
|
||||
fn passes_folder_filter(folder: &FolderDto, criteria: &SearchCriteriaDto) -> bool {
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !folder
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(v) = criteria.created_after {
|
||||
if folder.created_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.created_before {
|
||||
if folder.created_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_after {
|
||||
if folder.modified_at < v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(v) = criteria.modified_before {
|
||||
if folder.modified_at > v {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl SearchUseCase for SearchService {
|
||||
/**
|
||||
* Performs a search based on the specified criteria.
|
||||
*
|
||||
* @param criteria Search criteria
|
||||
* @return Search results
|
||||
* All processing happens server-side:
|
||||
* - Parallel recursive traversal
|
||||
* - Filtering by name, type, dates, size
|
||||
* - Relevance scoring
|
||||
* - Sorting (relevance, name, date, size)
|
||||
* - Content categorization & icon mapping
|
||||
* - Human-readable size formatting
|
||||
* - Pagination
|
||||
*/
|
||||
async fn search(&self, criteria: SearchCriteriaDto) -> Result<SearchResultsDto> {
|
||||
let start = Instant::now();
|
||||
|
||||
// TODO: Get user ID from the authentication context
|
||||
let user_id = "default-user";
|
||||
let cache_key = self.create_cache_key(&criteria, user_id);
|
||||
|
||||
// Try to get results from the cache
|
||||
// Try cache
|
||||
if let Some(cached_results) = self.get_from_cache(&cache_key) {
|
||||
return Ok(cached_results);
|
||||
}
|
||||
|
||||
// Initialize collections for results
|
||||
let mut found_files: Vec<FileDto> = Vec::new();
|
||||
let mut found_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
// Perform search in the specified folder or at the root
|
||||
self.search_recursive(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
&mut found_files,
|
||||
&mut found_folders,
|
||||
// ── Parallel recursive search ──
|
||||
let criteria_arc = Arc::new(criteria.clone());
|
||||
let (found_files, found_folders) = Self::search_parallel(
|
||||
self.file_repository.clone(),
|
||||
self.folder_repository.clone(),
|
||||
criteria.folder_id.clone(),
|
||||
criteria_arc,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Apply pagination
|
||||
let total_count = found_files.len() + found_folders.len();
|
||||
let query = criteria.name_contains.as_deref().unwrap_or("");
|
||||
|
||||
// Sort by relevance or date according to criteria
|
||||
// By default, sort by modification date (most recent first)
|
||||
found_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
found_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
// ── Enrich results with server-computed metadata ──
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, query))
|
||||
.collect();
|
||||
|
||||
// Apply limit and offset for pagination
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, query))
|
||||
.collect();
|
||||
|
||||
// ── Sort based on criteria.sort_by ──
|
||||
match criteria.sort_by.as_str() {
|
||||
"name" => {
|
||||
enriched_files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
enriched_folders
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
"name_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
enriched_folders
|
||||
.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
}
|
||||
"date" => {
|
||||
enriched_files.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
enriched_folders.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
}
|
||||
"date_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
enriched_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
}
|
||||
"size" => {
|
||||
enriched_files.sort_by(|a, b| a.size.cmp(&b.size));
|
||||
}
|
||||
"size_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.size.cmp(&a.size));
|
||||
}
|
||||
_ => {
|
||||
// "relevance" (default) — highest relevance first, tie-break by date desc
|
||||
enriched_files.sort_by(|a, b| {
|
||||
b.relevance_score
|
||||
.cmp(&a.relevance_score)
|
||||
.then_with(|| b.modified_at.cmp(&a.modified_at))
|
||||
});
|
||||
enriched_folders.sort_by(|a, b| {
|
||||
b.relevance_score
|
||||
.cmp(&a.relevance_score)
|
||||
.then_with(|| b.modified_at.cmp(&a.modified_at))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pagination ──
|
||||
let total_count = enriched_files.len() + enriched_folders.len();
|
||||
let start_idx = criteria.offset.min(total_count);
|
||||
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
||||
|
||||
let paginated_items: Vec<(bool, usize)> = (start_idx..end_idx)
|
||||
.map(|i| {
|
||||
if i < found_folders.len() {
|
||||
(true, i) // It's a folder
|
||||
if i < enriched_folders.len() {
|
||||
(true, i) // folder
|
||||
} else {
|
||||
(false, i - found_folders.len()) // It's a file
|
||||
(false, i - enriched_folders.len()) // file
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Extract paginated items
|
||||
let mut paginated_folders = Vec::new();
|
||||
let mut paginated_files = Vec::new();
|
||||
|
||||
for (is_folder, idx) in paginated_items {
|
||||
if is_folder {
|
||||
if idx < found_folders.len() {
|
||||
paginated_folders.push(found_folders[idx].clone());
|
||||
if idx < enriched_folders.len() {
|
||||
paginated_folders.push(enriched_folders[idx].clone());
|
||||
}
|
||||
} else if idx < found_files.len() {
|
||||
paginated_files.push(found_files[idx].clone());
|
||||
} else if idx < enriched_files.len() {
|
||||
paginated_files.push(enriched_files[idx].clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Create results object
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
let search_results = SearchResultsDto::new(
|
||||
paginated_files,
|
||||
paginated_folders,
|
||||
criteria.limit,
|
||||
criteria.offset,
|
||||
Some(total_count),
|
||||
elapsed_ms,
|
||||
criteria.sort_by.clone(),
|
||||
);
|
||||
|
||||
// Store in cache
|
||||
@@ -472,11 +723,17 @@ impl SearchUseCase for SearchService {
|
||||
Ok(search_results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the search results cache.
|
||||
*
|
||||
* @return Result indicating success
|
||||
*/
|
||||
/// Returns quick suggestions for autocomplete.
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
self.suggest(query, folder_id, limit).await
|
||||
}
|
||||
|
||||
/// Clears the search results cache.
|
||||
async fn clear_search_cache(&self) -> Result<()> {
|
||||
if let Ok(mut cache) = self.search_cache.lock() {
|
||||
cache.clear();
|
||||
@@ -485,7 +742,8 @@ impl SearchUseCase for SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// Implement the test use case (stub)
|
||||
// ── Stub for testing ────────────────────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
/// Creates a stub version of the service for testing
|
||||
pub fn new_stub() -> impl SearchUseCase {
|
||||
@@ -497,6 +755,18 @@ impl SearchService {
|
||||
Ok(SearchResultsDto::empty())
|
||||
}
|
||||
|
||||
async fn suggest(
|
||||
&self,
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
query_time_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn clear_search_cache(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ impl ShareUseCase for ShareService {
|
||||
// Create the Share entity
|
||||
let share = Share::new(
|
||||
dto.item_id.clone(),
|
||||
dto.item_name.clone(),
|
||||
item_type,
|
||||
user_id.to_string(),
|
||||
permissions,
|
||||
@@ -524,6 +525,14 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
|
||||
@@ -355,6 +355,14 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
|
||||
+29
-1
@@ -19,7 +19,7 @@ use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::pagination::{PaginatedResponseDto, PaginationRequestDto};
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto};
|
||||
use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort};
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory,
|
||||
@@ -250,6 +250,14 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
@@ -350,6 +358,14 @@ impl FolderUseCase for StubFolderUseCase {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_for_owner(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: &str,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
@@ -559,6 +575,18 @@ impl SearchUseCase for StubSearchUseCase {
|
||||
Ok(SearchResultsDto::empty())
|
||||
}
|
||||
|
||||
async fn suggest(
|
||||
&self,
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
) -> Result<SearchSuggestionsDto, DomainError> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
query_time_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn clear_search_cache(&self) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ pub struct Folder {
|
||||
/// Parent folder ID (None if it's a root folder)
|
||||
parent_id: Option<String>,
|
||||
|
||||
/// Owner user ID — scopes folder visibility per user.
|
||||
/// `None` only for legacy/stub folders; real folders always have an owner.
|
||||
owner_id: Option<String>,
|
||||
|
||||
/// Creation timestamp
|
||||
created_at: u64,
|
||||
|
||||
@@ -38,6 +42,7 @@ impl Default for Folder {
|
||||
storage_path: StoragePath::from_string("/"),
|
||||
path_string: "/".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}
|
||||
@@ -51,6 +56,17 @@ impl Folder {
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
) -> FolderResult<Self> {
|
||||
Self::new_with_owner(id, name, storage_path, parent_id, None)
|
||||
}
|
||||
|
||||
/// Creates a new folder with validation and an explicit owner.
|
||||
pub fn new_with_owner(
|
||||
id: String,
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<String>,
|
||||
) -> FolderResult<Self> {
|
||||
// Validate folder name
|
||||
if name.is_empty() || name.contains('/') || name.contains('\\') {
|
||||
@@ -71,6 +87,7 @@ impl Folder {
|
||||
storage_path,
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
})
|
||||
@@ -84,6 +101,19 @@ impl Folder {
|
||||
parent_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> FolderResult<Self> {
|
||||
Self::with_timestamps_and_owner(id, name, storage_path, parent_id, None, created_at, modified_at)
|
||||
}
|
||||
|
||||
/// Creates a folder with specific timestamps and owner (for DB reconstruction)
|
||||
pub fn with_timestamps_and_owner(
|
||||
id: String,
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> FolderResult<Self> {
|
||||
// Validate folder name
|
||||
if name.is_empty() || name.contains('/') || name.contains('\\') {
|
||||
@@ -99,6 +129,7 @@ impl Folder {
|
||||
storage_path,
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
})
|
||||
@@ -133,6 +164,10 @@ impl Folder {
|
||||
self.modified_at
|
||||
}
|
||||
|
||||
pub fn owner_id(&self) -> Option<&str> {
|
||||
self.owner_id.as_deref()
|
||||
}
|
||||
|
||||
/// Creates a new Folder instance from a DTO
|
||||
/// This function is primarily for conversions in batch handlers
|
||||
pub fn from_dto(
|
||||
@@ -153,6 +188,7 @@ impl Folder {
|
||||
storage_path,
|
||||
path_string: path,
|
||||
parent_id,
|
||||
owner_id: None,
|
||||
created_at,
|
||||
modified_at,
|
||||
}
|
||||
@@ -188,6 +224,7 @@ impl Folder {
|
||||
storage_path: new_storage_path,
|
||||
path_string: new_path_string,
|
||||
parent_id: self.parent_id.clone(),
|
||||
owner_id: self.owner_id.clone(),
|
||||
created_at: self.created_at,
|
||||
modified_at: now,
|
||||
})
|
||||
@@ -219,6 +256,7 @@ impl Folder {
|
||||
storage_path: new_storage_path,
|
||||
path_string: new_path_string,
|
||||
parent_id,
|
||||
owner_id: self.owner_id.clone(),
|
||||
created_at: self.created_at,
|
||||
modified_at: now,
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ pub use super::entity_errors::ShareError;
|
||||
pub struct Share {
|
||||
id: String,
|
||||
item_id: String,
|
||||
item_name: Option<String>,
|
||||
item_type: ShareItemType,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
@@ -34,6 +35,7 @@ pub enum ShareItemType {
|
||||
impl Share {
|
||||
pub fn new(
|
||||
item_id: String,
|
||||
item_name: Option<String>,
|
||||
item_type: ShareItemType,
|
||||
created_by: String,
|
||||
permissions: Option<SharePermissions>,
|
||||
@@ -69,6 +71,7 @@ impl Share {
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
item_id,
|
||||
item_name,
|
||||
item_type,
|
||||
token: Uuid::new_v4().to_string(),
|
||||
password_hash,
|
||||
@@ -88,6 +91,7 @@ impl Share {
|
||||
pub fn from_raw(
|
||||
id: String,
|
||||
item_id: String,
|
||||
item_name: Option<String>,
|
||||
item_type: ShareItemType,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
@@ -100,6 +104,7 @@ impl Share {
|
||||
Self {
|
||||
id,
|
||||
item_id,
|
||||
item_name,
|
||||
item_type,
|
||||
token,
|
||||
password_hash,
|
||||
@@ -121,6 +126,10 @@ impl Share {
|
||||
&self.item_id
|
||||
}
|
||||
|
||||
pub fn item_name(&self) -> Option<&str> {
|
||||
self.item_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn item_type(&self) -> &ShareItemType {
|
||||
&self.item_type
|
||||
}
|
||||
@@ -257,6 +266,7 @@ mod tests {
|
||||
fn test_create_share() {
|
||||
let share = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
None,
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
@@ -287,6 +297,7 @@ mod tests {
|
||||
let future = now + 3600; // 1 hour in the future
|
||||
let share = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
None,
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
@@ -301,6 +312,7 @@ mod tests {
|
||||
let past = now - 3600; // 1 hour in the past
|
||||
let share_result = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
None,
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
@@ -335,6 +347,7 @@ mod tests {
|
||||
fn test_has_password_with_hash() {
|
||||
let share = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
None,
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
@@ -351,6 +364,7 @@ mod tests {
|
||||
fn test_has_password_without_hash() {
|
||||
let share = Share::new(
|
||||
"test_file_id".to_string(),
|
||||
None,
|
||||
ShareItemType::File,
|
||||
"user123".to_string(),
|
||||
None,
|
||||
|
||||
@@ -36,6 +36,15 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
|
||||
|
||||
/// Lists root-level folders owned by a specific user.
|
||||
/// For non-root queries (parent_id is Some), ownership is implicit
|
||||
/// because the parent already belongs to the user.
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError>;
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
|
||||
@@ -73,15 +73,17 @@ impl FolderDbRepository {
|
||||
id: String,
|
||||
name: String,
|
||||
parent_id: Option<String>,
|
||||
user_id: Option<String>,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let storage_path = self.build_folder_path(&id).await?;
|
||||
Folder::with_timestamps(
|
||||
Folder::with_timestamps_and_owner(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
user_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
)
|
||||
@@ -141,14 +143,14 @@ impl FolderRepository for FolderDbRepository {
|
||||
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
||||
})?;
|
||||
|
||||
self.row_to_folder(row.0, name, parent_id, row.1, row.2)
|
||||
self.row_to_folder(row.0, name, parent_id, Some(user_id), row.1, row.2)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, i64)>(
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -161,7 +163,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
self.row_to_folder(row.0, row.1, row.2, row.3, row.4).await
|
||||
self.row_to_folder(row.0, row.1, row.2, Some(row.3), row.4, row.5).await
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
||||
@@ -212,10 +214,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, i64, i64)> = if let Some(pid) = parent_id {
|
||||
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,
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -229,7 +231,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -243,8 +245,55 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, ca, ma).await?);
|
||||
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)
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
// For sub-folders the owner is implicit (parent belongs to user),
|
||||
// but we still filter to be safe.
|
||||
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
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
// Root-level: only this user's home folders
|
||||
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
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_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)
|
||||
}
|
||||
@@ -277,10 +326,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, Option<String>, i64, i64)> = if let Some(pid) = parent_id {
|
||||
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,
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -297,7 +346,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -314,8 +363,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, ca, ma).await?);
|
||||
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))
|
||||
}
|
||||
@@ -524,7 +573,7 @@ impl FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
||||
|
||||
match row {
|
||||
Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, ca, ma).await,
|
||||
Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, Some(user_id.to_string()), ca, ma).await,
|
||||
None => {
|
||||
// Already exists — fetch it
|
||||
let existing = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
@@ -541,7 +590,7 @@ impl FolderDbRepository {
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?;
|
||||
self.row_to_folder(existing.0, name.to_string(), None, existing.1, existing.2)
|
||||
self.row_to_folder(existing.0, name.to_string(), None, Some(user_id.to_string()), existing.1, existing.2)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use crate::{
|
||||
struct ShareRecord {
|
||||
id: String,
|
||||
item_id: String,
|
||||
#[serde(default)]
|
||||
item_name: Option<String>,
|
||||
item_type: String,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
@@ -84,6 +86,7 @@ impl ShareFsRepository {
|
||||
Share::from_raw(
|
||||
record.id.clone(),
|
||||
record.item_id.clone(),
|
||||
record.item_name.clone(),
|
||||
item_type,
|
||||
record.token.clone(),
|
||||
record.password_hash.clone(),
|
||||
@@ -100,6 +103,7 @@ impl ShareFsRepository {
|
||||
ShareRecord {
|
||||
id: share.id().to_string(),
|
||||
item_id: share.item_id().to_string(),
|
||||
item_name: share.item_name().map(|s| s.to_string()),
|
||||
item_type: share.item_type().to_string(),
|
||||
token: share.token().to_string(),
|
||||
password_hash: share.password_hash().map(|s| s.to_string()),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
extract::{Json, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
@@ -12,6 +12,7 @@ use crate::application::services::batch_operations::{
|
||||
BatchOperationService, BatchResult, BatchStats,
|
||||
};
|
||||
use crate::interfaces::api::handlers::ApiResult;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Shared state for the batch handler
|
||||
#[derive(Clone)]
|
||||
@@ -428,3 +429,193 @@ pub async fn get_folders_batch(
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// DTO for batch trash operation requests
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchTrashRequest {
|
||||
/// IDs of the files to move to trash
|
||||
#[serde(default)]
|
||||
pub file_ids: Vec<String>,
|
||||
/// IDs of the folders to move to trash
|
||||
#[serde(default)]
|
||||
pub folder_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// DTO for batch download requests
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchDownloadRequest {
|
||||
/// IDs of the files to include in the ZIP
|
||||
#[serde(default)]
|
||||
pub file_ids: Vec<String>,
|
||||
/// IDs of the folders to include in the ZIP
|
||||
#[serde(default)]
|
||||
pub folder_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Handler for moving multiple files and folders to trash in batch
|
||||
pub async fn trash_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
auth_user: AuthUser,
|
||||
Json(request): Json<BatchTrashRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
if request.file_ids.is_empty() && request.folder_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No file or folder IDs provided"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let mut all_successful: Vec<String> = Vec::new();
|
||||
let mut all_failed: Vec<FailedOperation> = Vec::new();
|
||||
let total = request.file_ids.len() + request.folder_ids.len();
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Trash files
|
||||
if !request.file_ids.is_empty() {
|
||||
match state
|
||||
.batch_service
|
||||
.trash_files(request.file_ids, &auth_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
all_successful.extend(result.successful);
|
||||
all_failed.extend(
|
||||
result
|
||||
.failed
|
||||
.into_iter()
|
||||
.map(|(id, error)| FailedOperation { id, error }),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trash folders
|
||||
if !request.folder_ids.is_empty() {
|
||||
match state
|
||||
.batch_service
|
||||
.trash_folders(request.folder_ids, &auth_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
all_successful.extend(result.successful);
|
||||
all_failed.extend(
|
||||
result
|
||||
.failed
|
||||
.into_iter()
|
||||
.map(|(id, error)| FailedOperation { id, error }),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let successful_count = all_successful.len();
|
||||
let failed_count = all_failed.len();
|
||||
|
||||
let response = BatchOperationResponse {
|
||||
successful: all_successful,
|
||||
failed: all_failed,
|
||||
stats: BatchOperationStats {
|
||||
total,
|
||||
successful: successful_count,
|
||||
failed: failed_count,
|
||||
execution_time_ms: start_time.elapsed().as_millis(),
|
||||
},
|
||||
};
|
||||
|
||||
let status_code = if failed_count > 0 {
|
||||
if successful_count > 0 {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler for moving multiple folders in batch
|
||||
pub async fn move_folders_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFolderOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
if request.folder_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No folder IDs provided"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let result = state
|
||||
.batch_service
|
||||
.move_folders(request.folder_ids, request.target_folder_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let response: BatchOperationResponse<FolderDto> = result.into();
|
||||
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler for downloading multiple files and folders as a single ZIP
|
||||
pub async fn download_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchDownloadRequest>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if request.file_ids.is_empty() && request.folder_ids.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"No file or folder IDs provided".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let zip_bytes = state
|
||||
.batch_service
|
||||
.download_zip(request.file_ids, request.folder_ids)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let filename = format!("oxicloud-download-{}.zip", chrono::Utc::now().timestamp());
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/zip")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
)
|
||||
.header("Content-Length", zip_bytes.len().to_string())
|
||||
.body(axum::body::Body::from(zip_bytes))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
@@ -21,11 +21,9 @@ type AppState = Arc<FolderService>;
|
||||
pub struct FolderHandler;
|
||||
|
||||
impl FolderHandler {
|
||||
/// Creates a new folder
|
||||
/// Creates a new folder.
|
||||
/// When parent_id is not provided, the folder is created inside the
|
||||
/// authenticated user's home folder ("My Folder - {username}") rather
|
||||
/// than at the storage root. This prevents user-created directories
|
||||
/// from being placed flat in ./storage/.
|
||||
/// authenticated user's home folder rather than at the storage root.
|
||||
pub async fn create_folder(
|
||||
State(service): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
@@ -34,15 +32,13 @@ impl FolderHandler {
|
||||
// If no parent_id was supplied, resolve the user's home folder as
|
||||
// the default parent so the new folder is nested correctly.
|
||||
if dto.parent_id.is_none() {
|
||||
let home_folder_name = format!("My Folder - {}", auth_user.username);
|
||||
tracing::info!(
|
||||
"create_folder: parent_id is None for user '{}', looking up home folder '{}'",
|
||||
auth_user.username,
|
||||
home_folder_name
|
||||
"create_folder: parent_id is None for user '{}', resolving home folder",
|
||||
auth_user.username
|
||||
);
|
||||
match service.list_folders(None).await {
|
||||
match service.list_folders_for_owner(None, &auth_user.id).await {
|
||||
Ok(folders) => {
|
||||
if let Some(home) = folders.iter().find(|f| f.name == home_folder_name) {
|
||||
if let Some(home) = folders.first() {
|
||||
tracing::info!(
|
||||
"create_folder: resolved home folder ID '{}' for user '{}'",
|
||||
home.id,
|
||||
@@ -51,8 +47,8 @@ impl FolderHandler {
|
||||
dto.parent_id = Some(home.id.clone());
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"create_folder: home folder '{}' not found, folder will be created at root",
|
||||
home_folder_name
|
||||
"create_folder: home folder not found for user '{}', folder will be created at root",
|
||||
auth_user.username
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -79,13 +75,27 @@ impl FolderHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a folder by ID
|
||||
/// Gets a folder by ID.
|
||||
/// Validates that the authenticated user owns the folder.
|
||||
pub async fn get_folder(
|
||||
State(service): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match service.get_folder(&id).await {
|
||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||
Ok(folder) => {
|
||||
// Access check: folder must belong to the requesting user
|
||||
if let Some(ref owner) = folder.owner_id {
|
||||
if owner != &auth_user.id {
|
||||
tracing::warn!(
|
||||
"get_folder: user '{}' attempted to access folder '{}' owned by '{}'",
|
||||
auth_user.id, id, owner
|
||||
);
|
||||
return (StatusCode::NOT_FOUND, "Folder not found".to_string()).into_response();
|
||||
}
|
||||
}
|
||||
(StatusCode::OK, Json(folder)).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
@@ -97,144 +107,77 @@ impl FolderHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists root folders (no parent ID)
|
||||
/// Non-admin users only see their own home folder.
|
||||
/// Lists root folders for the authenticated user.
|
||||
/// Only returns folders owned by this user — no information disclosure.
|
||||
pub async fn list_root_folders(
|
||||
State(service): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> axum::response::Response {
|
||||
Self::list_folders_for_user(service, None, &auth_user).await
|
||||
Self::list_folders_scoped(service, None, &auth_user).await
|
||||
}
|
||||
|
||||
/// Lists contents of a specific folder by its ID
|
||||
/// Lists contents of a specific folder by its ID.
|
||||
/// Scoped to the authenticated user's folders.
|
||||
pub async fn list_folder_contents(
|
||||
State(service): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> axum::response::Response {
|
||||
Self::list_folders_inner(service, Some(&id)).await
|
||||
Self::list_folders_scoped(service, Some(&id), &auth_user).await
|
||||
}
|
||||
|
||||
/// Lists root folders with pagination support
|
||||
/// Lists root folders with pagination support.
|
||||
pub async fn list_root_folders_paginated(
|
||||
State(service): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
_pagination: Query<PaginationRequestDto>,
|
||||
) -> axum::response::Response {
|
||||
// For paginated root listing, filter by user as well
|
||||
Self::list_folders_for_user(service, None, &auth_user).await
|
||||
Self::list_folders_scoped(service, None, &auth_user).await
|
||||
}
|
||||
|
||||
/// Lists contents of a specific folder with pagination
|
||||
/// Lists contents of a specific folder with pagination.
|
||||
pub async fn list_folder_contents_paginated(
|
||||
State(service): State<AppState>,
|
||||
_auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
pagination: Query<PaginationRequestDto>,
|
||||
) -> axum::response::Response {
|
||||
Self::list_folders_paginated_inner(service, pagination, Some(&id)).await
|
||||
}
|
||||
|
||||
/// Checks if a folder name matches the user home-folder convention.
|
||||
fn is_user_home_folder(folder_name: &str) -> bool {
|
||||
folder_name.starts_with("My Folder - ")
|
||||
}
|
||||
|
||||
/// Checks if a folder belongs to the given user.
|
||||
fn folder_belongs_to_user(folder_name: &str, username: &str) -> bool {
|
||||
let expected = format!("My Folder - {}", username);
|
||||
folder_name == expected
|
||||
}
|
||||
|
||||
/// Lists folders, optionally filtered by parent ID (internal helper)
|
||||
async fn list_folders_inner(
|
||||
service: AppState,
|
||||
parent_id: Option<&str>,
|
||||
) -> axum::response::Response {
|
||||
match service.list_folders(parent_id).await {
|
||||
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders with user-based filtering for root listings.
|
||||
/// Non-admin users only see their own home folder at the root level.
|
||||
async fn list_folders_for_user(
|
||||
service: AppState,
|
||||
parent_id: Option<&str>,
|
||||
auth_user: &AuthUser,
|
||||
) -> axum::response::Response {
|
||||
match service.list_folders(parent_id).await {
|
||||
Ok(folders) => {
|
||||
// Only filter at root level (parent_id == None)
|
||||
let filtered = if parent_id.is_none() {
|
||||
folders
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
// Skip hidden/system folders
|
||||
if f.name.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
// If it's a user home folder, only show if it belongs to this user
|
||||
if Self::is_user_home_folder(&f.name) {
|
||||
return Self::folder_belongs_to_user(&f.name, &auth_user.username);
|
||||
}
|
||||
// Non-home folders are visible to everyone
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
folders
|
||||
};
|
||||
(StatusCode::OK, Json(filtered)).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders with pagination support (internal helper)
|
||||
async fn list_folders_paginated_inner(
|
||||
service: AppState,
|
||||
Query(pagination): Query<PaginationRequestDto>,
|
||||
parent_id: Option<&str>,
|
||||
) -> axum::response::Response {
|
||||
match service.list_folders_paginated(parent_id, &pagination).await {
|
||||
// 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 {
|
||||
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
// Return a JSON error response
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": err.to_string()
|
||||
})),
|
||||
Json(serde_json::json!({ "error": err.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper: lists folders scoped to the authenticated user.
|
||||
/// Uses `list_folders_for_owner` — the DB query filters by `user_id`,
|
||||
/// so no data from other users ever leaves the database.
|
||||
async fn list_folders_scoped(
|
||||
service: AppState,
|
||||
parent_id: Option<&str>,
|
||||
auth_user: &AuthUser,
|
||||
) -> axum::response::Response {
|
||||
match service.list_folders_for_owner(parent_id, &auth_user.id).await {
|
||||
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": err.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -12,43 +12,32 @@ use crate::common::di::AppState;
|
||||
/**
|
||||
* Handler for search operations through the API.
|
||||
*
|
||||
* This handler exposes endpoints related to search functionality,
|
||||
* allowing users to search for files and folders using various criteria.
|
||||
* All search processing (filtering, scoring, sorting, categorization,
|
||||
* formatting) is performed server-side. These handlers are thin HTTP
|
||||
* adapters that delegate to the SearchUseCase.
|
||||
*/
|
||||
pub struct SearchHandler;
|
||||
|
||||
impl SearchHandler {
|
||||
/**
|
||||
* Performs a search based on the criteria provided as query parameters.
|
||||
*
|
||||
* This endpoint allows simple searches directly with URL parameters.
|
||||
*
|
||||
* @param state Application state with services
|
||||
* @param query_params Search parameters as query string
|
||||
* @return HTTP response with the search results
|
||||
*/
|
||||
/// GET /search — simple query-parameter-based search.
|
||||
pub async fn search_files_get(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!("API: File search with parameters: {:?}", params);
|
||||
|
||||
// Extract the search service or return error if not available
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
None => {
|
||||
error!("Search service not available");
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "Search service is not available"
|
||||
})),
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Convert search parameters to DTO
|
||||
let search_criteria = SearchCriteriaDto {
|
||||
name_contains: params.query,
|
||||
file_types: params
|
||||
@@ -64,13 +53,14 @@ impl SearchHandler {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
limit: params.limit.unwrap_or(100),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
sort_by: params.sort_by.unwrap_or_else(|| "relevance".to_string()),
|
||||
};
|
||||
|
||||
// Perform the search
|
||||
match search_service.search(search_criteria).await {
|
||||
Ok(results) => {
|
||||
info!(
|
||||
"Search completed, {} files and {} folders found",
|
||||
"Search completed in {}ms — {} files, {} folders",
|
||||
results.query_time_ms,
|
||||
results.files.len(),
|
||||
results.folders.len()
|
||||
);
|
||||
@@ -80,51 +70,37 @@ impl SearchHandler {
|
||||
error!("Search error: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": format!("Search error: {}", err)
|
||||
})),
|
||||
Json(json!({ "error": format!("Search error: {}", err) })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an advanced search based on a complete JSON criteria object.
|
||||
*
|
||||
* This endpoint allows more complex searches with all possible criteria
|
||||
* provided in the request body.
|
||||
*
|
||||
* @param state Application state with services
|
||||
* @param criteria Complete search criteria
|
||||
* @return HTTP response with the search results
|
||||
*/
|
||||
/// POST /search/advanced — full criteria in the request body.
|
||||
pub async fn search_files_post(
|
||||
State(state): State<AppState>,
|
||||
Json(criteria): Json<SearchCriteriaDto>,
|
||||
) -> impl IntoResponse {
|
||||
info!("API: Advanced file search");
|
||||
|
||||
// Extract the search service or return error if not available
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
None => {
|
||||
error!("Search service not available");
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "Search service is not available"
|
||||
})),
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Perform the search
|
||||
match search_service.search(criteria).await {
|
||||
Ok(results) => {
|
||||
info!(
|
||||
"Search completed, {} files and {} folders found",
|
||||
"Advanced search completed in {}ms — {} files, {} folders",
|
||||
results.query_time_ms,
|
||||
results.files.len(),
|
||||
results.folders.len()
|
||||
);
|
||||
@@ -134,51 +110,79 @@ impl SearchHandler {
|
||||
error!("Search error: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": format!("Search error: {}", err)
|
||||
})),
|
||||
Json(json!({ "error": format!("Search error: {}", err) })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the search results cache.
|
||||
*
|
||||
* This endpoint is useful for forcing fresh searches after significant
|
||||
* changes in the file system.
|
||||
*
|
||||
* @param state Application state with services
|
||||
* @return HTTP response indicating success or error
|
||||
*/
|
||||
pub async fn clear_search_cache(State(state): State<AppState>) -> impl IntoResponse {
|
||||
info!("API: Clearing search cache");
|
||||
/// GET /search/suggest — lightweight autocomplete suggestions.
|
||||
pub async fn suggest_files(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SuggestParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!("API: Search suggestions for {:?}", params.query);
|
||||
|
||||
// Extract the search service or return error if not available
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
None => {
|
||||
error!("Search service not available");
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "Search service is not available"
|
||||
})),
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let limit = params.limit.unwrap_or(10).min(20);
|
||||
|
||||
match search_service
|
||||
.suggest(¶ms.query, params.folder_id.as_deref(), limit)
|
||||
.await
|
||||
{
|
||||
Ok(suggestions) => {
|
||||
info!(
|
||||
"Suggestions completed in {}ms — {} results",
|
||||
suggestions.query_time_ms,
|
||||
suggestions.suggestions.len()
|
||||
);
|
||||
(StatusCode::OK, Json(suggestions)).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Suggestions error: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("Suggestions error: {}", err) })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /search/cache — clears the search results cache.
|
||||
pub async fn clear_search_cache(State(state): State<AppState>) -> impl IntoResponse {
|
||||
info!("API: Clearing search cache");
|
||||
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
None => {
|
||||
error!("Search service not available");
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Clear the cache
|
||||
match search_service.clear_search_cache().await {
|
||||
Ok(_) => {
|
||||
info!("Search cache cleared successfully");
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"message": "Search cache cleared successfully"
|
||||
})),
|
||||
Json(json!({ "message": "Search cache cleared successfully" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -186,9 +190,7 @@ impl SearchHandler {
|
||||
error!("Error clearing search cache: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": format!("Error clearing search cache: {}", err)
|
||||
})),
|
||||
Json(json!({ "error": format!("Error clearing search cache: {}", err) })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -196,38 +198,38 @@ impl SearchHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Search parameters for the GET endpoint
|
||||
/// Search parameters for the GET /search endpoint
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SearchParams {
|
||||
/// Text to search for in file and folder names
|
||||
/// Text to search in file and folder names
|
||||
pub query: Option<String>,
|
||||
|
||||
/// Filter by file types (comma-separated extensions)
|
||||
#[serde(rename = "type")]
|
||||
pub type_filter: Option<String>,
|
||||
|
||||
/// Filter items created after this date (timestamp)
|
||||
/// Created after this timestamp
|
||||
pub created_after: Option<u64>,
|
||||
|
||||
/// Filter items created before this date (timestamp)
|
||||
/// Created before this timestamp
|
||||
pub created_before: Option<u64>,
|
||||
|
||||
/// Filter items modified after this date (timestamp)
|
||||
/// Modified after this timestamp
|
||||
pub modified_after: Option<u64>,
|
||||
|
||||
/// Filter items modified before this date (timestamp)
|
||||
/// Modified before this timestamp
|
||||
pub modified_before: Option<u64>,
|
||||
|
||||
/// Minimum size in bytes
|
||||
/// Minimum file size in bytes
|
||||
pub min_size: Option<u64>,
|
||||
|
||||
/// Maximum size in bytes
|
||||
/// Maximum file size in bytes
|
||||
pub max_size: Option<u64>,
|
||||
|
||||
/// Folder ID to limit the search scope
|
||||
pub folder_id: Option<String>,
|
||||
|
||||
/// Recursive search in subfolders
|
||||
/// Recursive search in subfolders (default: true)
|
||||
pub recursive: Option<bool>,
|
||||
|
||||
/// Result limit for pagination
|
||||
@@ -235,4 +237,20 @@ pub struct SearchParams {
|
||||
|
||||
/// Offset for pagination
|
||||
pub offset: Option<usize>,
|
||||
|
||||
/// Sort order: relevance | name | name_desc | date | date_desc | size | size_desc
|
||||
pub sort_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for the GET /search/suggest endpoint
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SuggestParams {
|
||||
/// Text to search for suggestions
|
||||
pub query: String,
|
||||
|
||||
/// Folder ID to limit the suggestion scope
|
||||
pub folder_id: Option<String>,
|
||||
|
||||
/// Maximum number of suggestions (default 10, max 20)
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ async fn handle_propfind(
|
||||
name: "".to_string(),
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
|
||||
@@ -93,11 +93,17 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
|
||||
let recent_service = app_state.recent_service.clone();
|
||||
|
||||
// Initialize the batch operations service
|
||||
let batch_service = Arc::new(BatchOperationService::default(
|
||||
let mut batch_service_builder = BatchOperationService::default(
|
||||
file_retrieval_service.clone(),
|
||||
file_management_service.clone(),
|
||||
folder_service.clone(),
|
||||
));
|
||||
);
|
||||
if let Some(ref ts) = trash_service {
|
||||
batch_service_builder = batch_service_builder.with_trash_service(ts.clone());
|
||||
}
|
||||
let zip_service_ref = app_state.core.zip_service.clone();
|
||||
batch_service_builder = batch_service_builder.with_zip_service(zip_service_ref);
|
||||
let batch_service = Arc::new(batch_service_builder);
|
||||
|
||||
// Create state for the batch operations handler
|
||||
let batch_handler_state = BatchHandlerState {
|
||||
@@ -176,6 +182,11 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
|
||||
.route("/folders/delete", post(batch_handler::delete_folders_batch))
|
||||
.route("/folders/create", post(batch_handler::create_folders_batch))
|
||||
.route("/folders/get", post(batch_handler::get_folders_batch))
|
||||
.route("/folders/move", post(batch_handler::move_folders_batch))
|
||||
// Trash operations (soft delete)
|
||||
.route("/trash", post(batch_handler::trash_batch))
|
||||
// Download as ZIP
|
||||
.route("/download", post(batch_handler::download_batch))
|
||||
.with_state(batch_handler_state);
|
||||
|
||||
// Create search routes if the service is available
|
||||
@@ -185,6 +196,8 @@ pub fn create_api_routes(app_state: &AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
// Simple search with query parameters
|
||||
.route("/", get(SearchHandler::search_files_get))
|
||||
// Lightweight autocomplete suggestions
|
||||
.route("/suggest", get(SearchHandler::suggest_files))
|
||||
// Advanced search with full criteria object
|
||||
.route("/advanced", post(SearchHandler::search_files_post))
|
||||
// Clear search cache
|
||||
|
||||
Reference in New Issue
Block a user