Merge pull request #387 from EdouardVanbelle/feat/trash-item-with-thumbnail-and-path
This commit is contained in:
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
color: var(--color-trash-delete);
|
||||
}
|
||||
|
||||
.file-item.trash-item > .path-cell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user