Merge pull request #207 from jaredwolff/fix/nextcloud-sync-conflict

Fix Nextcloud sync conflict caused by static UUID ETags
This commit is contained in:
Dionisio Pozo
2026-03-15 20:28:39 +01:00
committed by GitHub
19 changed files with 206 additions and 82 deletions
Generated
+2 -2
View File
@@ -3051,9 +3051,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
+7
View File
@@ -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,
}
}
+4 -2
View File
@@ -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>;
}
// ─────────────────────────────────────────────────────
+2 -1
View File
@@ -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(
+9 -6
View File
@@ -185,8 +185,9 @@ impl FileWritePort for StubFileWritePort {
_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(
@@ -477,8 +478,9 @@ impl FileUploadUseCase for StubFileUploadUseCase {
_path: &str,
_content: &[u8],
_content_type: &str,
) -> Result<(), DomainError> {
Ok(())
_modified_at: Option<i64>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn update_file_streaming(
@@ -488,8 +490,9 @@ impl FileUploadUseCase for StubFileUploadUseCase {
_size: u64,
_content_type: &str,
_pre_computed_hash: Option<String>,
) -> Result<(), DomainError> {
Ok(())
_modified_at: Option<i64>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
}
+1 -1
View File
@@ -243,7 +243,7 @@ impl Calendar {
pub fn update_color(&mut self, color: Option<String>) -> Result<()> {
// Validate color format if provided
if let Some(color_str) = &color {
Self::validate_color(&color_str)?;
Self::validate_color(color_str)?;
}
self.color = color;
+44
View File
@@ -21,6 +21,7 @@ pub struct FileParts {
pub created_at: u64,
pub modified_at: u64,
pub owner_id: Option<Uuid>,
pub etag: String,
}
/**
@@ -64,6 +65,9 @@ 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,
}
// We no longer need this module, now we use a String directly
@@ -81,6 +85,7 @@ impl Default for File {
created_at: 0,
modified_at: 0,
owner_id: None,
etag: String::new(),
}
}
}
@@ -119,6 +124,7 @@ impl File {
created_at: now,
modified_at: now,
owner_id: None,
etag: String::new(),
})
}
@@ -150,6 +156,7 @@ impl File {
created_at,
modified_at,
owner_id: None,
etag: String::new(),
})
}
@@ -164,6 +171,33 @@ impl File {
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
) -> FileResult<Self> {
Self::with_timestamps_and_etag(
id,
name,
storage_path,
size,
mime_type,
folder_id,
created_at,
modified_at,
owner_id,
String::new(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_timestamps_and_etag(
id: String,
name: String,
storage_path: StoragePath,
size: u64,
mime_type: String,
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
etag: String,
) -> FileResult<Self> {
// Validate file name
if name.is_empty() || name.contains('/') || name.contains('\\') {
@@ -184,6 +218,7 @@ impl File {
created_at,
modified_at,
owner_id,
etag,
})
}
@@ -203,9 +238,14 @@ impl File {
created_at: self.created_at,
modified_at: self.modified_at,
owner_id: self.owner_id,
etag: self.etag,
}
}
pub fn etag(&self) -> &str {
&self.etag
}
// Getters
pub fn id(&self) -> &str {
&self.id
@@ -273,6 +313,7 @@ impl File {
created_at,
modified_at,
owner_id: None,
etag: String::new(),
}
}
@@ -311,6 +352,7 @@ impl File {
created_at: self.created_at,
modified_at: now,
owner_id: self.owner_id,
etag: self.etag.clone(),
})
}
@@ -345,6 +387,7 @@ impl File {
created_at: self.created_at,
modified_at: now,
owner_id: self.owner_id,
etag: self.etag.clone(),
})
}
@@ -366,6 +409,7 @@ impl File {
created_at: self.created_at,
modified_at: now,
owner_id: self.owner_id,
etag: self.etag.clone(),
}
}
}
@@ -17,6 +17,7 @@ type MediaFileRow = (
String, // mime_type
i64, // created_at
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id
i64, // sort_date
);
@@ -38,6 +39,7 @@ use crate::infrastructure::services::dedup_service::DedupService;
use uuid::Uuid;
/// Type alias for file metadata rows from SQL queries.
/// Fields: id, name, folder_id, folder_path, size, mime_type, created_at, updated_at, blob_hash, user_id
type FileRow = (
String,
String,
@@ -47,6 +49,7 @@ type FileRow = (
String,
i64,
i64,
String,
Option<Uuid>,
);
@@ -114,10 +117,11 @@ impl FileBlobReadRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
etag: String,
owner_id: Option<Uuid>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps(
File::with_timestamps_and_etag(
id,
name,
storage_path,
@@ -127,6 +131,7 @@ impl FileBlobReadRepository {
created_at as u64,
modified_at as u64,
owner_id,
etag,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
@@ -183,6 +188,7 @@ impl FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date
FROM storage.files fi
@@ -206,9 +212,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, uid, sd) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, etag, uid, sd) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, uid,
id, name, fid, fpath, size, mime, ca, ma, etag, uid,
)?);
sort_dates.push(sd);
}
@@ -257,7 +263,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.9,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
)
}
@@ -302,7 +308,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.9,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
)
}
@@ -315,6 +321,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -332,6 +339,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -345,8 +353,8 @@ 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, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
.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)
})
.collect()
}
@@ -365,6 +373,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -384,6 +393,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -399,8 +409,8 @@ 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, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
.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)
})
.collect()
}
@@ -427,6 +437,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -447,6 +458,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -463,8 +475,8 @@ 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, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
.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)
})
.collect()
}
@@ -485,6 +497,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -507,6 +520,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -527,8 +541,8 @@ impl FileReadPort for FileBlobReadRepository {
})?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
.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)
})
.collect()
}
@@ -645,6 +659,7 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
String,
Option<Uuid>,
),
>(
@@ -653,6 +668,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -675,6 +691,7 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
String,
Option<Uuid>,
),
>(
@@ -683,6 +700,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -698,7 +716,7 @@ impl FileReadPort for FileBlobReadRepository {
match row {
Some(r) => Ok(Some(Self::row_to_file(
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8,
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9,
)?)),
None => Ok(None),
}
@@ -718,13 +736,14 @@ impl FileReadPort for FileBlobReadRepository {
let stream = async_stream::try_stream! {
let mut row_stream = sqlx::query_as::<_, (
String, String, Option<String>, Option<String>,
i64, String, i64, i64, Option<Uuid>,
i64, String, i64, i64, String, Option<Uuid>,
)>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -739,9 +758,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, uid) = row;
let (id, name, fid, fpath, size, mime, ca, ma, etag, uid) = row;
let file = FileBlobReadRepository::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, uid,
id, name, fid, fpath, size, mime, ca, ma, etag, uid,
)?;
yield file;
}
@@ -803,6 +822,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
@@ -824,6 +844,7 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
String,
Option<Uuid>,
i64,
),
@@ -847,13 +868,15 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
// total_count is the same in every row; 0 when result set is empty.
let total_count = rows.first().map_or(0, |r| r.9) as usize;
let total_count = rows.first().map_or(0, |r| r.10) as usize;
let files = rows
.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.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)
},
)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("mapping: {e}")))?;
@@ -964,6 +987,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
@@ -985,6 +1009,7 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
String,
Option<Uuid>,
i64,
),
@@ -1029,13 +1054,15 @@ impl FileReadPort for FileBlobReadRepository {
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
})?;
let total_count = rows.first().map_or(0, |r| r.9) as usize;
let total_count = rows.first().map_or(0, |r| r.10) as usize;
let files = rows
.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.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)
},
)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree mapping: {e}"))
@@ -1074,6 +1101,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -1102,6 +1130,7 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -1126,8 +1155,8 @@ 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, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
.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)
})
.collect()
}
@@ -100,9 +100,10 @@ impl FileBlobWriteRepository {
created_at: i64,
modified_at: i64,
owner_id: Option<Uuid>,
etag: String,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps(
File::with_timestamps_and_etag(
id,
name,
storage_path,
@@ -112,6 +113,7 @@ impl FileBlobWriteRepository {
created_at as u64,
modified_at as u64,
owner_id,
etag,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
@@ -132,12 +134,16 @@ impl FileBlobWriteRepository {
/// Uses a CTE to capture the old hash before updating so the old blob
/// reference can be decremented afterwards. Compensates on failure by
/// removing the new blob reference.
///
/// `modified_at`: if `Some`, sets `updated_at` to that Unix timestamp;
/// if `None`, uses `NOW()` (server time). Returns the new hash on success.
async fn swap_blob_hash(
&self,
file_id: &str,
new_hash: &str,
new_size: i64,
) -> Result<(), DomainError> {
modified_at: Option<i64>,
) -> Result<String, DomainError> {
// Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
let old_hash = match sqlx::query_scalar::<_, String>(
r#"
@@ -145,7 +151,8 @@ impl FileBlobWriteRepository {
SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
)
UPDATE storage.files f
SET blob_hash = $1, size = $2, updated_at = NOW()
SET blob_hash = $1, size = $2,
updated_at = COALESCE(to_timestamp($4), NOW())
FROM old
WHERE f.id = old.id
RETURNING old.blob_hash
@@ -154,6 +161,7 @@ impl FileBlobWriteRepository {
.bind(new_hash)
.bind(new_size)
.bind(file_id)
.bind(modified_at.map(|t| t as f64))
.fetch_optional(self.pool.as_ref())
.await
{
@@ -192,7 +200,7 @@ impl FileBlobWriteRepository {
);
}
Ok(())
Ok(new_hash.to_string())
}
}
@@ -277,6 +285,7 @@ impl FileWritePort for FileBlobWriteRepository {
row.1,
row.2,
Some(user_id),
blob_hash.clone(),
)
}
@@ -314,6 +323,7 @@ impl FileWritePort for FileBlobWriteRepository {
row.5,
row.6,
None,
String::new(),
)
}
@@ -407,6 +417,7 @@ impl FileWritePort for FileBlobWriteRepository {
row.5,
row.6,
None,
row.7,
)
}
@@ -446,6 +457,7 @@ impl FileWritePort for FileBlobWriteRepository {
row.5,
row.6,
None,
String::new(),
)
}
@@ -474,7 +486,8 @@ impl FileWritePort for FileBlobWriteRepository {
size: u64,
content_type: Option<String>,
pre_computed_hash: Option<String>,
) -> Result<(), DomainError> {
modified_at: Option<i64>,
) -> Result<String, DomainError> {
// Streaming: pass pre-computed hash so dedup skips re-reading the file.
let dedup_result = self
.dedup
@@ -482,7 +495,8 @@ impl FileWritePort for FileBlobWriteRepository {
.await?;
let new_hash = dedup_result.hash().to_string();
self.swap_blob_hash(file_id, &new_hash, size as i64).await
self.swap_blob_hash(file_id, &new_hash, size as i64, modified_at)
.await
}
async fn register_file_deferred(
@@ -528,6 +542,7 @@ impl FileWritePort for FileBlobWriteRepository {
row.1,
row.2,
Some(user_id),
String::new(),
)?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)
@@ -175,6 +175,7 @@ impl PathResolverService {
size_formatted: format_file_size(sz),
owner_id: uid,
sort_date: None,
etag: String::new(),
}))
}
}
@@ -928,6 +928,7 @@ async fn handle_put(
total_bytes as u64,
&content_type,
Some(hash),
None,
)
.await;
@@ -280,6 +280,7 @@ async fn put_file(
total_bytes,
&content_type,
Some(hash),
None,
)
.await;
+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()?;