Files
Oxicloud/src/application/services/trash_service.rs
T
zjean 18518bedaf fix: resolve clippy warnings and rustfmt issues for CI compliance
Fix all clippy lints (collapsible if, clone on Copy, needless borrow,
redundant bindings, unused params) and apply rustfmt across the codebase.
Update test mocks to match Uuid-based trait signatures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:34:07 +01:00

708 lines
29 KiB
Rust
Executable File

use std::sync::Arc;
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
/**
* Application service for trash operations.
*
* The TrashService implements the trash management functionality in the application layer,
* handling movement of files and folders to trash, restoration from trash, and permanent
* deletion. It orchestrates interactions between the domain entities and infrastructure
* repositories while enforcing business rules like retention policies.
*
* This service follows the Clean Architecture pattern by:
* - Depending on application ports rather than domain/infrastructure traits
* - Orchestrating domain operations without containing domain logic
* - Exposing its functionality through the TrashUseCase port
*/
pub struct TrashService {
/// Repository for trash-specific operations like listing and retrieving trashed items
trash_repository: Arc<TrashDbRepository>,
/// Port for file read operations (get file metadata)
file_read_port: Arc<FileBlobReadRepository>,
/// Port for file write operations (trash, restore, delete)
file_write_port: Arc<FileBlobWriteRepository>,
/// Port for folder operations (get folder, trash, restore, delete)
folder_storage_port: Arc<FolderDbRepository>,
/// Thumbnail service for cleaning up thumbnails on permanent delete
thumbnail_service: Option<Arc<ThumbnailService>>,
/// Number of days items should be kept in trash before automatic cleanup
retention_days: u32,
}
impl TrashService {
pub fn new(
trash_repository: Arc<TrashDbRepository>,
file_read_port: Arc<FileBlobReadRepository>,
file_write_port: Arc<FileBlobWriteRepository>,
folder_storage_port: Arc<FolderDbRepository>,
retention_days: u32,
thumbnail_service: Option<Arc<ThumbnailService>>,
) -> Self {
Self {
trash_repository,
file_read_port,
file_write_port,
folder_storage_port,
thumbnail_service,
retention_days,
}
}
/// Converts a TrashedItem entity to a DTO
fn to_dto(&self, item: TrashedItem) -> TrashedItemDto {
// Calculate days_until_deletion before moving item fields
let days_until_deletion = item.days_until_deletion();
TrashedItemDto {
id: item.id().to_string(),
original_id: item.original_id().to_string(),
item_type: match item.item_type() {
TrashedItemType::File => "file".to_string(),
TrashedItemType::Folder => "folder".to_string(),
},
name: item.name().to_string(),
original_path: item.original_path().to_string(),
trashed_at: item.trashed_at(),
days_until_deletion,
}
}
/// Validates that the given user owns the trashed item.
/// Returns an error if the item does not exist or belongs to a different user.
#[instrument(skip(self))]
async fn _validate_user_ownership(&self, item_id: &str, user_id: &str) -> Result<()> {
let item_uuid = Uuid::parse_str(item_id)
.map_err(|e| DomainError::validation_error(format!("Invalid item ID: {}", e)))?;
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
match self
.trash_repository
.get_trash_item(&item_uuid, &user_uuid)
.await?
{
Some(item) => {
if item.user_id() != user_uuid {
error!(
"User {} attempted to access trash item {} owned by {}",
user_id,
item_id,
item.user_id()
);
return Err(DomainError::access_denied(
"TrashItem",
"You do not have permission to access this trash item",
));
}
Ok(())
}
None => {
// Item not found for this user — treat as authorization error
// to avoid leaking existence information
Err(DomainError::not_found(
"TrashItem",
format!("{} (user: {})", item_id, user_id),
))
}
}
}
}
impl TrashUseCase for TrashService {
#[instrument(skip(self))]
async fn get_trash_items(&self, user_id: Uuid) -> Result<Vec<TrashedItemDto>> {
debug!("Getting trash items for user: {}", user_id);
let items = self.trash_repository.get_trash_items(&user_id).await?;
let dtos = items.into_iter().map(|item| self.to_dto(item)).collect();
Ok(dtos)
}
#[instrument(skip(self))]
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> {
info!(
"Moving to trash: type={}, id={}, user={}",
item_type, item_id, user_id
);
debug!("User UUID validation: {}", user_id);
// Note: We now verify file/folder ownership BEFORE moving to trash.
// This prevents users from trashing items they do not own (IDOR).
// Parse UUIDs with detailed error handling
debug!("Validating item UUID: {}", item_id);
let item_uuid = match Uuid::parse_str(item_id) {
Ok(uuid) => {
debug!("Valid item UUID: {}", uuid);
uuid
}
Err(e) => {
error!("Invalid item UUID: {} - Error: {}", item_id, e);
return Err(DomainError::validation_error(format!(
"Invalid item ID: {}",
e
)));
}
};
let user_uuid = user_id;
match item_type {
"file" => {
info!("Processing file to move to trash: {}", item_id);
// Get the file — ownership-verified at SQL level.
// Returns NotFound if the file does not exist OR belongs to
// another user, preventing cross-user trash operations.
debug!("Getting file data (owner-scoped): {}", item_id);
let file = match self
.file_read_port
.get_file_for_owner(item_id, user_id)
.await
{
Ok(file) => {
debug!("File found: {} ({})", file.name(), item_id);
file
}
Err(e) => {
error!("Error getting file: {} - {}", item_id, e);
return Err(DomainError::new(
ErrorKind::NotFound,
"File",
format!("Error retrieving file {}: {}", item_id, e),
));
}
};
let original_path = file.storage_path().to_string();
debug!("Original file path: {}", original_path);
// Create the trash item
debug!("Creating TrashedItem object for the file");
let trashed_item = TrashedItem::new(
item_uuid,
user_uuid,
TrashedItemType::File,
file.name().to_string(),
original_path,
self.retention_days,
);
debug!(
"TrashedItem created successfully: {} -> {}",
file.name(),
trashed_item.id()
);
// First add to trash index to register the item
info!("Adding file {} to trash index", item_id);
match self.trash_repository.add_to_trash(&trashed_item).await {
Ok(_) => {
debug!("File added to trash index successfully");
}
Err(e) => {
error!("Error adding file to trash index: {}", e);
return Err(DomainError::internal_error(
"TrashRepository",
format!("Failed to add file to trash: {}", e),
));
}
};
// Then physically move the file to trash
info!("Physically moving file to trash: {}", item_id);
match self.file_write_port.move_to_trash(item_id).await {
Ok(_) => {
debug!("File physically moved to trash successfully: {}", item_id);
}
Err(e) => {
error!("Error physically moving file to trash: {} - {}", item_id, e);
return Err(DomainError::new(
ErrorKind::InternalError,
"File",
format!("Error moving file {} to trash: {}", item_id, e),
));
}
}
info!("File completely moved to trash: {}", item_id);
Ok(())
}
"folder" => {
// Get the folder and verify ownership.
// Returns NotFound if the folder does not exist or belongs
// to another user — prevents cross-user trash operations.
let folder = self
.folder_storage_port
.get_folder(item_id)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Folder",
format!("Error retrieving folder {}: {}", item_id, e),
)
})?;
// Ownership check — return NotFound (not Forbidden) to
// prevent leaking whether the folder exists.
if folder.owner_id() != Some(user_id) {
return Err(DomainError::not_found(
"Folder",
format!("Folder not found: {}", item_id),
));
}
let original_path = folder.storage_path().to_string();
// Create the trash item
let trashed_item = TrashedItem::new(
item_uuid,
user_uuid,
TrashedItemType::Folder,
folder.name().to_string(),
original_path,
self.retention_days,
);
// First add to trash index to register the item
debug!("Adding folder {} to trash repository", item_id);
match self.trash_repository.add_to_trash(&trashed_item).await {
Ok(_) => debug!("Successfully added folder to trash repository"),
Err(e) => {
error!("Failed to add folder to trash repository: {}", e);
return Err(DomainError::internal_error(
"TrashRepository",
format!("Failed to add folder to trash: {}", e),
));
}
};
// Then physically move the folder to trash
self.folder_storage_port
.move_to_trash(item_id)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"Folder",
format!("Error moving folder {} to trash: {}", item_id, e),
)
})?;
debug!("Folder moved to trash: {}", item_id);
Ok(())
}
_ => Err(DomainError::validation_error(format!(
"Invalid item type: {}",
item_type
))),
}
}
#[instrument(skip(self))]
async fn restore_item(&self, trash_id: &str, user_id: Uuid) -> Result<()> {
info!("Restoring item {} for user {}", trash_id, user_id);
let trash_uuid = match Uuid::parse_str(trash_id) {
Ok(id) => {
info!("Trash UUID parsed successfully: {}", id);
id
}
Err(e) => {
error!("Invalid trash ID format: {} - {}", trash_id, e);
return Err(DomainError::validation_error(format!(
"Invalid trash ID: {}",
e
)));
}
};
let user_uuid = user_id;
// Get the trash item
info!("Retrieving trash item from repository: ID={}", trash_id);
let item_result = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await;
match item_result {
Ok(Some(item)) => {
info!(
"Found item in trash: ID={}, Type={:?}, OriginalID={}",
trash_id,
item.item_type(),
item.original_id()
);
// Restore based on type
match item.item_type() {
TrashedItemType::File => {
// Restore the file to its original location
let file_id = item.original_id().to_string();
let original_path = item.original_path().to_string();
info!(
"Restoring file from trash: ID={}, OriginalPath={}",
file_id, original_path
);
match self
.file_write_port
.restore_from_trash(&file_id, &original_path)
.await
{
Ok(_) => {
info!("Successfully restored file from trash: {}", file_id);
}
Err(e) => {
// Check if the error is because the file is not found
if format!("{}", e).contains("not found") {
info!(
"File not found in trash, may already have been restored: {}",
file_id
);
// We continue so we can clean up the trash entry
} else {
// Return error for other kinds of errors
error!("Error restoring file from trash: {} - {}", file_id, e);
return Err(DomainError::new(
ErrorKind::InternalError,
"File",
format!(
"Error restoring file {} from trash: {}",
file_id, e
),
));
}
}
}
}
TrashedItemType::Folder => {
// Restore the folder to its original location
let folder_id = item.original_id().to_string();
let original_path = item.original_path().to_string();
info!(
"Restoring folder from trash: ID={}, OriginalPath={}",
folder_id, original_path
);
match self
.folder_storage_port
.restore_from_trash(&folder_id, &original_path)
.await
{
Ok(_) => {
info!("Successfully restored folder from trash: {}", folder_id);
}
Err(e) => {
// Check if the error is because the folder is not found
if format!("{}", e).contains("not found") {
info!(
"Folder not found in trash, may already have been restored: {}",
folder_id
);
// We continue so we can clean up the trash entry
} else {
// Return error for other kinds of errors
error!(
"Error restoring folder from trash: {} - {}",
folder_id, e
);
return Err(DomainError::new(
ErrorKind::InternalError,
"Folder",
format!(
"Error restoring folder {} from trash: {}",
folder_id, e
),
));
}
}
}
}
}
// Always remove the item from the trash index to maintain consistency
info!(
"Removing item from trash index after restoration: {}",
trash_id
);
match self
.trash_repository
.restore_from_trash(&trash_uuid, &user_uuid)
.await
{
Ok(_) => {
info!("Successfully removed entry from trash index: {}", trash_id);
}
Err(e) => {
error!(
"Error removing entry from trash index: {} - {}",
trash_id, e
);
return Err(DomainError::new(
ErrorKind::InternalError,
"Trash",
format!("Error removing trash entry after restoration: {}", e),
));
}
}
info!("Item successfully restored from trash: {}", trash_id);
Ok(())
}
Ok(None) => {
// If the item isn't found in trash, we can just return success
info!(
"Item not found in trash index, considering as already restored: {}",
trash_id
);
Ok(())
}
Err(e) => {
// Something went wrong with the repository
error!(
"Error retrieving item from trash repository: {} - {}",
trash_id, e
);
Err(e)
}
}
}
#[instrument(skip(self))]
async fn delete_permanently(&self, trash_id: &str, user_id: Uuid) -> Result<()> {
info!(
"Permanently deleting item {} for user {}",
trash_id, user_id
);
let trash_uuid = match Uuid::parse_str(trash_id) {
Ok(id) => {
info!("Trash UUID parsed successfully: {}", id);
id
}
Err(e) => {
error!("Invalid trash ID format: {} - {}", trash_id, e);
return Err(DomainError::validation_error(format!(
"Invalid trash ID: {}",
e
)));
}
};
let user_uuid = user_id;
// Get the trash item
info!("Retrieving trash item from repository: ID={}", trash_id);
let item_result = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await;
match item_result {
Ok(Some(item)) => {
info!(
"Found item in trash: ID={}, Type={:?}, OriginalID={}",
trash_id,
item.item_type(),
item.original_id()
);
// Permanently delete based on type
match item.item_type() {
TrashedItemType::File => {
// Permanently delete the file
let file_id = item.original_id().to_string();
info!("Permanently deleting file: {}", file_id);
match self.file_write_port.delete_file_permanently(&file_id).await {
Ok(_) => {
info!("Successfully deleted file permanently: {}", file_id);
}
Err(e) => {
// Check if the file is not found - in that case, we can continue
// because we still want to remove the item from the trash index
if format!("{}", e).contains("not found") {
info!(
"File not found, may already have been deleted: {}",
file_id
);
} else {
// Return error for other types of errors
error!("Error permanently deleting file: {} - {}", file_id, e);
return Err(DomainError::new(
ErrorKind::InternalError,
"File",
format!(
"Error deleting file {} permanently: {}",
file_id, e
),
));
}
}
}
// Best-effort thumbnail cleanup — thumbnails are cache
// artifacts, so failure must not block file deletion.
if let Some(thumb) = &self.thumbnail_service
&& let Err(e) = thumb.delete_thumbnails(&file_id).await
{
warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
}
}
TrashedItemType::Folder => {
// Permanently delete the folder
let folder_id = item.original_id().to_string();
info!("Permanently deleting folder: {}", folder_id);
match self
.folder_storage_port
.delete_folder_permanently(&folder_id)
.await
{
Ok(_) => {
info!("Successfully deleted folder permanently: {}", folder_id);
}
Err(e) => {
// Check if the folder is not found - in that case, we can continue
if format!("{}", e).contains("not found") {
info!(
"Folder not found, may already have been deleted: {}",
folder_id
);
} else {
// Return error for other types of errors
error!(
"Error permanently deleting folder: {} - {}",
folder_id, e
);
return Err(DomainError::new(
ErrorKind::InternalError,
"Folder",
format!(
"Error deleting folder {} permanently: {}",
folder_id, e
),
));
}
}
}
}
}
// Always remove the item from trash index to maintain consistency
info!("Removing entry from trash index: {}", trash_id);
match self
.trash_repository
.delete_permanently(&trash_uuid, &user_uuid)
.await
{
Ok(_) => {
info!("Successfully removed entry from trash index: {}", trash_id);
}
Err(e) => {
error!(
"Error removing entry from trash index: {} - {}",
trash_id, e
);
return Err(DomainError::new(
ErrorKind::InternalError,
"Trash",
format!("Error removing trash entry: {}", e),
));
}
};
info!("Item permanently deleted from trash: {}", trash_id);
Ok(())
}
Ok(None) => {
// If the item isn't found in trash, we can just return success
info!(
"Item not found in trash, considering as already deleted: {}",
trash_id
);
Ok(())
}
Err(e) => {
// Something went wrong with the repository
error!(
"Error retrieving item from trash repository: {} - {}",
trash_id, e
);
Err(e)
}
}
}
#[instrument(skip(self))]
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
info!("Emptying trash for user {}", user_id);
// Collect trashed file IDs BEFORE bulk-deleting so we can clean up
// their thumbnails afterward. This is best-effort — if the query
// fails we still proceed with the bulk delete.
let trashed_file_ids: Vec<String> = if self.thumbnail_service.is_some() {
match self.trash_repository.get_trash_items(&user_id).await {
Ok(items) => items
.iter()
.filter(|i| matches!(i.item_type(), TrashedItemType::File))
.map(|i| i.original_id().to_string())
.collect(),
Err(e) => {
warn!("Could not list trashed items for thumbnail cleanup: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
// clear_trash() already performs bulk SQL DELETEs in 2 queries:
// 1. DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE
// 2. DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE
//
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements blob ref_counts for every deleted file row — no Rust-side
// remove_reference() call is needed.
//
// Finally it clears the trash_items index for the user.
self.trash_repository.clear_trash(&user_id).await?;
// Best-effort thumbnail cleanup for all deleted files
if let Some(thumb) = &self.thumbnail_service {
for file_id in &trashed_file_ids {
if let Err(e) = thumb.delete_thumbnails(file_id).await {
warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
}
}
}
info!("Trash emptied for user {}", user_id);
Ok(())
}
}