refactor(file|folder): separate etag and blob_hash

This commit is contained in:
Edouard Vanbelle
2026-06-06 15:37:27 +02:00
parent 6aee68b34c
commit 0135930da9
20 changed files with 307 additions and 77 deletions
+10 -6
View File
@@ -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")))?;
}
+24 -4
View File
@@ -62,14 +62,32 @@ pub struct FileDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_date: Option<u64>,
/// 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<File> 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<File> 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,
}
+10
View File
@@ -73,11 +73,19 @@ pub struct FolderDto {
/// Human-readable category (always "Folder")
#[schema(value_type = String)]
pub category: Arc<str>,
/// 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<Folder> 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<Folder> 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(),
}
}
}
+6 -1
View File
@@ -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 {