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 {
+104 -16
View File
@@ -21,7 +21,8 @@ pub struct FileParts {
pub created_at: u64,
pub modified_at: u64,
pub owner_id: Option<Uuid>,
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<Uuid>,
/// 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<Uuid>,
) -> FileResult<Self> {
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<Uuid>,
etag: String,
blob_hash: String,
) -> FileResult<Self> {
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");
}
}
+23
View File
@@ -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(
@@ -131,11 +131,11 @@ impl FileBlobReadRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
etag: String,
blob_hash: String,
owner_id: Option<Uuid>,
) -> Result<File, DomainError> {
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::<Result<Vec<_>, _>>()
@@ -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::<Result<Vec<_>, _>>()
@@ -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()
}
}
@@ -101,10 +101,10 @@ impl FileBlobWriteRepository {
created_at: i64,
modified_at: i64,
owner_id: Option<Uuid>,
etag: String,
blob_hash: String,
) -> Result<File, DomainError> {
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}")))
}
@@ -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(),
}))
}
@@ -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 {
+12 -1
View File
@@ -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 {
@@ -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 {
@@ -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::<Utc>::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::<Utc>::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::<Utc>::from_timestamp(file.created_at as i64, 0)
@@ -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(),
+22 -5
View File
@@ -348,11 +348,18 @@ async fn handle_get(
chrono::DateTime::<Utc>::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<File>` 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::<Utc>::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<W: std::io::Write>(
.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())?;
+3
View File
@@ -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
*/
/**
+5 -2
View File
@@ -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: ''
})
);
}
+5 -2
View File
@@ -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: ''
})
);
}
@@ -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: ''
})
);
}
+5 -2
View File
@@ -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: ''
})
);
}