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:
@@ -56,6 +56,11 @@ pub struct FileDto {
|
||||
/// Only populated by the /api/photos endpoint.
|
||||
#[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)]
|
||||
pub etag: String,
|
||||
}
|
||||
|
||||
impl From<File> for FileDto {
|
||||
@@ -85,6 +90,7 @@ impl From<File> for FileDto {
|
||||
size_formatted,
|
||||
owner_id: parts.owner_id.map(|u| u.to_string()),
|
||||
sort_date: None,
|
||||
etag: parts.etag,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +130,7 @@ impl FileDto {
|
||||
category: Arc::from("Document"),
|
||||
size_formatted: "0 Bytes".to_string(),
|
||||
owner_id: None,
|
||||
etag: String::new(),
|
||||
sort_date: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
modified_at: Option<i64>,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Streaming update — spools body to a temp file with incremental hash,
|
||||
/// then atomically replaces the file content via dedup store.
|
||||
@@ -83,7 +84,8 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
size: u64,
|
||||
content_type: &str,
|
||||
pre_computed_hash: Option<String>,
|
||||
) -> Result<(), DomainError>;
|
||||
modified_at: Option<i64>,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -291,7 +291,8 @@ pub trait FileWritePort: Send + Sync + 'static {
|
||||
size: u64,
|
||||
content_type: Option<String>,
|
||||
pre_computed_hash: Option<String>,
|
||||
) -> Result<(), DomainError>;
|
||||
modified_at: Option<i64>,
|
||||
) -> Result<String, DomainError>;
|
||||
|
||||
/// Registers file metadata WITHOUT writing content to disk (write-behind).
|
||||
///
|
||||
|
||||
@@ -227,7 +227,8 @@ impl FileUploadUseCase for FileUploadService {
|
||||
path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
modified_at: Option<i64>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Spool to temp file + hash
|
||||
let temp = tempfile::NamedTempFile::new()
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("temp file: {e}")))?;
|
||||
@@ -242,6 +243,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
content.len() as u64,
|
||||
content_type,
|
||||
Some(hash),
|
||||
modified_at,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -260,21 +262,26 @@ impl FileUploadUseCase for FileUploadService {
|
||||
size: u64,
|
||||
content_type: &str,
|
||||
pre_computed_hash: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
modified_at: Option<i64>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Try to find the existing file first
|
||||
if let Some(file_read) = &self.file_read
|
||||
&& let Some(file) = file_read.find_file_by_path(path).await?
|
||||
{
|
||||
let file_id = file.id().to_string();
|
||||
self.file_write
|
||||
.update_file_content_from_temp(
|
||||
file.id(),
|
||||
&file_id,
|
||||
temp_path,
|
||||
size,
|
||||
Some(content_type.to_string()),
|
||||
pre_computed_hash,
|
||||
modified_at,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
// Re-read to get fresh DTO with updated etag and timestamps.
|
||||
let updated = file_read.get_file(&file_id).await?;
|
||||
return Ok(FileDto::from(updated));
|
||||
}
|
||||
|
||||
// File doesn't exist — create it via streaming upload
|
||||
@@ -297,7 +304,8 @@ impl FileUploadUseCase for FileUploadService {
|
||||
None
|
||||
};
|
||||
|
||||
self.file_write
|
||||
let created = self
|
||||
.file_write
|
||||
.save_file_from_temp(
|
||||
filename.to_string(),
|
||||
parent_id,
|
||||
@@ -307,6 +315,6 @@ impl FileUploadUseCase for FileUploadService {
|
||||
pre_computed_hash,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(FileDto::from(created))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,8 +204,9 @@ impl FileWritePort for MockFileWritePort {
|
||||
_size: u64,
|
||||
_content_type: Option<String>,
|
||||
_pre_computed_hash: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
_modified_at: Option<i64>,
|
||||
) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
|
||||
@@ -556,8 +556,9 @@ impl FileWritePort for MockFileRepository {
|
||||
_size: u64,
|
||||
_content_type: Option<String>,
|
||||
_pre_computed_hash: Option<String>,
|
||||
) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
_modified_at: Option<i64>,
|
||||
) -> std::result::Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
|
||||
Reference in New Issue
Block a user