Fix Nextcloud sync conflict by using content-hash ETags

The Nextcloud Android client compares ETags before and after upload to
verify its write landed. OxiCloud was returning the stable file UUID as
the ETag, which never changed on content updates, causing false
SYNC_CONFLICT errors on every upload.

Five fixes applied:
1. Thread blob_hash (SHA-256) through File entity, FileDto, all read/write
   queries, and all WebDAV/PROPFIND responses as the ETag — changes on
   every content update, no DB migration needed.
2. Honor X-OC-Mtime header: parse the client-supplied mtime and use it
   for updated_at via COALESCE(to_timestamp($n), NOW()).
3. Disable phantom checksum capability (preferredUploadType/supportedTypes)
   that the server never actually implemented, stopping retry loops.
4. Add nc:creation_time and nc:upload_time to PROPFIND responses.
5. Return oc-etag header in chunked upload MOVE (assemble) responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jared Wolff
2026-03-15 14:14:24 -04:00
parent 5fb3e31ad7
commit 1fcd02a519
18 changed files with 204 additions and 80 deletions
+2 -2
View File
@@ -505,8 +505,8 @@ fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value
"chunking": "1.0"
},
"checksums": {
"preferredUploadType": "SHA1",
"supportedTypes": ["SHA1", "MD5"]
"preferredUploadType": "",
"supportedTypes": []
},
"files_sharing": {
"api_enabled": false,
@@ -266,6 +266,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,
etag: String::new(),
}
}
+23 -8
View File
@@ -116,6 +116,12 @@ async fn handle_assemble(
.ok_or_else(|| AppError::bad_request("Missing Destination header"))?
.to_string();
let oc_mtime = req
.headers()
.get("x-oc-mtime")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok());
let dest_subpath = extract_files_subpath(&destination, &user.username)
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
@@ -144,11 +150,19 @@ async fn handle_assemble(
// Check if file exists (update vs create).
let existing = file_service.get_file_by_path(&internal_path).await;
if existing.is_ok() {
upload_service
.update_file_streaming(&internal_path, &temp_path, size, &content_type, None)
let etag: Option<String> = if existing.is_ok() {
let dto = upload_service
.update_file_streaming(
&internal_path,
&temp_path,
size,
&content_type,
None,
oc_mtime,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
Some(dto.etag)
} else {
// For new files we still need to read the temp file since create_file takes &[u8].
let assembled = tokio::fs::read(&temp_path).await.map_err(|e| {
@@ -166,11 +180,12 @@ async fn handle_assemble(
);
let parent_internal = parent_internal.trim_end_matches('/');
upload_service
let dto = upload_service
.create_file(parent_internal, filename, &assembled, &content_type)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
}
Some(dto.etag)
};
// Clean up temp file (session cleanup below removes the directory anyway).
let _ = tokio::fs::remove_file(&temp_path).await;
@@ -178,11 +193,11 @@ async fn handle_assemble(
// Cleanup session.
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
// Return etag if we can fetch the file.
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
if let Some(tag) = etag {
return Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::ETAG, format!("\"{}\"", tag))
.header("oc-etag", format!("\"{}\"", tag))
.body(Body::empty())
.unwrap());
}
+11 -17
View File
@@ -526,7 +526,7 @@ async fn handle_put(
.unwrap_or("application/octet-stream")
.to_string();
let _oc_mtime = req
let oc_mtime = req
.headers()
.get("x-oc-mtime")
.and_then(|v| v.to_str().ok())
@@ -545,24 +545,16 @@ async fn handle_put(
let existing = file_service.get_file_by_path(&internal_path).await;
if existing.is_ok() {
// Update existing file.
upload_service
.update_file(&internal_path, &body_bytes, &content_type)
// Update existing file — returns FileDto with fresh content-hash etag.
let updated = upload_service
.update_file(&internal_path, &body_bytes, &content_type, oc_mtime)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
// Re-fetch for etag.
if let Ok(updated) = file_service.get_file_by_path(&internal_path).await {
let builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", updated.id))
.header("oc-etag", format!("\"{}\"", updated.id));
return Ok(builder.body(Body::empty()).unwrap());
}
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", updated.etag))
.header("oc-etag", format!("\"{}\"", updated.etag))
.body(Body::empty())
.unwrap());
}
@@ -582,8 +574,8 @@ async fn handle_put(
let builder = Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", file_dto.id))
.header("oc-etag", format!("\"{}\"", file_dto.id));
.header(header::ETAG, format!("\"{}\"", file_dto.etag))
.header("oc-etag", format!("\"{}\"", file_dto.etag));
Ok(builder.body(Body::empty()).unwrap())
}
@@ -1126,7 +1118,7 @@ pub fn write_file_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!("\"{}\"", file.id))?;
write_text_element(xml, "d:getetag", &format!("\"{}\"", file.etag))?;
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
// Nextcloud/ownCloud properties
@@ -1166,6 +1158,8 @@ pub fn write_file_response<W: std::io::Write>(
write_text_element(xml, "nc:is-encrypted", "0")?;
write_text_element(xml, "nc:mount-type", "")?;
write_text_element(xml, "nc:creation_time", &file.created_at.to_string())?;
write_text_element(xml, "nc:upload_time", &file.modified_at.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.xml_err()?;