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 resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, 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`. /// `true` when `owner_id == requesting user_id`.
pub is_owner: bool, pub is_owner: bool,
pub favorited_at: DateTime<Utc>, pub favorited_at: DateTime<Utc>,
+5
View File
@@ -181,6 +181,11 @@ pub struct FolderResourceRow {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, 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. // Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts. /// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String, pub sort_str: String,
+4
View File
@@ -105,6 +105,10 @@ pub struct RecentResourceRow {
pub resource_created_at: DateTime<Utc>, pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, 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`. /// `true` when `owner_id == requesting user_id`.
pub is_owner: bool, pub is_owner: bool,
pub accessed_at: DateTime<Utc>, pub accessed_at: DateTime<Utc>,
+7
View File
@@ -127,6 +127,13 @@ pub struct SearchFileResultDto {
pub icon_special_class: String, pub icon_special_class: String,
/// Content category: "document", "image", "video", "audio", "archive", "code", "other" /// Content category: "document", "image", "video", "audio", "archive", "code", "other"
pub category: String, 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 /// 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 resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>, pub modified_at: DateTime<Utc>,
pub owner_id: Uuid, 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 trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>, pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`). /// 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_class: get_icon_class(&file.name, &file.mime_type),
icon_special_class: get_icon_special_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), 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::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::file::File;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::repositories::trash_repository::TrashRepository;
@@ -836,8 +837,16 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
.as_deref() .as_deref()
.unwrap_or("application/octet-stream"); .unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64; let size_bytes = row.size.max(0) as u64;
// Trash listing row doesn't carry blob_hash either; trashed // Route ETag through `File::compute_etag` so trash items
// items aren't ETag-conditional in the UI. // 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 { let dto = FileDto {
id: row.resource_id.to_string(), id: row.resource_id.to_string(),
name: row.name.clone(), name: row.name.clone(),
@@ -846,15 +855,15 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
mime_type: std::sync::Arc::from(mime), mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()), folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64, 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_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)), icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
category: std::sync::Arc::from(category_for(&row.name, mime)), category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes), size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
sort_date: None, sort_date: None,
content_hash: String::new(), content_hash,
etag: String::new(), etag,
}; };
TrashResourceItemDto { TrashResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
+27 -12
View File
@@ -269,6 +269,21 @@ impl File {
/// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap /// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap
/// in `"…"` themselves at the HTTP boundary. /// 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}`. /// **Formula**: `{blob_hash[..16]}-{modified_at}`.
/// ///
/// - The 16-char BLAKE3 prefix is the content identity (64 bits /// - The 16-char BLAKE3 prefix is the content identity (64 bits
@@ -280,19 +295,19 @@ impl File {
/// and clients would serve stale metadata. /// and clients would serve stale metadata.
/// - When `blob_hash` is shorter than 16 chars (test fixtures, /// - When `blob_hash` is shorter than 16 chars (test fixtures,
/// stub entities) the prefix is just the whole value. /// stub entities) the prefix is just the whole value.
/// - Folder ETags follow a separate path — see /// - Folder ETags follow a separate formula — see
/// [`crate::domain::entities::folder::Folder::etag`]. /// [`crate::domain::entities::folder::Folder::compute_etag`].
/// ///
/// Every handler that emits a file ETag header MUST route /// Every handler that emits a file ETag header MUST go through
/// through this method (or the matching [`FileDto::etag`] field /// this function (directly or via [`File::etag`] /
/// populated from it) so `GET`, `HEAD`, `PROPFIND`, `PUT` /// `FileDto::etag`) so `GET`, `HEAD`, `PROPFIND`, `PUT`
/// response, and `MOVE` all return byte-identical values for /// response, `MOVE`, and every JSON listing return
/// the same file. The raw blob hash remains accessible via /// byte-identical values for the same file. Changing the
/// [`File::content_hash`] for API consumers that need the /// formula here changes it everywhere — that is the property
/// pre-derivation value. /// we want.
pub fn etag(&self) -> String { pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String {
let prefix: String = self.blob_hash.chars().take(16).collect(); let prefix: String = blob_hash.chars().take(16).collect();
format!("{}-{}", prefix, self.modified_at) format!("{}-{}", prefix, modified_at)
} }
// Getters // Getters
+16 -3
View File
@@ -239,6 +239,19 @@ impl Folder {
/// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap /// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap
/// in `"…"` themselves at the HTTP boundary. /// 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}`. /// **Formula**: `{id[..16]}-{tree_modified_at}`.
/// ///
/// - The 16-char UUID prefix gives the folder its identity /// - 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 /// trigger does bump `tree_modified_at` on rename via the
/// folder-side trigger, so the etag still changes — which is /// folder-side trigger, so the etag still changes — which is
/// correct, the parent collection's listing changed. /// correct, the parent collection's listing changed.
pub fn etag(&self) -> String { pub fn compute_etag(id: &str, tree_modified_at: u64) -> String {
let prefix: String = self.id.chars().take(16).collect(); let prefix: String = id.chars().take(16).collect();
format!("{}-{}", prefix, self.tree_modified_at) format!("{}-{}", prefix, tree_modified_at)
} }
/// Creates a new Folder instance from a DTO /// Creates a new Folder instance from a DTO
@@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
fld.created_at AS resource_created_at, fld.created_at AS resource_created_at,
fld.updated_at AS modified_at, fld.updated_at AS modified_at,
fld.user_id AS owner_id, fld.user_id AS owner_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner, (fld.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at, uf.created_at AS favorited_at,
fld.path::text AS resource_path, fld.path::text AS resource_path,
@@ -332,6 +333,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.created_at AS resource_created_at, f.created_at AS resource_created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id AS owner_id, f.user_id AS owner_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner, (f.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at, uf.created_at AS favorited_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, 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"), resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"), modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"), owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false), is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"), favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(), path: row.try_get("resource_path").ok(),
@@ -1151,6 +1151,7 @@ impl FolderDbRepository {
f.created_at, f.created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id, f.user_id,
NULL::text AS blob_hash,
LOWER(f.name) AS sort_str, LOWER(f.name) AS sort_str,
0::bigint AS type_order, 0::bigint AS type_order,
0::int AS folder_first 0::int AS folder_first
@@ -1169,6 +1170,7 @@ impl FolderDbRepository {
fm.created_at, fm.created_at,
fm.updated_at AS modified_at, fm.updated_at AS modified_at,
fm.user_id, fm.user_id,
fm.blob_hash,
LOWER(fm.name) AS sort_str, LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order, fm.category_order::bigint AS type_order,
1::int AS folder_first 1::int AS folder_first
@@ -1292,7 +1294,7 @@ impl FolderDbRepository {
let sql = format!( let sql = format!(
"WITH resources AS ({cte_inner}) \ "WITH resources AS ({cte_inner}) \
SELECT resource_type, id, name, folder_id, mime_type, size, \ 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 \ FROM resources \
{where_clause} \ {where_clause} \
{order_clause} \ {order_clause} \
@@ -1300,7 +1302,8 @@ impl FolderDbRepository {
); );
// Row: (resource_type, id, name, folder_id, mime_type, size, // 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 = ( type Row = (
String, String,
Uuid, Uuid,
@@ -1311,6 +1314,7 @@ impl FolderDbRepository {
chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>,
Uuid, Uuid,
Option<String>,
String, String,
i64, i64,
i32, i32,
@@ -1341,9 +1345,10 @@ impl FolderDbRepository {
created_at: r.6, created_at: r.6,
modified_at: r.7, modified_at: r.7,
owner_id: r.8, owner_id: r.8,
sort_str: r.9, blob_hash: r.9,
type_order: r.10, sort_str: r.10,
folder_first: r.11, type_order: r.11,
folder_first: r.12,
}) })
.collect()) .collect())
} }
@@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
fld.created_at AS resource_created_at, fld.created_at AS resource_created_at,
fld.updated_at AS modified_at, fld.updated_at AS modified_at,
fld.user_id AS owner_id, fld.user_id AS owner_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner, (fld.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at, ur.accessed_at AS accessed_at,
fld.path::text AS resource_path, fld.path::text AS resource_path,
@@ -239,6 +240,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.created_at AS resource_created_at, f.created_at AS resource_created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id AS owner_id, f.user_id AS owner_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner, (f.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at, ur.accessed_at AS accessed_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, 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"), resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"), modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"), owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false), is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"), accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(), path: row.try_get("resource_path").ok(),
@@ -261,6 +261,7 @@ impl TrashDbRepository {
fld.created_at AS resource_created_at, fld.created_at AS resource_created_at,
fld.updated_at AS modified_at, fld.updated_at AS modified_at,
fld.user_id AS owner_id, fld.user_id AS owner_id,
NULL::text AS blob_hash,
fld.trashed_at AS trashed_at, fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, (fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path, fld.path::text AS resource_path,
@@ -286,6 +287,7 @@ impl TrashDbRepository {
f.created_at AS resource_created_at, f.created_at AS resource_created_at,
f.updated_at AS modified_at, f.updated_at AS modified_at,
f.user_id AS owner_id, f.user_id AS owner_id,
f.blob_hash,
f.trashed_at AS trashed_at, f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, (f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, 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"), resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"), modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"), owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
trashed_at, trashed_at,
deletion_date, deletion_date,
path: row.try_get("resource_path").ok(), path: row.try_get("resource_path").ok(),
@@ -20,6 +20,7 @@ use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::services::favorites_service::FavoritesService; use crate::application::services::favorites_service::FavoritesService;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
@@ -279,12 +280,18 @@ pub async fn list_favorites_resources(
.as_deref() .as_deref()
.unwrap_or("application/octet-stream"); .unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64; let size_bytes = row.size.max(0) as u64;
// The favorites list query doesn't select // Route ETag through `File::compute_etag` so
// `blob_hash` — favorites UI displays metadata // this listing's `etag` byte-equals what
// only and doesn't trigger ETag-conditional // GET/HEAD/PROPFIND would return for the same
// requests against these rows. If a caller // file. `blob_hash` is `None` only for
// ever needs the content hash here, widen the // folder rows, which take the other branch.
// favorites SQL. 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 { let dto = FileDto {
id: row.resource_id.to_string(), id: row.resource_id.to_string(),
name: row.name.clone(), name: row.name.clone(),
@@ -293,7 +300,7 @@ pub async fn list_favorites_resources(
mime_type: std::sync::Arc::from(mime), mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()), folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64, 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_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for( icon_special_class: std::sync::Arc::from(icon_special_class_for(
&row.name, mime, &row.name, mime,
@@ -302,8 +309,8 @@ pub async fn list_favorites_resources(
size_formatted: format_file_size(size_bytes), size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
sort_date: None, sort_date: None,
content_hash: String::new(), content_hash,
etag: String::new(), etag,
}; };
FavoritesResourceItemDto { FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
+17 -10
View File
@@ -26,6 +26,7 @@ use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::folder_service::FolderService; use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState; use crate::common::di::AppState as GlobalAppState;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
@@ -717,14 +718,20 @@ pub async fn list_folder_resources(
.as_deref() .as_deref()
.unwrap_or("application/octet-stream"); .unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64; let size_bytes = row.size.max(0) as u64;
// `FolderResourceRow` (the UNION ALL row used // `blob_hash` is `Some(_)` for file rows in the
// by this listing) doesn't carry `blob_hash`, // UNION ALL (`NULL` for folders). Route the
// so neither `content_hash` nor `etag` can be // ETag formula through `File::compute_etag` —
// populated here without widening the SQL. The // the single source of truth shared with
// REST file-listing UI doesn't issue // GET/HEAD/PROPFIND/PUT response — so this
// conditional requests against these rows — // listing's `etag` byte-equals what a
// file download / WebDAV PROPFIND go through // conditional request would compare against.
// paths that DO carry the hash. 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 { let dto = FileDto {
id: row.id.to_string(), id: row.id.to_string(),
name: row.name.clone(), name: row.name.clone(),
@@ -740,8 +747,8 @@ pub async fn list_folder_resources(
size_formatted: format_file_size(size_bytes), size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
sort_date: None, sort_date: None,
content_hash: String::new(), content_hash,
etag: String::new(), etag,
}; };
FolderResourceItemDto { FolderResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
+14 -5
View File
@@ -19,6 +19,7 @@ use crate::application::dtos::recent_dto::{
}; };
use crate::application::ports::recent_ports::RecentItemsUseCase; use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::services::recent_service::RecentService; use crate::application::services::recent_service::RecentService;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid; use uuid::Uuid;
@@ -309,8 +310,16 @@ pub async fn list_recent_resources(
.as_deref() .as_deref()
.unwrap_or("application/octet-stream"); .unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64; let size_bytes = row.size.max(0) as u64;
// Recents listing row doesn't carry blob_hash // Route ETag through `File::compute_etag` so this
// (same reason as folder_handler / favorites). // 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 { let dto = FileDto {
id: row.resource_id.to_string(), id: row.resource_id.to_string(),
name: row.name.clone(), name: row.name.clone(),
@@ -319,7 +328,7 @@ pub async fn list_recent_resources(
mime_type: std::sync::Arc::from(mime), mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()), folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64, 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_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for( icon_special_class: std::sync::Arc::from(icon_special_class_for(
&row.name, mime, &row.name, mime,
@@ -328,8 +337,8 @@ pub async fn list_recent_resources(
size_formatted: format_file_size(size_bytes), size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()), owner_id: Some(row.owner_id.to_string()),
sort_date: None, sort_date: None,
content_hash: String::new(), content_hash,
etag: String::new(), etag,
}; };
RecentResourceItemDto { RecentResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
+13 -5
View File
@@ -21,6 +21,7 @@ use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase; use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::middleware::auth::CurrentUser;
use crate::interfaces::nextcloud::webdav_handler::{ use crate::interfaces::nextcloud::webdav_handler::{
@@ -250,9 +251,16 @@ async fn handle_search(
/// Build a `FileDto` from a search file result. /// Build a `FileDto` from a search file result.
fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto { fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto {
// `SearchFileResultDto` doesn't carry `blob_hash`; the SEARCH / // Route ETag through `File::compute_etag` so REPORT/SEARCH hits
// REPORT XML emitter doesn't read `content_hash` or `etag` off // emit the same opaque token NC's sync client cached from the
// these DTOs anyway, so leaving them empty here is correct. // 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 { FileDto {
id: fr.id.clone(), id: fr.id.clone(),
name: fr.name.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), size_formatted: format_file_size(fr.size),
owner_id: None, owner_id: None,
sort_date: None, sort_date: None,
content_hash: String::new(), content_hash: fr.blob_hash.clone(),
etag: String::new(), etag,
} }
} }