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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user