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.
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)
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.
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.
///
/// Returns `NotFound` if the file does not exist **or** belongs to a
@@ -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<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
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
// 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()))
}
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> {
let files = self.files.lock().unwrap();
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(
&self,
_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(
&self,
_folder_id: Option<&str>,
+12
View File
@@ -70,6 +70,10 @@ impl FileReadPort for StubFileReadPort {
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> {
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<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
Ok(Vec::new())
}
@@ -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<uuid::Uuid, DomainError> {
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<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> {
let row = sqlx::query_as::<
_,
@@ -52,6 +52,7 @@ impl TrashDbRepository {
item_type: String,
user_id: Uuid,
trashed_at: Option<DateTime<Utc>>,
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<Vec<TrashedItem>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>)>(
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<DateTime<Utc>>, 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<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#"
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)
}))
}
+1 -1
View File
@@ -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,
+4
View File
@@ -43,6 +43,10 @@
color: var(--color-trash-delete);
}
.file-item.trash-item > .path-cell {
display: none;
}
.actions-cell {
display: flex;
gap: 8px;
+9
View File
@@ -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 = `
<div class="list-header trash-header">
@@ -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 = `
<div class="name-cell">
<div class="${iconWrapClass}">
<i class="${iconClass}"></i>
${canThumbnail ? `<img class="file-thumb" src="/api/files/${item.original_id}/thumbnail/icon" loading="lazy" alt="">` : ''}
</div>
<span>${escapeHtml(item.name)}</span>
</div>