feat(content_hash): propagate etag and hash_content to */resources

This commit is contained in:
Edouard Vanbelle
2026-06-06 19:51:51 +02:00
parent 46f8789f4f
commit bde7c83932
17 changed files with 166 additions and 54 deletions
+4
View File
@@ -125,6 +125,10 @@ pub struct FavoriteResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Routes into `FileDto::content_hash` and feeds
/// `File::compute_etag` to populate `FileDto::etag`.
pub blob_hash: Option<String>,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub favorited_at: DateTime<Utc>,
+5
View File
@@ -181,6 +181,11 @@ pub struct FolderResourceRow {
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Populates `FileDto::content_hash` + `FileDto::etag`
/// on the REST `/api/folders/{id}/resources` listing so API
/// consumers can issue conditional requests against listed files.
pub blob_hash: Option<String>,
// Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String,
+4
View File
@@ -105,6 +105,10 @@ pub struct RecentResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so this listing's
/// `etag` matches GET/HEAD/PROPFIND for the same file.
pub blob_hash: Option<String>,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub accessed_at: DateTime<Utc>,
+7
View File
@@ -127,6 +127,13 @@ pub struct SearchFileResultDto {
pub icon_special_class: String,
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
pub category: String,
/// Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and
/// `File::compute_etag` when search results are converted to
/// `FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()`
/// for backward-compatible deserialisation of cached results
/// that pre-date the column.
#[serde(default)]
pub blob_hash: String,
}
/// A folder search result enriched with server-computed metadata
+6
View File
@@ -61,6 +61,12 @@ pub struct TrashResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so the trash listing's
/// `etag` matches what GET/HEAD/PROPFIND would return for the
/// same file (restorable trash items are conditional-request
/// targets too).
pub blob_hash: Option<String>,
pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
@@ -172,6 +172,10 @@ impl SearchService {
icon_class: get_icon_class(&file.name, &file.mime_type),
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
category: get_category(&file.name, &file.mime_type),
// Carry the content hash through so REPORT/SEARCH
// responses on the NC surface can emit the same ETag
// (`File::compute_etag`) as PROPFIND/GET would.
blob_hash: file.content_hash.clone(),
}
}
+14 -5
View File
@@ -17,6 +17,7 @@ use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::file::File;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
@@ -836,8 +837,16 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// Trash listing row doesn't carry blob_hash either; trashed
// items aren't ETag-conditional in the UI.
// Route ETag through `File::compute_etag` so trash items
// match GET/HEAD/PROPFIND ETags — a client restoring a
// file may conditional-request it immediately after.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
@@ -846,15 +855,15 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash: String::new(),
etag: String::new(),
content_hash,
etag,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,