diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index d4135a2c..b40e0a35 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -122,6 +122,12 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// another user. All user-facing handlers should use this method. async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result; + async fn get_file_or_trashed_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result; + /// Gets a file by its path (for WebDAV) async fn get_file_by_path(&self, path: &str) -> Result; diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index a452f0f0..e43d6715 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -29,6 +29,8 @@ pub trait FileReadPort: Send + Sync + 'static { /// Gets a file by its ID. async fn get_file(&self, id: &str) -> Result; + async fn get_file_or_trashed(&self, id: &str) -> Result; + /// Gets a file by its ID, scoped to a specific owner. /// /// Returns `NotFound` if the file does not exist **or** belongs to a diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index d9a4fe9a..c16f4e86 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -255,6 +255,16 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(FileDto::from(file)) } + async fn get_file_or_trashed_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result { + self.require_file(id, Permission::Read, caller_id).await?; + let file = self.file_read.get_file_or_trashed(id).await?; + Ok(FileDto::from(file)) + } + // FIXME no authorisation at all async fn get_file_by_path(&self, path: &str) -> Result { // Direct SQL lookup — O(folder_depth) queries instead of O(total_files) diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index e143bc5c..19dd3412 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -60,6 +60,14 @@ impl FileReadPort for MockFileReadPort { .ok_or_else(|| DomainError::not_found("File", id.to_string())) } + async fn get_file_or_trashed(&self, id: &str) -> Result { + let files = self.files.lock().unwrap(); + files + .get(id) + .map(|(f, _)| f.clone()) + .ok_or_else(|| DomainError::not_found("File", id.to_string())) + } + async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result { let files = self.files.lock().unwrap(); match files.get(id) { diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index a86bb1fe..8217e4e4 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -782,6 +782,13 @@ mod tests { } } + async fn get_file_or_trashed( + &self, + _id: &str, + ) -> Result { + unimplemented!() + } + async fn list_files( &self, _folder_id: Option<&str>, diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index e0cf8bf0..dbe1c660 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -459,6 +459,14 @@ impl FileReadPort for MockFileRepository { } } + async fn get_file_or_trashed(&self, id: &str) -> std::prelude::v1::Result { + let files = self.files.lock().unwrap(); + if let Some(file) = files.get(id) { + Ok(file.clone()) + } else { + Err(DomainError::not_found("File", id.to_string())) + } + } async fn list_files( &self, _folder_id: Option<&str>, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 9b35f53c..8da97929 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -70,6 +70,10 @@ impl FileReadPort for StubFileReadPort { Ok(File::default()) } + async fn get_file_or_trashed(&self, _id: &str) -> Result { + Ok(File::default()) + } + async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { Ok(Vec::new()) } @@ -527,6 +531,14 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(FileDto::default()) } + async fn get_file_or_trashed_with_perms( + &self, + _id: &str, + _owner_id: Uuid, + ) -> Result { + Ok(FileDto::default()) + } + async fn list_files(&self, _folder_id: Option<&str>) -> Result, DomainError> { Ok(Vec::new()) } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index b84d346f..8bb55f53 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -84,14 +84,14 @@ impl FileBlobReadRepository { /// Mirrors `FolderDbRepository::get_folder_user_id`. /// Used by the AuthorizationEngine for owner short-circuit. pub async fn get_file_user_id(&self, file_id: &str) -> Result { - sqlx::query_scalar::<_, uuid::Uuid>( - "SELECT user_id FROM storage.files WHERE id = $1::uuid AND NOT is_trashed", - ) - .bind(file_id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}")))? - .ok_or_else(|| DomainError::not_found("File", file_id)) + sqlx::query_scalar::<_, uuid::Uuid>("SELECT user_id FROM storage.files WHERE id = $1::uuid") + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}")) + })? + .ok_or_else(|| DomainError::not_found("File", file_id)) } /// Creates a stub instance for testing — never hits PG. @@ -281,6 +281,49 @@ impl FileReadPort for FileBlobReadRepository { ) } + /// Like `get_file` but also returns trashed files, gated by owner_id. + /// Used exclusively by the thumbnail handler so that thumbnails remain + /// accessible while a file is in the trash (before permanent deletion). + async fn get_file_or_trashed(&self, id: &str) -> Result { + let row = sqlx::query_as::< + _, + ( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + String, + Option, + ), + >( + r#" + SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, + fi.size, fi.mime_type, + EXTRACT(EPOCH FROM fi.created_at)::bigint, + EXTRACT(EPOCH FROM fi.updated_at)::bigint, + fi.blob_hash, + fi.user_id + FROM storage.files fi + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + WHERE fi.id = $1::uuid + "#, + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("get_trashed: {e}")))? + .ok_or_else(|| DomainError::not_found("File", id))?; + + self.hash_cache.insert(id.to_string(), row.8.clone()); + Self::row_to_file( + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + ) + } + async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result { let row = sqlx::query_as::< _, diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index aee38c2b..a6cf0172 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -52,6 +52,7 @@ impl TrashDbRepository { item_type: String, user_id: Uuid, trashed_at: Option>, + original_path: String, ) -> TrashedItem { let trashed_at = trashed_at.unwrap_or_else(Utc::now); let deletion_date = trashed_at + chrono::Duration::days(self.retention_days); @@ -69,7 +70,7 @@ impl TrashDbRepository { user_id, // owner item_type_enum, name.clone(), - String::new(), // original_path — not stored separately in soft-delete model + original_path, // parent folder path at time of trash trashed_at, deletion_date, ) @@ -85,33 +86,38 @@ impl TrashRepository for TrashDbRepository { } async fn get_trash_items(&self, user_id: &Uuid) -> Result> { - let rows = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>)>( - r#" - SELECT id, name, item_type, user_id, trashed_at - FROM storage.trash_items - WHERE user_id = $1 - ORDER BY trashed_at DESC + let rows = + sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>, String)>( + r#" + SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at, + COALESCE(p.path || '/' || t.name, t.name) AS original_path + FROM storage.trash_items t + LEFT JOIN storage.folders p ON p.id = t.original_parent_id + WHERE t.user_id = $1 + ORDER BY t.trashed_at DESC "#, - ) - .bind(user_id) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?; + ) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?; Ok(rows .into_iter() - .map(|(id, name, item_type, uid, trashed_at)| { - self.row_to_trashed_item(id, name, item_type, uid, trashed_at) + .map(|(id, name, item_type, uid, trashed_at, path)| { + self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path) }) .collect()) } async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { - let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>)>( + let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>, String)>( r#" - SELECT id, name, item_type, user_id, trashed_at - FROM storage.trash_items - WHERE id = $1 AND user_id = $2 + SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at, + COALESCE(p.path || '/' || t.name, t.name) AS original_path + FROM storage.trash_items t + LEFT JOIN storage.folders p ON p.id = t.original_parent_id + WHERE t.id = $1 AND t.user_id = $2 "#, ) .bind(id) @@ -120,8 +126,8 @@ impl TrashRepository for TrashDbRepository { .await .map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?; - Ok(row.map(|(id, name, item_type, uid, trashed_at)| { - self.row_to_trashed_item(id, name, item_type, uid, trashed_at) + Ok(row.map(|(id, name, item_type, uid, trashed_at, path)| { + self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path) })) } diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index b303ef76..b0fef948 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -396,7 +396,7 @@ impl FileHandler { let file_retrieval_service = &state.applications.file_retrieval_service; let file = match file_retrieval_service - .get_file_with_perms(&id, auth_user.id) + .get_file_or_trashed_with_perms(&id, auth_user.id) .await { Ok(f) => f, diff --git a/static/css/views/trash.css b/static/css/views/trash.css index 5740d623..bfed0a15 100644 --- a/static/css/views/trash.css +++ b/static/css/views/trash.css @@ -43,6 +43,10 @@ color: var(--color-trash-delete); } +.file-item.trash-item > .path-cell { + display: none; +} + .actions-cell { display: flex; gap: 8px; diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index 8d2517fb..b7066906 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -6,9 +6,13 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { fileOps } from '../features/files/fileOperations.js'; import { multiSelect } from '../features/files/multiSelect.js'; +import * as pathTooltip from '../features/pathTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; +/** Categories whose items have a server-side thumbnail. */ +const THUMBNAILABLE = new Set(['image', 'video', 'pdf']); + /** * * @import {TrashItem} from '../core/types.js' @@ -19,6 +23,7 @@ async function loadTrashItems() { try { if (multiSelect) multiSelect.clear(); + pathTooltip.destroy(elements.filesList); ui.resetFilesList(); // ensure also list visible & error hidden elements.filesList.innerHTML = `
@@ -45,6 +50,7 @@ async function loadTrashItems() { trashItems.forEach((item) => { addTrashItemToView(item); }); + pathTooltip.init(elements.filesList); } catch (error) { console.error('Error loading trash items:', error); ui.showNotification('Error', 'Error loading trash items'); @@ -76,17 +82,20 @@ function addTrashItemToView(item) { const isFolder = !isFile; const iconWrapClass = isFolder ? 'file-icon folder-icon' : `file-icon ${iconSpecialClass}`.trim(); + const canThumbnail = isFile && THUMBNAILABLE.has((item.category || '').toLowerCase()); const listElement = document.createElement('div'); listElement.className = 'file-item trash-item'; listElement.dataset.trashId = item.id; listElement.dataset.originalId = item.original_id; listElement.dataset.itemType = item.item_type; + if (item.original_path) listElement.dataset.path = item.original_path; listElement.innerHTML = `
+ ${canThumbnail ? `` : ''}
${escapeHtml(item.name)}