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 {
|
||||
|
||||
+104
-16
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user