diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index da4f1ccb..24dbde25 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -444,9 +444,11 @@ impl WebDavAdapter { xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - // Other standard properties + // ETag — routes through `FolderDto::etag` (= `Folder::etag()`) + // so every WebDAV emitter and HEAD response agree on a single + // value for the same folder. xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.id))))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.etag))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; // Content length (0 for directories) @@ -505,9 +507,11 @@ impl WebDavAdapter { xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; - // ETag + // ETag — routes through `FileDto::etag` (= `File::etag()`) so + // PROPFIND, GET, HEAD, PUT-response, and MOVE all emit + // byte-identical values for the same file. xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; - xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.id))))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.etag))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; Ok(()) @@ -589,7 +593,7 @@ impl WebDavAdapter { xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "\"{}\"", - folder.id + folder.etag ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; } @@ -685,7 +689,7 @@ impl WebDavAdapter { xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; xml_writer.write_event(Event::Text(BytesText::new(&format!( "\"{}\"", - file.id + file.etag ))))?; xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; } diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index bc6af74c..78569529 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -62,14 +62,32 @@ pub struct FileDto { #[serde(skip_serializing_if = "Option::is_none")] pub sort_date: Option, - /// Content-addressable ETag (= blob_hash). Changes on every content write. - /// Used for WebDAV/Nextcloud ETag headers. Omitted from REST API JSON. - #[serde(skip)] + /// Raw BLAKE3 content hash. Populated from `File::content_hash()`. + /// Exposed in REST JSON so API consumers can use it for + /// content-addressable URLs, dedup verification, and integrity + /// audits. Distinct from `etag` (which is an HTTP-only cache + /// token whose formula may grow to include `modified_at` etc.). + pub content_hash: String, + + /// Opaque HTTP ETag. Populated from `File::etag()`. Used by + /// WebDAV/NextCloud handlers when emitting `ETag` headers and + /// also exposed in REST JSON so frontends can pass it back + /// through `If-Match` / `If-None-Match` on download / mutation + /// endpoints without a separate HEAD round-trip. pub etag: String, } impl From for FileDto { fn from(file: File) -> Self { + // Compute the HTTP ETag BEFORE consuming the entity — once + // `File::etag()` folds modified_at (and possibly more) into + // the formula, this becomes more than a string clone and + // must run against a live entity, not against + // already-extracted parts. content_hash is just the blob + // hash; etag is the cache token derived from it. + let etag = file.etag().to_string(); + let content_hash = file.content_hash().to_string(); + // Consume the entity by moving all fields — zero heap allocations // for id, name, path, folder_id, owner_id (previously 5× .to_string()). let parts = file.into_parts(); @@ -95,7 +113,8 @@ impl From for FileDto { size_formatted, owner_id: parts.owner_id.map(|u| u.to_string()), sort_date: None, - etag: parts.etag, + content_hash, + etag, } } } @@ -150,6 +169,7 @@ impl FileDto { category: Arc::from("Document"), size_formatted: "0 Bytes".to_string(), owner_id: None, + content_hash: String::new(), etag: String::new(), sort_date: None, } diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index e411fab6..8a671e66 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -73,11 +73,19 @@ pub struct FolderDto { /// Human-readable category (always "Folder") #[schema(value_type = String)] pub category: Arc, + + /// Opaque ETag for HTTP responses. Populated from `Folder::etag()` + /// at conversion time so every WebDAV / NextCloud handler emits + /// the same value, and exposed in REST JSON so the frontend can + /// pass it back through `If-Match` on rename / move endpoints + /// without a separate HEAD round-trip. + pub etag: String, } impl From for FolderDto { fn from(folder: Folder) -> Self { let is_root = folder.parent_id().is_none(); + let etag = folder.etag().to_string(); Self { id: folder.id().to_string(), @@ -91,6 +99,7 @@ impl From for FolderDto { icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + etag, } } } @@ -141,6 +150,7 @@ impl FolderDto { icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + etag: String::new(), } } } diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 073aae40..5466d8fc 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -809,8 +809,10 @@ fn build_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) -> fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { let path = row.path.clone().unwrap_or_default(); if row.resource_type == "folder" { + let resource_id = row.resource_id.to_string(); let dto = FolderDto { - id: row.resource_id.to_string(), + etag: resource_id.clone(), + id: resource_id, name: row.name.clone(), path, parent_id: row.parent_id.map(|u| u.to_string()), @@ -834,6 +836,8 @@ 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. let dto = FileDto { id: row.resource_id.to_string(), name: row.name.clone(), @@ -849,6 +853,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { 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(), }; TrashResourceItemDto { diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 57728d4d..acd5c036 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -21,7 +21,8 @@ pub struct FileParts { pub created_at: u64, pub modified_at: u64, pub owner_id: Option, - pub etag: String, + /// BLAKE3 content hash. See [`File::content_hash`] for semantics. + pub blob_hash: String, } /** @@ -66,8 +67,14 @@ pub struct File { /// Owner user ID (from storage.files.user_id) owner_id: Option, - /// Content-addressable ETag (= blob_hash). Changes on every content write. - etag: String, + /// BLAKE3 content hash. Stable across renames/moves, changes only + /// when the file's content bytes change. Source of truth for both + /// content-addressable storage and the HTTP ETag (via + /// [`File::etag`]). Exposed publicly via [`File::content_hash`] + /// so the REST API can surface it as a distinct concept from the + /// ETag (the ETag formula may grow to include `modified_at` etc., + /// but `content_hash` remains the raw hash). + blob_hash: String, } // We no longer need this module, now we use a String directly @@ -85,7 +92,7 @@ impl Default for File { created_at: 0, modified_at: 0, owner_id: None, - etag: String::new(), + blob_hash: String::new(), } } } @@ -123,7 +130,7 @@ impl File { created_at: now, modified_at: now, owner_id: None, - etag: String::new(), + blob_hash: String::new(), }) } @@ -154,7 +161,7 @@ impl File { created_at, modified_at, owner_id: None, - etag: String::new(), + blob_hash: String::new(), }) } @@ -170,7 +177,7 @@ impl File { modified_at: u64, owner_id: Option, ) -> FileResult { - Self::with_timestamps_and_etag( + Self::with_timestamps_and_blob_hash( id, name, storage_path, @@ -185,7 +192,7 @@ impl File { } #[allow(clippy::too_many_arguments)] - pub fn with_timestamps_and_etag( + pub fn with_timestamps_and_blob_hash( id: String, name: String, storage_path: StoragePath, @@ -195,7 +202,7 @@ impl File { created_at: u64, modified_at: u64, owner_id: Option, - etag: String, + blob_hash: String, ) -> FileResult { if let Err(reason) = validate_storage_name(&name) { return Err(FileError::InvalidFileName(format!("{name}: {reason}"))); @@ -215,7 +222,7 @@ impl File { created_at, modified_at, owner_id, - etag, + blob_hash, }) } @@ -235,12 +242,44 @@ impl File { created_at: self.created_at, modified_at: self.modified_at, owner_id: self.owner_id, - etag: self.etag, + blob_hash: self.blob_hash, } } + /// Raw BLAKE3 content hash — the cryptographic identity of the + /// file's bytes. Stable across renames, moves, and metadata + /// updates. Changes only when the underlying content changes. + /// + /// This is **distinct from [`File::etag`]**: the ETag is an HTTP + /// cache token that may incorporate non-content signals (mtime, + /// permissions, …) in future revisions; `content_hash` is the + /// raw hash, suitable for content-addressable URLs, dedup + /// verification, and integrity audits. Keep both accessible — + /// the API layer can choose to expose `content_hash` even when + /// `etag` grows additional inputs. + pub fn content_hash(&self) -> &str { + &self.blob_hash + } + + /// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap + /// in `"…"` themselves at the HTTP boundary. + /// + /// **Current formula**: equal to [`File::content_hash`] — + /// content-addressable BLAKE3. Stable across renames, changes on + /// every content write. 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. + /// + /// A follow-up PR will fold `modified_at` into the formula + /// (`{blob_hash[..16]}-{modified_at}`) so a `x-oc-mtime`-only + /// update (NextCloud preserves client mtime) invalidates client + /// caches even when the content bytes are unchanged. At that + /// point `etag()` and `content_hash()` diverge — that is the + /// reason they are exposed as two separate methods today. pub fn etag(&self) -> &str { - &self.etag + self.content_hash() } // Getters @@ -310,7 +349,7 @@ impl File { created_at, modified_at, owner_id: None, - etag: String::new(), + blob_hash: String::new(), } } @@ -348,7 +387,7 @@ impl File { created_at: self.created_at, modified_at: now, owner_id: self.owner_id, - etag: self.etag.clone(), + blob_hash: self.blob_hash.clone(), }) } @@ -383,7 +422,7 @@ impl File { created_at: self.created_at, modified_at: now, owner_id: self.owner_id, - etag: self.etag.clone(), + blob_hash: self.blob_hash.clone(), }) } @@ -405,7 +444,7 @@ impl File { created_at: self.created_at, modified_at: now, owner_id: self.owner_id, - etag: self.etag.clone(), + blob_hash: self.blob_hash.clone(), } } } @@ -467,4 +506,53 @@ mod tests { assert_eq!(renamed.name(), "newname.txt"); assert_eq!(renamed.id(), "123"); // The ID does not change } + + /// Today `etag()` and `content_hash()` return the same string + /// (the v1 formula is identity); the test exists to catch a + /// future change that accidentally lets them disagree when they + /// should match. PR 2 will deliberately make them diverge. + #[test] + fn test_etag_currently_equals_content_hash() { + let file = File::with_timestamps_and_blob_hash( + "id-1".to_string(), + "file.txt".to_string(), + StoragePath::from_string("/file.txt"), + 42, + "text/plain".to_string(), + None, + 1_000, + 2_000, + None, + "abcdef0123456789".to_string(), + ) + .unwrap(); + + assert_eq!(file.content_hash(), "abcdef0123456789"); + assert_eq!(file.etag(), file.content_hash()); + } + + /// Renames must NOT change the content identity. Both `etag()` + /// and `content_hash()` are derived from `blob_hash`, which is + /// preserved across `with_name`. If a future refactor drops + /// `blob_hash` from the rename builder, this test catches it. + #[test] + fn test_etag_stable_across_rename() { + let file = File::with_timestamps_and_blob_hash( + "id-1".to_string(), + "file.txt".to_string(), + StoragePath::from_string("/file.txt"), + 42, + "text/plain".to_string(), + None, + 1_000, + 2_000, + None, + "stable-hash".to_string(), + ) + .unwrap(); + + let renamed = file.with_name("renamed.txt".to_string()).unwrap(); + assert_eq!(renamed.etag(), "stable-hash"); + assert_eq!(renamed.content_hash(), "stable-hash"); + } } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index d4ebe33c..68311240 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -178,6 +178,29 @@ impl Folder { self.owner_id } + /// Opaque ETag string (raw, NOT HTTP-quoted). Handlers wrap in + /// `"…"` themselves at the HTTP boundary. + /// + /// **Current formula**: the folder's UUID — stable for the life of + /// the row, does NOT change when descendants are added/modified/ + /// deleted. This matches today's behaviour in every existing + /// folder ETag emission site and is the de-facto v1 contract. + /// + /// **Known limitation**: NextCloud's sync engine relies on a + /// collection's ETag changing whenever any descendant changes — + /// that's the signal it uses to decide "recurse into this folder + /// to find what's new". A constant ETag breaks NC's incremental + /// sync (forces periodic deep recrawl). + /// + /// A follow-up PR will introduce `storage.folders.tree_modified_at` + /// (bumped by trigger on any descendant write) and switch this + /// method to `format!("{}-{}", id_short, tree_modified_at)`. That + /// PR will be ETag-breaking — all clients re-walk once — so it's + /// kept separate from this refactor. + pub fn etag(&self) -> &str { + &self.id + } + /// Creates a new Folder instance from a DTO /// This function is primarily for conversions in batch handlers pub fn from_dto( diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 8bb55f53..af86f5fb 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -131,11 +131,11 @@ impl FileBlobReadRepository { mime_type: String, created_at: i64, modified_at: i64, - etag: String, + blob_hash: String, owner_id: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_and_etag( + File::with_timestamps_and_blob_hash( id, name, storage_path, @@ -145,7 +145,7 @@ impl FileBlobReadRepository { created_at as u64, modified_at as u64, owner_id, - etag, + blob_hash, ) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}"))) } @@ -226,9 +226,9 @@ impl FileBlobReadRepository { let mut files = Vec::with_capacity(rows.len()); let mut sort_dates = Vec::with_capacity(rows.len()); - for (id, name, fid, fpath, size, mime, ca, ma, etag, uid, sd) in rows { + for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows { files.push(Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, etag, uid, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, )?); sort_dates.push(sd); } @@ -410,9 +410,11 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) - }) + .map( + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + }, + ) .collect() } @@ -466,9 +468,11 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?; rows.into_iter() - .map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) - }) + .map( + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + }, + ) .collect() } @@ -532,9 +536,11 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_batch: {e}")))?; rows.into_iter() - .map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) - }) + .map( + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + }, + ) .collect() } @@ -598,9 +604,11 @@ impl FileReadPort for FileBlobReadRepository { })?; rows.into_iter() - .map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) - }) + .map( + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + }, + ) .collect() } @@ -815,9 +823,9 @@ impl FileReadPort for FileBlobReadRepository { while let Some(row) = row_stream.try_next().await.map_err(|e| { DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}")) })? { - let (id, name, fid, fpath, size, mime, ca, ma, etag, uid) = row; + let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) = row; let file = FileBlobReadRepository::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, etag, uid, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, )?; yield file; } @@ -930,8 +938,8 @@ impl FileReadPort for FileBlobReadRepository { let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, etag, uid, _total)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) }, ) .collect::, _>>() @@ -1116,8 +1124,8 @@ impl FileReadPort for FileBlobReadRepository { let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, etag, uid, _total)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) }, ) .collect::, _>>() @@ -1212,9 +1220,11 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("suggest: {e}")))?; rows.into_iter() - .map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid) - }) + .map( + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + }, + ) .collect() } } diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 3bc1a9bd..7819f939 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -101,10 +101,10 @@ impl FileBlobWriteRepository { created_at: i64, modified_at: i64, owner_id: Option, - etag: String, + blob_hash: String, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_and_etag( + File::with_timestamps_and_blob_hash( id, name, storage_path, @@ -114,7 +114,7 @@ impl FileBlobWriteRepository { created_at as u64, modified_at as u64, owner_id, - etag, + blob_hash, ) .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}"))) } diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index 291db682..ea187db5 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -145,6 +145,7 @@ impl PathResolverService { match resource_type.as_str() { "folder" => Ok(ResolvedResource::Folder(FolderDto { + etag: id.clone(), id, name: name.clone(), path: res_path, @@ -160,6 +161,11 @@ impl PathResolverService { _ => { let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); let sz = size.unwrap_or(0) as u64; + // `content_hash`/`etag` are empty here: this resolver + // path doesn't select `blob_hash` from SQL — callers + // are doing existence/type discrimination, not ETag + // emission. If a caller ever needs an ETag from this + // codepath, widen the SELECT and populate properly. Ok(ResolvedResource::File(FileDto { id, name: name.clone(), @@ -175,6 +181,7 @@ impl PathResolverService { size_formatted: format_file_size(sz), owner_id: uid, sort_date: None, + content_hash: String::new(), etag: String::new(), })) } diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 05d21264..2d5dcbfe 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -253,8 +253,10 @@ pub async fn list_favorites_resources( }; if row.resource_type == "folder" { + let resource_id = row.resource_id.to_string(); let dto = FolderDto { - id: row.resource_id.to_string(), + etag: resource_id.clone(), + id: resource_id, name: row.name.clone(), path, parent_id: row.parent_id.map(|u| u.to_string()), @@ -277,6 +279,12 @@ 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. let dto = FileDto { id: row.resource_id.to_string(), name: row.name.clone(), @@ -294,6 +302,7 @@ 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(), }; FavoritesResourceItemDto { diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 69775bdb..41cf5803 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -692,8 +692,10 @@ pub async fn list_folder_resources( .into_iter() .map(|row| { if row.resource_type == "folder" { + let resource_id = row.id.to_string(); let dto = FolderDto { - id: row.id.to_string(), + etag: resource_id.clone(), + id: resource_id, name: row.name.clone(), path: String::new(), // cleared — share recipients must not see hierarchy parent_id: row.parent_id.map(|u| u.to_string()), @@ -715,6 +717,14 @@ 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. let dto = FileDto { id: row.id.to_string(), name: row.name.clone(), @@ -730,6 +740,7 @@ 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(), }; FolderResourceItemDto { diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index fe6d978a..404311b6 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -283,8 +283,10 @@ pub async fn list_recent_resources( }; if row.resource_type == "folder" { + let resource_id = row.resource_id.to_string(); let dto = FolderDto { - id: row.resource_id.to_string(), + etag: resource_id.clone(), + id: resource_id, name: row.name.clone(), path, parent_id: row.parent_id.map(|u| u.to_string()), @@ -307,6 +309,8 @@ 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). let dto = FileDto { id: row.resource_id.to_string(), name: row.name.clone(), @@ -324,6 +328,7 @@ 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(), }; RecentResourceItemDto { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 3a61d8ba..b4b93ada 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -353,6 +353,7 @@ async fn handle_propfind( // Root folder let root_folder = FolderDto { id: "root".to_string(), + etag: "root".to_string(), name: "".to_string(), path: "".to_string(), parent_id: None, @@ -706,7 +707,7 @@ async fn handle_get( .status(StatusCode::OK) .header(header::CONTENT_TYPE, &*file.mime_type) .header(header::CONTENT_LENGTH, file.size) - .header(header::ETAG, format!("\"{}\"", file.id)) + .header(header::ETAG, format!("\"{}\"", file.etag)) .header( header::LAST_MODIFIED, chrono::DateTime::::from_timestamp(file.created_at as i64, 0) @@ -747,7 +748,7 @@ async fn handle_head( .status(StatusCode::OK) .header(header::CONTENT_TYPE, "httpd/unix-directory") .header(header::CONTENT_LENGTH, 0) - .header(header::ETAG, format!("\"{}\"", folder.id)) + .header(header::ETAG, format!("\"{}\"", folder.etag)) .body(Body::empty()) .unwrap()); } @@ -756,7 +757,7 @@ async fn handle_head( .status(StatusCode::OK) .header(header::CONTENT_TYPE, &*file.mime_type) .header(header::CONTENT_LENGTH, file.size) - .header(header::ETAG, format!("\"{}\"", file.id)) + .header(header::ETAG, format!("\"{}\"", file.etag)) .header( header::LAST_MODIFIED, chrono::DateTime::::from_timestamp(file.created_at as i64, 0) @@ -777,7 +778,7 @@ async fn handle_head( .status(StatusCode::OK) .header(header::CONTENT_TYPE, "httpd/unix-directory") .header(header::CONTENT_LENGTH, 0) - .header(header::ETAG, format!("\"{}\"", folder.id)) + .header(header::ETAG, format!("\"{}\"", folder.etag)) .body(Body::empty()) .unwrap()); } @@ -793,7 +794,7 @@ async fn handle_head( .status(StatusCode::OK) .header(header::CONTENT_TYPE, &*file.mime_type) .header(header::CONTENT_LENGTH, file.size) - .header(header::ETAG, format!("\"{}\"", file.id)) + .header(header::ETAG, format!("\"{}\"", file.etag)) .header( header::LAST_MODIFIED, chrono::DateTime::::from_timestamp(file.created_at as i64, 0) diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 03311557..704ee845 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -250,6 +250,9 @@ 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. FileDto { id: fr.id.clone(), name: fr.name.clone(), @@ -267,6 +270,7 @@ 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(), } } @@ -276,6 +280,7 @@ fn folder_dto_from_search( sr: &crate::application::dtos::search_dto::SearchFolderResultDto, ) -> FolderDto { FolderDto { + etag: sr.id.clone(), id: sr.id.clone(), name: sr.name.clone(), path: sr.path.clone(), diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 4311068f..752343fb 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -348,11 +348,18 @@ async fn handle_get( chrono::DateTime::::from_timestamp(timestamp_to_i64(file.modified_at), 0) .unwrap_or_else(Utc::now); + // ETag comes from `FileDto::etag` (populated from `File::etag()` + // in the `From` impl) — single source of truth, so GET, + // HEAD, PUT-response, MOVE, and PROPFIND all emit byte-identical + // values for the same file. NC's sync engine compares cached + // PROPFIND ETags against GET/HEAD responses; using `file.id` here + // (a UUID) while PROPFIND emitted the blob hash made NC see + // every file as "remotely changed" on first descent. Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, file.mime_type.as_ref()) .header(header::CONTENT_LENGTH, file.size) - .header(header::ETAG, format!("\"{}\"", file.id)) + .header(header::ETAG, format!("\"{}\"", file.etag)) .header(header::LAST_MODIFIED, modified_at.to_rfc2822()) .body(Body::from_stream(std::pin::Pin::from(stream))) .unwrap()) @@ -400,11 +407,14 @@ async fn handle_head( chrono::DateTime::::from_timestamp(timestamp_to_i64(file.modified_at), 0) .unwrap_or_else(Utc::now); + // ETag comes from `FileDto::etag` — see the same comment block on + // the GET handler. HEAD and GET must agree byte-for-byte; pulling + // both from the same DTO field guarantees that. Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, file.mime_type.as_ref()) .header(header::CONTENT_LENGTH, file.size) - .header(header::ETAG, format!("\"{}\"", file.id)) + .header(header::ETAG, format!("\"{}\"", file.etag)) .header(header::LAST_MODIFIED, modified_at.to_rfc2822()) .body(Body::empty()) .unwrap()) @@ -850,9 +860,13 @@ async fn handle_move( let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?; let mut builder = Response::builder().status(StatusCode::CREATED); if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await { + // Route through `FileDto::etag` so the MOVE response + // matches what a subsequent PROPFIND on the destination + // will return — `moved.id` (UUID) would differ from the + // blob hash and trigger NC's "remote changed" detection. builder = builder - .header(header::ETAG, format!("\"{}\"", moved.id)) - .header("oc-etag", format!("\"{}\"", moved.id)); + .header(header::ETAG, format!("\"{}\"", moved.etag)) + .header("oc-etag", format!("\"{}\"", moved.etag)); } return Ok(builder.body(Body::empty()).unwrap()); @@ -1094,7 +1108,10 @@ pub fn write_folder_response( .unwrap_or_else(Utc::now); write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?; - write_text_element(xml, "d:getetag", &format!("\"{}\"", folder.id))?; + // Route through `FolderDto::etag` (= `Folder::etag()`, currently + // the folder UUID — see the entity for the documented v1 formula + // and the follow-up plan to make it descendant-aware). + write_text_element(xml, "d:getetag", &format!("\"{}\"", folder.etag))?; write_text_element(xml, "d:getcontenttype", "httpd/unix-directory")?; write_text_element(xml, "d:getcontentlength", "0")?; write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?; diff --git a/static/js/core/types.js b/static/js/core/types.js index 4c16fca2..99ba6f02 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -25,6 +25,7 @@ * @property {string} owner_id * @property {string|null} parent_id the folder parent (null if is_root) * @property {string} path the full path + * @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match */ //FIXME: rename into FileItem @@ -44,6 +45,8 @@ * @property {number} size * @property {string} size_formatted * @property {number} sort_date + * @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match + * @property {string} content_hash raw BLAKE3 content hash, for dedup checks */ /** diff --git a/static/js/views/favorites/favoritesView.js b/static/js/views/favorites/favoritesView.js index 79d82b0a..4a81cc90 100644 --- a/static/js/views/favorites/favoritesView.js +++ b/static/js/views/favorites/favoritesView.js @@ -377,7 +377,8 @@ const favoritesView = { sort_date: toSecs(item.favorited_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: 'Folder' + category: 'Folder', + etag: '' }) ); } else if (item.resource_type === 'file') { @@ -397,7 +398,9 @@ const favoritesView = { sort_date: toSecs(item.favorited_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: f.category + category: f.category, + etag: '', + content_hash: '' }) ); } diff --git a/static/js/views/recent/recentView.js b/static/js/views/recent/recentView.js index 44fa0446..62fcdcb1 100644 --- a/static/js/views/recent/recentView.js +++ b/static/js/views/recent/recentView.js @@ -384,7 +384,8 @@ const recentView = { sort_date: toSecs(item.accessed_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: 'Folder' + category: 'Folder', + etag: '' }) ); } else if (item.resource_type === 'file') { @@ -404,7 +405,9 @@ const recentView = { sort_date: toSecs(item.accessed_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: f.category + category: f.category, + etag: '', + content_hash: '' }) ); } diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 6bdacdcc..05fde699 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -386,7 +386,8 @@ const sharedWithMeView = { sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: 'folder' + category: 'folder', + etag: '' }) ); } else if (item.resource_type === 'file') { @@ -406,7 +407,9 @@ const sharedWithMeView = { sort_date: grantedAtSecs(item.granted_at), icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: f.category + category: f.category, + etag: '', + content_hash: '' }) ); } diff --git a/static/js/views/trash/trashView.js b/static/js/views/trash/trashView.js index 476843b3..0a6b430d 100644 --- a/static/js/views/trash/trashView.js +++ b/static/js/views/trash/trashView.js @@ -393,7 +393,8 @@ const trashView = { deletion_date: item.deletion_date, icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: 'Folder' + category: 'Folder', + etag: '' }) ); } else if (item.resource_type === 'file') { @@ -417,7 +418,9 @@ const trashView = { deletion_date: item.deletion_date, icon_class: f.icon_class, icon_special_class: f.icon_special_class ?? '', - category: f.category + category: f.category, + etag: '', + content_hash: '' }) ); }