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::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
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::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")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||||
|
|
||||||
// Content length (0 for directories)
|
// 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::Text(BytesText::new(&modified_at.to_rfc2822())))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
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::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")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -589,7 +593,7 @@ impl WebDavAdapter {
|
|||||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"\"{}\"",
|
"\"{}\"",
|
||||||
folder.id
|
folder.etag
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
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::Start(BytesStart::new("D:getetag")))?;
|
||||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||||
"\"{}\"",
|
"\"{}\"",
|
||||||
file.id
|
file.etag
|
||||||
))))?;
|
))))?;
|
||||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,32 @@ pub struct FileDto {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub sort_date: Option<u64>,
|
pub sort_date: Option<u64>,
|
||||||
|
|
||||||
/// Content-addressable ETag (= blob_hash). Changes on every content write.
|
/// Raw BLAKE3 content hash. Populated from `File::content_hash()`.
|
||||||
/// Used for WebDAV/Nextcloud ETag headers. Omitted from REST API JSON.
|
/// Exposed in REST JSON so API consumers can use it for
|
||||||
#[serde(skip)]
|
/// 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,
|
pub etag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<File> for FileDto {
|
impl From<File> for FileDto {
|
||||||
fn from(file: File) -> Self {
|
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
|
// Consume the entity by moving all fields — zero heap allocations
|
||||||
// for id, name, path, folder_id, owner_id (previously 5× .to_string()).
|
// for id, name, path, folder_id, owner_id (previously 5× .to_string()).
|
||||||
let parts = file.into_parts();
|
let parts = file.into_parts();
|
||||||
@@ -95,7 +113,8 @@ impl From<File> for FileDto {
|
|||||||
size_formatted,
|
size_formatted,
|
||||||
owner_id: parts.owner_id.map(|u| u.to_string()),
|
owner_id: parts.owner_id.map(|u| u.to_string()),
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
etag: parts.etag,
|
content_hash,
|
||||||
|
etag,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +169,7 @@ impl FileDto {
|
|||||||
category: Arc::from("Document"),
|
category: Arc::from("Document"),
|
||||||
size_formatted: "0 Bytes".to_string(),
|
size_formatted: "0 Bytes".to_string(),
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,11 +73,19 @@ pub struct FolderDto {
|
|||||||
/// Human-readable category (always "Folder")
|
/// Human-readable category (always "Folder")
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub category: Arc<str>,
|
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 {
|
impl From<Folder> for FolderDto {
|
||||||
fn from(folder: Folder) -> Self {
|
fn from(folder: Folder) -> Self {
|
||||||
let is_root = folder.parent_id().is_none();
|
let is_root = folder.parent_id().is_none();
|
||||||
|
let etag = folder.etag().to_string();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: folder.id().to_string(),
|
id: folder.id().to_string(),
|
||||||
@@ -91,6 +99,7 @@ impl From<Folder> for FolderDto {
|
|||||||
icon_class: Arc::from("fas fa-folder"),
|
icon_class: Arc::from("fas fa-folder"),
|
||||||
icon_special_class: Arc::from("folder-icon"),
|
icon_special_class: Arc::from("folder-icon"),
|
||||||
category: Arc::from("Folder"),
|
category: Arc::from("Folder"),
|
||||||
|
etag,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +150,7 @@ impl FolderDto {
|
|||||||
icon_class: Arc::from("fas fa-folder"),
|
icon_class: Arc::from("fas fa-folder"),
|
||||||
icon_special_class: Arc::from("folder-icon"),
|
icon_special_class: Arc::from("folder-icon"),
|
||||||
category: Arc::from("Folder"),
|
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 {
|
fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||||
let path = row.path.clone().unwrap_or_default();
|
let path = row.path.clone().unwrap_or_default();
|
||||||
if row.resource_type == "folder" {
|
if row.resource_type == "folder" {
|
||||||
|
let resource_id = row.resource_id.to_string();
|
||||||
let dto = FolderDto {
|
let dto = FolderDto {
|
||||||
id: row.resource_id.to_string(),
|
etag: resource_id.clone(),
|
||||||
|
id: resource_id,
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
path,
|
path,
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
@@ -834,6 +836,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
let size_bytes = row.size.max(0) as u64;
|
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 {
|
let dto = FileDto {
|
||||||
id: row.resource_id.to_string(),
|
id: row.resource_id.to_string(),
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
@@ -849,6 +853,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
|||||||
size_formatted: format_file_size(size_bytes),
|
size_formatted: format_file_size(size_bytes),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
};
|
};
|
||||||
TrashResourceItemDto {
|
TrashResourceItemDto {
|
||||||
|
|||||||
+104
-16
@@ -21,7 +21,8 @@ pub struct FileParts {
|
|||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
pub modified_at: u64,
|
pub modified_at: u64,
|
||||||
pub owner_id: Option<Uuid>,
|
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 user ID (from storage.files.user_id)
|
||||||
owner_id: Option<Uuid>,
|
owner_id: Option<Uuid>,
|
||||||
|
|
||||||
/// Content-addressable ETag (= blob_hash). Changes on every content write.
|
/// BLAKE3 content hash. Stable across renames/moves, changes only
|
||||||
etag: String,
|
/// 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
|
// We no longer need this module, now we use a String directly
|
||||||
@@ -85,7 +92,7 @@ impl Default for File {
|
|||||||
created_at: 0,
|
created_at: 0,
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
etag: String::new(),
|
blob_hash: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +130,7 @@ impl File {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
etag: String::new(),
|
blob_hash: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +161,7 @@ impl File {
|
|||||||
created_at,
|
created_at,
|
||||||
modified_at,
|
modified_at,
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
etag: String::new(),
|
blob_hash: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +177,7 @@ impl File {
|
|||||||
modified_at: u64,
|
modified_at: u64,
|
||||||
owner_id: Option<Uuid>,
|
owner_id: Option<Uuid>,
|
||||||
) -> FileResult<Self> {
|
) -> FileResult<Self> {
|
||||||
Self::with_timestamps_and_etag(
|
Self::with_timestamps_and_blob_hash(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
storage_path,
|
storage_path,
|
||||||
@@ -185,7 +192,7 @@ impl File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn with_timestamps_and_etag(
|
pub fn with_timestamps_and_blob_hash(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
storage_path: StoragePath,
|
storage_path: StoragePath,
|
||||||
@@ -195,7 +202,7 @@ impl File {
|
|||||||
created_at: u64,
|
created_at: u64,
|
||||||
modified_at: u64,
|
modified_at: u64,
|
||||||
owner_id: Option<Uuid>,
|
owner_id: Option<Uuid>,
|
||||||
etag: String,
|
blob_hash: String,
|
||||||
) -> FileResult<Self> {
|
) -> FileResult<Self> {
|
||||||
if let Err(reason) = validate_storage_name(&name) {
|
if let Err(reason) = validate_storage_name(&name) {
|
||||||
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
||||||
@@ -215,7 +222,7 @@ impl File {
|
|||||||
created_at,
|
created_at,
|
||||||
modified_at,
|
modified_at,
|
||||||
owner_id,
|
owner_id,
|
||||||
etag,
|
blob_hash,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +242,44 @@ impl File {
|
|||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
modified_at: self.modified_at,
|
modified_at: self.modified_at,
|
||||||
owner_id: self.owner_id,
|
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 {
|
pub fn etag(&self) -> &str {
|
||||||
&self.etag
|
self.content_hash()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -310,7 +349,7 @@ impl File {
|
|||||||
created_at,
|
created_at,
|
||||||
modified_at,
|
modified_at,
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
etag: String::new(),
|
blob_hash: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +387,7 @@ impl File {
|
|||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
owner_id: self.owner_id,
|
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,
|
created_at: self.created_at,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
owner_id: self.owner_id,
|
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,
|
created_at: self.created_at,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
owner_id: self.owner_id,
|
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.name(), "newname.txt");
|
||||||
assert_eq!(renamed.id(), "123"); // The ID does not change
|
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
|
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
|
/// Creates a new Folder instance from a DTO
|
||||||
/// This function is primarily for conversions in batch handlers
|
/// This function is primarily for conversions in batch handlers
|
||||||
pub fn from_dto(
|
pub fn from_dto(
|
||||||
|
|||||||
@@ -131,11 +131,11 @@ impl FileBlobReadRepository {
|
|||||||
mime_type: String,
|
mime_type: String,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
modified_at: i64,
|
modified_at: i64,
|
||||||
etag: String,
|
blob_hash: String,
|
||||||
owner_id: Option<Uuid>,
|
owner_id: Option<Uuid>,
|
||||||
) -> Result<File, DomainError> {
|
) -> Result<File, DomainError> {
|
||||||
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||||
File::with_timestamps_and_etag(
|
File::with_timestamps_and_blob_hash(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
storage_path,
|
storage_path,
|
||||||
@@ -145,7 +145,7 @@ impl FileBlobReadRepository {
|
|||||||
created_at as u64,
|
created_at as u64,
|
||||||
modified_at as u64,
|
modified_at as u64,
|
||||||
owner_id,
|
owner_id,
|
||||||
etag,
|
blob_hash,
|
||||||
)
|
)
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
|
.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 files = Vec::with_capacity(rows.len());
|
||||||
let mut sort_dates = 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(
|
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);
|
sort_dates.push(sd);
|
||||||
}
|
}
|
||||||
@@ -410,9 +410,11 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
|
.map(
|
||||||
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)| {
|
||||||
})
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +468,11 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
|
.map(
|
||||||
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)| {
|
||||||
})
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,9 +536,11 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_batch: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_batch: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
|
.map(
|
||||||
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)| {
|
||||||
})
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,9 +604,11 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
|
.map(
|
||||||
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)| {
|
||||||
})
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,9 +823,9 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
while let Some(row) = row_stream.try_next().await.map_err(|e| {
|
while let Some(row) = row_stream.try_next().await.map_err(|e| {
|
||||||
DomainError::internal_error("FileBlobRead", format!("subtree stream: {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(
|
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;
|
yield file;
|
||||||
}
|
}
|
||||||
@@ -930,8 +938,8 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
let files = rows
|
let files = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(id, name, fid, fpath, size, mime, ca, ma, etag, uid, _total)| {
|
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
|
||||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
@@ -1116,8 +1124,8 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
let files = rows
|
let files = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(id, name, fid, fpath, size, mime, ca, ma, etag, uid, _total)| {
|
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
|
||||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
@@ -1212,9 +1220,11 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("suggest: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("suggest: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
|
.map(
|
||||||
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)| {
|
||||||
})
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ impl FileBlobWriteRepository {
|
|||||||
created_at: i64,
|
created_at: i64,
|
||||||
modified_at: i64,
|
modified_at: i64,
|
||||||
owner_id: Option<Uuid>,
|
owner_id: Option<Uuid>,
|
||||||
etag: String,
|
blob_hash: String,
|
||||||
) -> Result<File, DomainError> {
|
) -> Result<File, DomainError> {
|
||||||
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||||
File::with_timestamps_and_etag(
|
File::with_timestamps_and_blob_hash(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
storage_path,
|
storage_path,
|
||||||
@@ -114,7 +114,7 @@ impl FileBlobWriteRepository {
|
|||||||
created_at as u64,
|
created_at as u64,
|
||||||
modified_at as u64,
|
modified_at as u64,
|
||||||
owner_id,
|
owner_id,
|
||||||
etag,
|
blob_hash,
|
||||||
)
|
)
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ impl PathResolverService {
|
|||||||
|
|
||||||
match resource_type.as_str() {
|
match resource_type.as_str() {
|
||||||
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
||||||
|
etag: id.clone(),
|
||||||
id,
|
id,
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
path: res_path,
|
path: res_path,
|
||||||
@@ -160,6 +161,11 @@ impl PathResolverService {
|
|||||||
_ => {
|
_ => {
|
||||||
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
let sz = size.unwrap_or(0) as u64;
|
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 {
|
Ok(ResolvedResource::File(FileDto {
|
||||||
id,
|
id,
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -175,6 +181,7 @@ impl PathResolverService {
|
|||||||
size_formatted: format_file_size(sz),
|
size_formatted: format_file_size(sz),
|
||||||
owner_id: uid,
|
owner_id: uid,
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,8 +253,10 @@ pub async fn list_favorites_resources(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if row.resource_type == "folder" {
|
if row.resource_type == "folder" {
|
||||||
|
let resource_id = row.resource_id.to_string();
|
||||||
let dto = FolderDto {
|
let dto = FolderDto {
|
||||||
id: row.resource_id.to_string(),
|
etag: resource_id.clone(),
|
||||||
|
id: resource_id,
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
path,
|
path,
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
@@ -277,6 +279,12 @@ pub async fn list_favorites_resources(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
let size_bytes = row.size.max(0) as u64;
|
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 {
|
let dto = FileDto {
|
||||||
id: row.resource_id.to_string(),
|
id: row.resource_id.to_string(),
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
@@ -294,6 +302,7 @@ pub async fn list_favorites_resources(
|
|||||||
size_formatted: format_file_size(size_bytes),
|
size_formatted: format_file_size(size_bytes),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
};
|
};
|
||||||
FavoritesResourceItemDto {
|
FavoritesResourceItemDto {
|
||||||
|
|||||||
@@ -692,8 +692,10 @@ pub async fn list_folder_resources(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
if row.resource_type == "folder" {
|
if row.resource_type == "folder" {
|
||||||
|
let resource_id = row.id.to_string();
|
||||||
let dto = FolderDto {
|
let dto = FolderDto {
|
||||||
id: row.id.to_string(),
|
etag: resource_id.clone(),
|
||||||
|
id: resource_id,
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
path: String::new(), // cleared — share recipients must not see hierarchy
|
path: String::new(), // cleared — share recipients must not see hierarchy
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
@@ -715,6 +717,14 @@ pub async fn list_folder_resources(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
let size_bytes = row.size.max(0) as u64;
|
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 {
|
let dto = FileDto {
|
||||||
id: row.id.to_string(),
|
id: row.id.to_string(),
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
@@ -730,6 +740,7 @@ pub async fn list_folder_resources(
|
|||||||
size_formatted: format_file_size(size_bytes),
|
size_formatted: format_file_size(size_bytes),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
};
|
};
|
||||||
FolderResourceItemDto {
|
FolderResourceItemDto {
|
||||||
|
|||||||
@@ -283,8 +283,10 @@ pub async fn list_recent_resources(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if row.resource_type == "folder" {
|
if row.resource_type == "folder" {
|
||||||
|
let resource_id = row.resource_id.to_string();
|
||||||
let dto = FolderDto {
|
let dto = FolderDto {
|
||||||
id: row.resource_id.to_string(),
|
etag: resource_id.clone(),
|
||||||
|
id: resource_id,
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
path,
|
path,
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
@@ -307,6 +309,8 @@ pub async fn list_recent_resources(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
let size_bytes = row.size.max(0) as u64;
|
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 {
|
let dto = FileDto {
|
||||||
id: row.resource_id.to_string(),
|
id: row.resource_id.to_string(),
|
||||||
name: row.name.clone(),
|
name: row.name.clone(),
|
||||||
@@ -324,6 +328,7 @@ pub async fn list_recent_resources(
|
|||||||
size_formatted: format_file_size(size_bytes),
|
size_formatted: format_file_size(size_bytes),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
};
|
};
|
||||||
RecentResourceItemDto {
|
RecentResourceItemDto {
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ async fn handle_propfind(
|
|||||||
// Root folder
|
// Root folder
|
||||||
let root_folder = FolderDto {
|
let root_folder = FolderDto {
|
||||||
id: "root".to_string(),
|
id: "root".to_string(),
|
||||||
|
etag: "root".to_string(),
|
||||||
name: "".to_string(),
|
name: "".to_string(),
|
||||||
path: "".to_string(),
|
path: "".to_string(),
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
@@ -706,7 +707,7 @@ async fn handle_get(
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, &*file.mime_type)
|
.header(header::CONTENT_TYPE, &*file.mime_type)
|
||||||
.header(header::CONTENT_LENGTH, file.size)
|
.header(header::CONTENT_LENGTH, file.size)
|
||||||
.header(header::ETAG, format!("\"{}\"", file.id))
|
.header(header::ETAG, format!("\"{}\"", file.etag))
|
||||||
.header(
|
.header(
|
||||||
header::LAST_MODIFIED,
|
header::LAST_MODIFIED,
|
||||||
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||||
@@ -747,7 +748,7 @@ async fn handle_head(
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
||||||
.header(header::CONTENT_LENGTH, 0)
|
.header(header::CONTENT_LENGTH, 0)
|
||||||
.header(header::ETAG, format!("\"{}\"", folder.id))
|
.header(header::ETAG, format!("\"{}\"", folder.etag))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
@@ -756,7 +757,7 @@ async fn handle_head(
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, &*file.mime_type)
|
.header(header::CONTENT_TYPE, &*file.mime_type)
|
||||||
.header(header::CONTENT_LENGTH, file.size)
|
.header(header::CONTENT_LENGTH, file.size)
|
||||||
.header(header::ETAG, format!("\"{}\"", file.id))
|
.header(header::ETAG, format!("\"{}\"", file.etag))
|
||||||
.header(
|
.header(
|
||||||
header::LAST_MODIFIED,
|
header::LAST_MODIFIED,
|
||||||
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
||||||
@@ -777,7 +778,7 @@ async fn handle_head(
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
.header(header::CONTENT_TYPE, "httpd/unix-directory")
|
||||||
.header(header::CONTENT_LENGTH, 0)
|
.header(header::CONTENT_LENGTH, 0)
|
||||||
.header(header::ETAG, format!("\"{}\"", folder.id))
|
.header(header::ETAG, format!("\"{}\"", folder.etag))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
@@ -793,7 +794,7 @@ async fn handle_head(
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, &*file.mime_type)
|
.header(header::CONTENT_TYPE, &*file.mime_type)
|
||||||
.header(header::CONTENT_LENGTH, file.size)
|
.header(header::CONTENT_LENGTH, file.size)
|
||||||
.header(header::ETAG, format!("\"{}\"", file.id))
|
.header(header::ETAG, format!("\"{}\"", file.etag))
|
||||||
.header(
|
.header(
|
||||||
header::LAST_MODIFIED,
|
header::LAST_MODIFIED,
|
||||||
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
|
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.
|
/// Build a `FileDto` from a search file result.
|
||||||
fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto {
|
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 {
|
FileDto {
|
||||||
id: fr.id.clone(),
|
id: fr.id.clone(),
|
||||||
name: fr.name.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),
|
size_formatted: format_file_size(fr.size),
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
sort_date: None,
|
sort_date: None,
|
||||||
|
content_hash: String::new(),
|
||||||
etag: String::new(),
|
etag: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +280,7 @@ fn folder_dto_from_search(
|
|||||||
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
|
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
|
||||||
) -> FolderDto {
|
) -> FolderDto {
|
||||||
FolderDto {
|
FolderDto {
|
||||||
|
etag: sr.id.clone(),
|
||||||
id: sr.id.clone(),
|
id: sr.id.clone(),
|
||||||
name: sr.name.clone(),
|
name: sr.name.clone(),
|
||||||
path: sr.path.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)
|
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||||||
.unwrap_or_else(Utc::now);
|
.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()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
||||||
.header(header::CONTENT_LENGTH, file.size)
|
.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())
|
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
|
||||||
.body(Body::from_stream(std::pin::Pin::from(stream)))
|
.body(Body::from_stream(std::pin::Pin::from(stream)))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
@@ -400,11 +407,14 @@ async fn handle_head(
|
|||||||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||||||
.unwrap_or_else(Utc::now);
|
.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()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
|
||||||
.header(header::CONTENT_LENGTH, file.size)
|
.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())
|
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap())
|
.unwrap())
|
||||||
@@ -850,9 +860,13 @@ async fn handle_move(
|
|||||||
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
|
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
|
||||||
let mut builder = Response::builder().status(StatusCode::CREATED);
|
let mut builder = Response::builder().status(StatusCode::CREATED);
|
||||||
if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await {
|
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
|
builder = builder
|
||||||
.header(header::ETAG, format!("\"{}\"", moved.id))
|
.header(header::ETAG, format!("\"{}\"", moved.etag))
|
||||||
.header("oc-etag", format!("\"{}\"", moved.id));
|
.header("oc-etag", format!("\"{}\"", moved.etag));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(builder.body(Body::empty()).unwrap());
|
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);
|
.unwrap_or_else(Utc::now);
|
||||||
|
|
||||||
write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?;
|
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:getcontenttype", "httpd/unix-directory")?;
|
||||||
write_text_element(xml, "d:getcontentlength", "0")?;
|
write_text_element(xml, "d:getcontentlength", "0")?;
|
||||||
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
|
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
* @property {string} owner_id
|
* @property {string} owner_id
|
||||||
* @property {string|null} parent_id the folder parent (null if is_root)
|
* @property {string|null} parent_id the folder parent (null if is_root)
|
||||||
* @property {string} path the full path
|
* @property {string} path the full path
|
||||||
|
* @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match
|
||||||
*/
|
*/
|
||||||
|
|
||||||
//FIXME: rename into FileItem
|
//FIXME: rename into FileItem
|
||||||
@@ -44,6 +45,8 @@
|
|||||||
* @property {number} size
|
* @property {number} size
|
||||||
* @property {string} size_formatted
|
* @property {string} size_formatted
|
||||||
* @property {number} sort_date
|
* @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),
|
sort_date: toSecs(item.favorited_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: 'Folder'
|
category: 'Folder',
|
||||||
|
etag: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (item.resource_type === 'file') {
|
} else if (item.resource_type === 'file') {
|
||||||
@@ -397,7 +398,9 @@ const favoritesView = {
|
|||||||
sort_date: toSecs(item.favorited_at),
|
sort_date: toSecs(item.favorited_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_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),
|
sort_date: toSecs(item.accessed_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: 'Folder'
|
category: 'Folder',
|
||||||
|
etag: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (item.resource_type === 'file') {
|
} else if (item.resource_type === 'file') {
|
||||||
@@ -404,7 +405,9 @@ const recentView = {
|
|||||||
sort_date: toSecs(item.accessed_at),
|
sort_date: toSecs(item.accessed_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_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),
|
sort_date: grantedAtSecs(item.granted_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: 'folder'
|
category: 'folder',
|
||||||
|
etag: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (item.resource_type === 'file') {
|
} else if (item.resource_type === 'file') {
|
||||||
@@ -406,7 +407,9 @@ const sharedWithMeView = {
|
|||||||
sort_date: grantedAtSecs(item.granted_at),
|
sort_date: grantedAtSecs(item.granted_at),
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_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,
|
deletion_date: item.deletion_date,
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: 'Folder'
|
category: 'Folder',
|
||||||
|
etag: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (item.resource_type === 'file') {
|
} else if (item.resource_type === 'file') {
|
||||||
@@ -417,7 +418,9 @@ const trashView = {
|
|||||||
deletion_date: item.deletion_date,
|
deletion_date: item.deletion_date,
|
||||||
icon_class: f.icon_class,
|
icon_class: f.icon_class,
|
||||||
icon_special_class: f.icon_special_class ?? '',
|
icon_special_class: f.icon_special_class ?? '',
|
||||||
category: f.category
|
category: f.category,
|
||||||
|
etag: '',
|
||||||
|
content_hash: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user