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.
|
/// 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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,14 +84,14 @@ 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)
|
||||||
)
|
.fetch_optional(self.pool.as_ref())
|
||||||
.bind(file_id)
|
.await
|
||||||
.fetch_optional(self.pool.as_ref())
|
.map_err(|e| {
|
||||||
.await
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a stub instance for testing — never hits PG.
|
/// 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> {
|
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,33 +86,38 @@ 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 =
|
||||||
r#"
|
sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
|
||||||
SELECT id, name, item_type, user_id, trashed_at
|
r#"
|
||||||
FROM storage.trash_items
|
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
|
||||||
WHERE user_id = $1
|
COALESCE(p.path || '/' || t.name, t.name) AS original_path
|
||||||
ORDER BY trashed_at DESC
|
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)
|
.bind(user_id)
|
||||||
.fetch_all(self.pool.as_ref())
|
.fetch_all(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
|
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
|
||||||
|
|
||||||
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)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user