feat(ui/trash): show original file path in tooltip on mouse over + display thumbnails

* fix: permission also check elements trashed elements
 * tested manually
 * all automated tests ok
This commit is contained in:
Edouard Vanbelle
2026-05-22 00:38:44 +02:00
parent 0cd2f24b7f
commit 191f725199
12 changed files with 144 additions and 29 deletions
@@ -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)
}))
}