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
@@ -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())?;