From bde7c839328a6e1afd7651b04f9fabe15d3688cb Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 6 Jun 2026 19:51:51 +0200 Subject: [PATCH] feat(content_hash): propagate etag and hash_content to */resources --- src/application/dtos/favorites_dto.rs | 4 ++ src/application/dtos/folder_dto.rs | 5 +++ src/application/dtos/recent_dto.rs | 4 ++ src/application/dtos/search_dto.rs | 7 ++++ src/application/dtos/trash_dto.rs | 6 +++ src/application/services/search_service.rs | 4 ++ src/application/services/trash_service.rs | 19 ++++++--- src/domain/entities/file.rs | 39 +++++++++++++------ src/domain/entities/folder.rs | 19 +++++++-- .../pg/favorites_pg_repository.rs | 3 ++ .../repositories/pg/folder_db_repository.rs | 15 ++++--- .../pg/recent_items_pg_repository.rs | 3 ++ .../repositories/pg/trash_db_repository.rs | 3 ++ .../api/handlers/favorites_handler.rs | 25 +++++++----- src/interfaces/api/handlers/folder_handler.rs | 27 ++++++++----- src/interfaces/api/handlers/recent_handler.rs | 19 ++++++--- src/interfaces/nextcloud/report_handler.rs | 18 ++++++--- 17 files changed, 166 insertions(+), 54 deletions(-) diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 19d09cef..80852772 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -125,6 +125,10 @@ pub struct FavoriteResourceRow { pub resource_created_at: DateTime, pub modified_at: DateTime, 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, /// `true` when `owner_id == requesting user_id`. pub is_owner: bool, pub favorited_at: DateTime, diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 8a671e66..1cd5ca3a 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -181,6 +181,11 @@ pub struct FolderResourceRow { pub created_at: DateTime, pub modified_at: DateTime, 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, // Pre-computed sort fields — returned by the SQL for cursor construction. /// `LOWER(name)` used by `name`/`type` sorts. pub sort_str: String, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index cf3ca378..3eee591b 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -105,6 +105,10 @@ pub struct RecentResourceRow { pub resource_created_at: DateTime, pub modified_at: DateTime, 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, /// `true` when `owner_id == requesting user_id`. pub is_owner: bool, pub accessed_at: DateTime, diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index 24f4af9b..5713f712 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -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 diff --git a/src/application/dtos/trash_dto.rs b/src/application/dtos/trash_dto.rs index 5715c9ac..2cbe2e60 100644 --- a/src/application/dtos/trash_dto.rs +++ b/src/application/dtos/trash_dto.rs @@ -61,6 +61,12 @@ pub struct TrashResourceRow { pub resource_created_at: DateTime, pub modified_at: DateTime, 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, pub trashed_at: DateTime, pub deletion_date: DateTime, /// Original location path (for folders: `path`; for files: `parent.path || '/' || name`). diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index 1adaff83..5ce252ab 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -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(), } } diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 5466d8fc..935f294e 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -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, diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 245af3f5..f42147f9 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -269,6 +269,21 @@ impl File { /// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap /// in `"…"` themselves at the HTTP boundary. /// + /// This is a thin instance-method wrapper around + /// [`File::compute_etag`] — see that function for the full + /// formula, rationale, and the "single source of truth" + /// guarantee that lets raw-row listings (`/api/folders/{id}/resources`, + /// favorites, trash, recents, REPORT/SEARCH) compute the same + /// value without constructing a full `File` entity. + pub fn etag(&self) -> String { + Self::compute_etag(&self.blob_hash, self.modified_at) + } + + /// Pure formula for the file ETag, exposed as a static method so + /// listing handlers that operate on raw SQL rows (rather than + /// fully-constructed `File` entities) route through the same + /// definition. + /// /// **Formula**: `{blob_hash[..16]}-{modified_at}`. /// /// - The 16-char BLAKE3 prefix is the content identity (64 bits @@ -280,19 +295,19 @@ impl File { /// and clients would serve stale metadata. /// - When `blob_hash` is shorter than 16 chars (test fixtures, /// stub entities) the prefix is just the whole value. - /// - Folder ETags follow a separate path — see - /// [`crate::domain::entities::folder::Folder::etag`]. + /// - Folder ETags follow a separate formula — see + /// [`crate::domain::entities::folder::Folder::compute_etag`]. /// - /// Every handler that emits a file ETag header MUST route - /// through this method (or the matching [`FileDto::etag`] field - /// populated from it) so `GET`, `HEAD`, `PROPFIND`, `PUT` - /// response, and `MOVE` all return byte-identical values for - /// the same file. The raw blob hash remains accessible via - /// [`File::content_hash`] for API consumers that need the - /// pre-derivation value. - pub fn etag(&self) -> String { - let prefix: String = self.blob_hash.chars().take(16).collect(); - format!("{}-{}", prefix, self.modified_at) + /// Every handler that emits a file ETag header MUST go through + /// this function (directly or via [`File::etag`] / + /// `FileDto::etag`) so `GET`, `HEAD`, `PROPFIND`, `PUT` + /// response, `MOVE`, and every JSON listing return + /// byte-identical values for the same file. Changing the + /// formula here changes it everywhere — that is the property + /// we want. + pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String { + let prefix: String = blob_hash.chars().take(16).collect(); + format!("{}-{}", prefix, modified_at) } // Getters diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 137c5af4..b08c0c76 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -239,6 +239,19 @@ impl Folder { /// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap /// in `"…"` themselves at the HTTP boundary. /// + /// Thin instance-method wrapper around [`Folder::compute_etag`] + /// — see that function for the formula and the rationale. + /// Raw-row listings (favorites, trash, recents, search) call + /// the static form so the same formula governs every code path. + pub fn etag(&self) -> String { + Self::compute_etag(&self.id, self.tree_modified_at) + } + + /// Pure formula for the folder ETag, exposed as a static method + /// so callers that don't have a fully-constructed `Folder` (raw + /// SQL rows in listing handlers, search results, etc.) route + /// through the same definition. + /// /// **Formula**: `{id[..16]}-{tree_modified_at}`. /// /// - The 16-char UUID prefix gives the folder its identity @@ -257,9 +270,9 @@ impl Folder { /// trigger does bump `tree_modified_at` on rename via the /// folder-side trigger, so the etag still changes — which is /// correct, the parent collection's listing changed. - pub fn etag(&self) -> String { - let prefix: String = self.id.chars().take(16).collect(); - format!("{}-{}", prefix, self.tree_modified_at) + pub fn compute_etag(id: &str, tree_modified_at: u64) -> String { + let prefix: String = id.chars().take(16).collect(); + format!("{}-{}", prefix, tree_modified_at) } /// Creates a new Folder instance from a DTO diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 03d00a9e..3020722b 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { fld.created_at AS resource_created_at, fld.updated_at AS modified_at, fld.user_id AS owner_id, + NULL::text AS blob_hash, (fld.user_id = $1::uuid) AS is_owner, uf.created_at AS favorited_at, fld.path::text AS resource_path, @@ -332,6 +333,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { f.created_at AS resource_created_at, f.updated_at AS modified_at, f.user_id AS owner_id, + f.blob_hash, (f.user_id = $1::uuid) AS is_owner, uf.created_at AS favorited_at, COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, @@ -574,6 +576,7 @@ LIMIT $6" resource_created_at: row.get("resource_created_at"), modified_at: row.get("modified_at"), owner_id: row.get("owner_id"), + blob_hash: row.try_get("blob_hash").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), favorited_at: row.get("favorited_at"), path: row.try_get("resource_path").ok(), diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 8e653464..ea6b3466 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1151,6 +1151,7 @@ impl FolderDbRepository { f.created_at, f.updated_at AS modified_at, f.user_id, + NULL::text AS blob_hash, LOWER(f.name) AS sort_str, 0::bigint AS type_order, 0::int AS folder_first @@ -1169,6 +1170,7 @@ impl FolderDbRepository { fm.created_at, fm.updated_at AS modified_at, fm.user_id, + fm.blob_hash, LOWER(fm.name) AS sort_str, fm.category_order::bigint AS type_order, 1::int AS folder_first @@ -1292,7 +1294,7 @@ impl FolderDbRepository { let sql = format!( "WITH resources AS ({cte_inner}) \ SELECT resource_type, id, name, folder_id, mime_type, size, \ - created_at, modified_at, user_id, sort_str, type_order, folder_first \ + created_at, modified_at, user_id, blob_hash, sort_str, type_order, folder_first \ FROM resources \ {where_clause} \ {order_clause} \ @@ -1300,7 +1302,8 @@ impl FolderDbRepository { ); // Row: (resource_type, id, name, folder_id, mime_type, size, - // created_at, modified_at, user_id, sort_str, type_order, folder_first) + // created_at, modified_at, user_id, blob_hash, + // sort_str, type_order, folder_first) type Row = ( String, Uuid, @@ -1311,6 +1314,7 @@ impl FolderDbRepository { chrono::DateTime, chrono::DateTime, Uuid, + Option, String, i64, i32, @@ -1341,9 +1345,10 @@ impl FolderDbRepository { created_at: r.6, modified_at: r.7, owner_id: r.8, - sort_str: r.9, - type_order: r.10, - folder_first: r.11, + blob_hash: r.9, + sort_str: r.10, + type_order: r.11, + folder_first: r.12, }) .collect()) } diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 1a3cc33c..93e44c05 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { fld.created_at AS resource_created_at, fld.updated_at AS modified_at, fld.user_id AS owner_id, + NULL::text AS blob_hash, (fld.user_id = $1::uuid) AS is_owner, ur.accessed_at AS accessed_at, fld.path::text AS resource_path, @@ -239,6 +240,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { f.created_at AS resource_created_at, f.updated_at AS modified_at, f.user_id AS owner_id, + f.blob_hash, (f.user_id = $1::uuid) AS is_owner, ur.accessed_at AS accessed_at, COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, @@ -483,6 +485,7 @@ LIMIT $6" resource_created_at: row.get("resource_created_at"), modified_at: row.get("modified_at"), owner_id: row.get("owner_id"), + blob_hash: row.try_get("blob_hash").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), accessed_at: row.get("accessed_at"), path: row.try_get("resource_path").ok(), diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index 191ccecb..ab9d3741 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -261,6 +261,7 @@ impl TrashDbRepository { fld.created_at AS resource_created_at, fld.updated_at AS modified_at, fld.user_id AS owner_id, + NULL::text AS blob_hash, fld.trashed_at AS trashed_at, (fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, fld.path::text AS resource_path, @@ -286,6 +287,7 @@ impl TrashDbRepository { f.created_at AS resource_created_at, f.updated_at AS modified_at, f.user_id AS owner_id, + f.blob_hash, f.trashed_at AS trashed_at, (f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, @@ -473,6 +475,7 @@ LIMIT $6" resource_created_at: row.get("resource_created_at"), modified_at: row.get("modified_at"), owner_id: row.get("owner_id"), + blob_hash: row.try_get("blob_hash").ok(), trashed_at, deletion_date, path: row.try_get("resource_path").ok(), diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 2d5dcbfe..c32a5a20 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -20,6 +20,7 @@ use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::services::favorites_service::FavoritesService; +use crate::domain::entities::file::File; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -279,12 +280,18 @@ pub async fn list_favorites_resources( .as_deref() .unwrap_or("application/octet-stream"); let size_bytes = row.size.max(0) as u64; - // The favorites list query doesn't select - // `blob_hash` — favorites UI displays metadata - // only and doesn't trigger ETag-conditional - // requests against these rows. If a caller - // ever needs the content hash here, widen the - // favorites SQL. + // Route ETag through `File::compute_etag` so + // this listing's `etag` byte-equals what + // GET/HEAD/PROPFIND would return for the same + // file. `blob_hash` is `None` only for + // folder rows, which take the other branch. + 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(), @@ -293,7 +300,7 @@ pub async fn list_favorites_resources( 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, @@ -302,8 +309,8 @@ pub async fn list_favorites_resources( 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, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 41cf5803..da113be5 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -26,6 +26,7 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; +use crate::domain::entities::file::File; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -717,14 +718,20 @@ pub async fn list_folder_resources( .as_deref() .unwrap_or("application/octet-stream"); let size_bytes = row.size.max(0) as u64; - // `FolderResourceRow` (the UNION ALL row used - // by this listing) doesn't carry `blob_hash`, - // so neither `content_hash` nor `etag` can be - // populated here without widening the SQL. The - // REST file-listing UI doesn't issue - // conditional requests against these rows — - // file download / WebDAV PROPFIND go through - // paths that DO carry the hash. + // `blob_hash` is `Some(_)` for file rows in the + // UNION ALL (`NULL` for folders). Route the + // ETag formula through `File::compute_etag` — + // the single source of truth shared with + // GET/HEAD/PROPFIND/PUT response — so this + // listing's `etag` byte-equals what a + // conditional request would compare against. + 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.id.to_string(), name: row.name.clone(), @@ -740,8 +747,8 @@ pub async fn list_folder_resources( 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, }; FolderResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 404311b6..14dcb189 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -19,6 +19,7 @@ use crate::application::dtos::recent_dto::{ }; use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::services::recent_service::RecentService; +use crate::domain::entities::file::File; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; use uuid::Uuid; @@ -309,8 +310,16 @@ pub async fn list_recent_resources( .as_deref() .unwrap_or("application/octet-stream"); let size_bytes = row.size.max(0) as u64; - // Recents listing row doesn't carry blob_hash - // (same reason as folder_handler / favorites). + // Route ETag through `File::compute_etag` so this + // listing matches GET/HEAD/PROPFIND byte-for-byte + // for the same file. + 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(), @@ -319,7 +328,7 @@ pub async fn list_recent_resources( 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, @@ -328,8 +337,8 @@ pub async fn list_recent_resources( 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, }; RecentResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 704ee845..c59a07f2 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -21,6 +21,7 @@ use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; +use crate::domain::entities::file::File; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::nextcloud::webdav_handler::{ @@ -250,9 +251,16 @@ async fn handle_search( /// Build a `FileDto` from a search file result. fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto { - // `SearchFileResultDto` doesn't carry `blob_hash`; the SEARCH / - // REPORT XML emitter doesn't read `content_hash` or `etag` off - // these DTOs anyway, so leaving them empty here is correct. + // Route ETag through `File::compute_etag` so REPORT/SEARCH hits + // emit the same opaque token NC's sync client cached from the + // earlier PROPFIND walk — without this, NC's conditional-request + // logic on search results disagrees with its own cached state + // and triggers a spurious re-fetch. + let etag = if fr.blob_hash.is_empty() { + String::new() + } else { + File::compute_etag(&fr.blob_hash, fr.modified_at) + }; FileDto { id: fr.id.clone(), name: fr.name.clone(), @@ -270,8 +278,8 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes size_formatted: format_file_size(fr.size), owner_id: None, sort_date: None, - content_hash: String::new(), - etag: String::new(), + content_hash: fr.blob_hash.clone(), + etag, } }