Merge pull request #387 from EdouardVanbelle/feat/trash-item-with-thumbnail-and-path

This commit is contained in:
Dionisio Pozo
2026-05-22 08:12:39 +02:00
committed by GitHub
12 changed files with 144 additions and 29 deletions
+6
View File
@@ -122,6 +122,12 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
/// another user. All user-facing handlers should use this method. /// another user. All user-facing handlers should use this method.
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError>; async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError>;
async fn get_file_or_trashed_with_perms(
&self,
id: &str,
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// Gets a file by its path (for WebDAV) /// Gets a file by its path (for WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>; async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
+2
View File
@@ -29,6 +29,8 @@ pub trait FileReadPort: Send + Sync + 'static {
/// Gets a file by its ID. /// Gets a file by its ID.
async fn get_file(&self, id: &str) -> Result<File, DomainError>; async fn get_file(&self, id: &str) -> Result<File, DomainError>;
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError>;
/// Gets a file by its ID, scoped to a specific owner. /// Gets a file by its ID, scoped to a specific owner.
/// ///
/// Returns `NotFound` if the file does not exist **or** belongs to a /// Returns `NotFound` if the file does not exist **or** belongs to a
@@ -255,6 +255,16 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(FileDto::from(file)) Ok(FileDto::from(file))
} }
async fn get_file_or_trashed_with_perms(
&self,
id: &str,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
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 // FIXME no authorisation at all
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> { async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files) // Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
@@ -60,6 +60,14 @@ impl FileReadPort for MockFileReadPort {
.ok_or_else(|| DomainError::not_found("File", id.to_string())) .ok_or_else(|| DomainError::not_found("File", id.to_string()))
} }
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError> {
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<File, DomainError> { async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
let files = self.files.lock().unwrap(); let files = self.files.lock().unwrap();
match files.get(id) { match files.get(id) {
@@ -782,6 +782,13 @@ mod tests {
} }
} }
async fn get_file_or_trashed(
&self,
_id: &str,
) -> Result<crate::domain::entities::file::File, DomainError> {
unimplemented!()
}
async fn list_files( async fn list_files(
&self, &self,
_folder_id: Option<&str>, _folder_id: Option<&str>,
@@ -459,6 +459,14 @@ impl FileReadPort for MockFileRepository {
} }
} }
async fn get_file_or_trashed(&self, id: &str) -> std::prelude::v1::Result<File, DomainError> {
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( async fn list_files(
&self, &self,
_folder_id: Option<&str>, _folder_id: Option<&str>,
+12
View File
@@ -70,6 +70,10 @@ impl FileReadPort for StubFileReadPort {
Ok(File::default()) Ok(File::default())
} }
async fn get_file_or_trashed(&self, _id: &str) -> Result<File, DomainError> {
Ok(File::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> { async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
Ok(Vec::new()) Ok(Vec::new())
} }
@@ -527,6 +531,14 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(FileDto::default()) Ok(FileDto::default())
} }
async fn get_file_or_trashed_with_perms(
&self,
_id: &str,
_owner_id: Uuid,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> { async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
Ok(Vec::new()) Ok(Vec::new())
} }
@@ -84,13 +84,13 @@ impl FileBlobReadRepository {
/// Mirrors `FolderDbRepository::get_folder_user_id`. /// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit. /// Used by the AuthorizationEngine for owner short-circuit.
pub async fn get_file_user_id(&self, file_id: &str) -> Result<uuid::Uuid, DomainError> { pub async fn get_file_user_id(&self, file_id: &str) -> Result<uuid::Uuid, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>( sqlx::query_scalar::<_, uuid::Uuid>("SELECT user_id FROM storage.files WHERE id = $1::uuid")
"SELECT user_id FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
)
.bind(file_id) .bind(file_id)
.fetch_optional(self.pool.as_ref()) .fetch_optional(self.pool.as_ref())
.await .await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}")))? .map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}"))
})?
.ok_or_else(|| DomainError::not_found("File", file_id)) .ok_or_else(|| DomainError::not_found("File", file_id))
} }
@@ -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<File, DomainError> {
let row = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
Option<String>,
i64,
String,
i64,
i64,
String,
Option<Uuid>,
),
>(
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<File, DomainError> { async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
let row = sqlx::query_as::< let row = sqlx::query_as::<
_, _,
@@ -52,6 +52,7 @@ impl TrashDbRepository {
item_type: String, item_type: String,
user_id: Uuid, user_id: Uuid,
trashed_at: Option<DateTime<Utc>>, trashed_at: Option<DateTime<Utc>>,
original_path: String,
) -> TrashedItem { ) -> TrashedItem {
let trashed_at = trashed_at.unwrap_or_else(Utc::now); let trashed_at = trashed_at.unwrap_or_else(Utc::now);
let deletion_date = trashed_at + chrono::Duration::days(self.retention_days); let deletion_date = trashed_at + chrono::Duration::days(self.retention_days);
@@ -69,7 +70,7 @@ impl TrashDbRepository {
user_id, // owner user_id, // owner
item_type_enum, item_type_enum,
name.clone(), name.clone(),
String::new(), // original_path — not stored separately in soft-delete model original_path, // parent folder path at time of trash
trashed_at, trashed_at,
deletion_date, deletion_date,
) )
@@ -85,12 +86,15 @@ impl TrashRepository for TrashDbRepository {
} }
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> { async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>)>( let rows =
sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
r#" r#"
SELECT id, name, item_type, user_id, trashed_at SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
FROM storage.trash_items COALESCE(p.path || '/' || t.name, t.name) AS original_path
WHERE user_id = $1 FROM storage.trash_items t
ORDER BY trashed_at DESC 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) .bind(user_id)
@@ -100,18 +104,20 @@ impl TrashRepository for TrashDbRepository {
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|(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) self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
}) })
.collect()) .collect())
} }
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> { async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>)>( let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
r#" r#"
SELECT id, name, item_type, user_id, trashed_at SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
FROM storage.trash_items COALESCE(p.path || '/' || t.name, t.name) AS original_path
WHERE id = $1 AND user_id = $2 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) .bind(id)
@@ -120,8 +126,8 @@ impl TrashRepository for TrashDbRepository {
.await .await
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?; .map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
Ok(row.map(|(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) self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
})) }))
} }
+1 -1
View File
@@ -396,7 +396,7 @@ impl FileHandler {
let file_retrieval_service = &state.applications.file_retrieval_service; let file_retrieval_service = &state.applications.file_retrieval_service;
let file = match 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 .await
{ {
Ok(f) => f, Ok(f) => f,
+4
View File
@@ -43,6 +43,10 @@
color: var(--color-trash-delete); color: var(--color-trash-delete);
} }
.file-item.trash-item > .path-cell {
display: none;
}
.actions-cell { .actions-cell {
display: flex; display: flex;
gap: 8px; gap: 8px;
+9
View File
@@ -6,9 +6,13 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js';
import { i18n } from '../core/i18n.js'; import { i18n } from '../core/i18n.js';
import { fileOps } from '../features/files/fileOperations.js'; import { fileOps } from '../features/files/fileOperations.js';
import { multiSelect } from '../features/files/multiSelect.js'; import { multiSelect } from '../features/files/multiSelect.js';
import * as pathTooltip from '../features/pathTooltip.js';
import { appElements } from './state.js'; import { appElements } from './state.js';
import { ui } from './ui.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' * @import {TrashItem} from '../core/types.js'
@@ -19,6 +23,7 @@ async function loadTrashItems() {
try { try {
if (multiSelect) multiSelect.clear(); if (multiSelect) multiSelect.clear();
pathTooltip.destroy(elements.filesList);
ui.resetFilesList(); // ensure also list visible & error hidden ui.resetFilesList(); // ensure also list visible & error hidden
elements.filesList.innerHTML = ` elements.filesList.innerHTML = `
<div class="list-header trash-header"> <div class="list-header trash-header">
@@ -45,6 +50,7 @@ async function loadTrashItems() {
trashItems.forEach((item) => { trashItems.forEach((item) => {
addTrashItemToView(item); addTrashItemToView(item);
}); });
pathTooltip.init(elements.filesList);
} catch (error) { } catch (error) {
console.error('Error loading trash items:', error); console.error('Error loading trash items:', error);
ui.showNotification('Error', 'Error loading trash items'); ui.showNotification('Error', 'Error loading trash items');
@@ -76,17 +82,20 @@ function addTrashItemToView(item) {
const isFolder = !isFile; const isFolder = !isFile;
const iconWrapClass = isFolder ? 'file-icon folder-icon' : `file-icon ${iconSpecialClass}`.trim(); const iconWrapClass = isFolder ? 'file-icon folder-icon' : `file-icon ${iconSpecialClass}`.trim();
const canThumbnail = isFile && THUMBNAILABLE.has((item.category || '').toLowerCase());
const listElement = document.createElement('div'); const listElement = document.createElement('div');
listElement.className = 'file-item trash-item'; listElement.className = 'file-item trash-item';
listElement.dataset.trashId = item.id; listElement.dataset.trashId = item.id;
listElement.dataset.originalId = item.original_id; listElement.dataset.originalId = item.original_id;
listElement.dataset.itemType = item.item_type; listElement.dataset.itemType = item.item_type;
if (item.original_path) listElement.dataset.path = item.original_path;
listElement.innerHTML = ` listElement.innerHTML = `
<div class="name-cell"> <div class="name-cell">
<div class="${iconWrapClass}"> <div class="${iconWrapClass}">
<i class="${iconClass}"></i> <i class="${iconClass}"></i>
${canThumbnail ? `<img class="file-thumb" src="/api/files/${item.original_id}/thumbnail/icon" loading="lazy" alt="">` : ''}
</div> </div>
<span>${escapeHtml(item.name)}</span> <span>${escapeHtml(item.name)}</span>
</div> </div>