refactor(file|folder): separate etag and blob_hash
This commit is contained in:
@@ -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")))?;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user