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:
Dionisio
2026-02-15 23:45:11 +01:00
parent 6e1b77f244
commit 7737ed90c7
33 changed files with 3078 additions and 1958 deletions
+6
View File
@@ -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,
+106 -7
View File
@@ -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,
}
+3
View File
@@ -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,
+24 -15
View File
@@ -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>;
}
+371 -1
View File
@@ -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(&current.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(&current.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(&current.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,
+522 -252
View File
@@ -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
View File
@@ -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(())
}
+38
View File
@@ -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,
})
+14
View File
@@ -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()),
+192 -1
View File
@@ -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())
}
+63 -120
View File
@@ -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()
}
+89 -71
View File
@@ -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(&params.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,
+15 -2
View File
@@ -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