diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index 0fcd289a..99ed00bb 100755 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -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, + + /// 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 for FileDto { @@ -85,6 +90,7 @@ impl From 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, } } diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index ceea8134..08c97189 100755 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -69,7 +69,8 @@ pub trait FileUploadUseCase: Send + Sync + 'static { path: &str, content: &[u8], content_type: &str, - ) -> Result<(), DomainError>; + modified_at: Option, + ) -> Result; /// 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, - ) -> Result<(), DomainError>; + modified_at: Option, + ) -> Result; } // ───────────────────────────────────────────────────── diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index d4bef15d..a452f0f0 100755 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -291,7 +291,8 @@ pub trait FileWritePort: Send + Sync + 'static { size: u64, content_type: Option, pre_computed_hash: Option, - ) -> Result<(), DomainError>; + modified_at: Option, + ) -> Result; /// Registers file metadata WITHOUT writing content to disk (write-behind). /// diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index b42eaab8..8a9465a9 100755 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -227,7 +227,8 @@ impl FileUploadUseCase for FileUploadService { path: &str, content: &[u8], content_type: &str, - ) -> Result<(), DomainError> { + modified_at: Option, + ) -> Result { // 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, - ) -> Result<(), DomainError> { + modified_at: Option, + ) -> Result { // 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)) } } diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index a221e5b2..4abe8766 100755 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -204,8 +204,9 @@ impl FileWritePort for MockFileWritePort { _size: u64, _content_type: Option, _pre_computed_hash: Option, - ) -> Result<(), DomainError> { - Ok(()) + _modified_at: Option, + ) -> Result { + Ok(String::new()) } async fn register_file_deferred( diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 620c2828..860a0881 100755 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -556,8 +556,9 @@ impl FileWritePort for MockFileRepository { _size: u64, _content_type: Option, _pre_computed_hash: Option, - ) -> std::result::Result<(), DomainError> { - Ok(()) + _modified_at: Option, + ) -> std::result::Result { + Ok(String::new()) } async fn register_file_deferred( diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 7b2170ff..e41a95fc 100755 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -185,8 +185,9 @@ impl FileWritePort for StubFileWritePort { _size: u64, _content_type: Option, _pre_computed_hash: Option, - ) -> Result<(), DomainError> { - Ok(()) + _modified_at: Option, + ) -> Result { + 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, + ) -> Result { + 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, - ) -> Result<(), DomainError> { - Ok(()) + _modified_at: Option, + ) -> Result { + Ok(FileDto::default()) } } diff --git a/src/domain/entities/calendar.rs b/src/domain/entities/calendar.rs index 3b6f0a49..1d7ef4c4 100755 --- a/src/domain/entities/calendar.rs +++ b/src/domain/entities/calendar.rs @@ -243,7 +243,7 @@ impl Calendar { pub fn update_color(&mut self, color: Option) -> 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; diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 6129969e..de54d0e6 100755 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -21,6 +21,7 @@ pub struct FileParts { pub created_at: u64, pub modified_at: u64, pub owner_id: Option, + pub etag: String, } /** @@ -64,6 +65,9 @@ pub struct File { /// Owner user ID (from storage.files.user_id) owner_id: Option, + + /// 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, + ) -> FileResult { + 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, + created_at: u64, + modified_at: u64, + owner_id: Option, + etag: String, ) -> FileResult { // 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(), } } } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 307056d8..82f4edb1 100755 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -17,6 +17,7 @@ type MediaFileRow = ( String, // mime_type i64, // created_at i64, // updated_at + String, // blob_hash Option, // 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, ); @@ -114,10 +117,11 @@ impl FileBlobReadRepository { mime_type: String, created_at: i64, modified_at: i64, + etag: String, owner_id: Option, ) -> Result { 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, ), >( @@ -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, ), >( @@ -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, Option, - i64, String, i64, i64, Option, + i64, String, i64, i64, String, Option, )>( 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, 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::, _>>() .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, 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::, _>>() .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() } diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 5b9a0969..45b9d552 100755 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -100,9 +100,10 @@ impl FileBlobWriteRepository { created_at: i64, modified_at: i64, owner_id: Option, + etag: String, ) -> Result { 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, + ) -> Result { // 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, pre_computed_hash: Option, - ) -> Result<(), DomainError> { + modified_at: Option, + ) -> Result { // 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/) diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index c4393c3f..291db682 100755 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -175,6 +175,7 @@ impl PathResolverService { size_formatted: format_file_size(sz), owner_id: uid, sort_date: None, + etag: String::new(), })) } } diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 4fd31229..544f2faf 100755 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -928,6 +928,7 @@ async fn handle_put( total_bytes as u64, &content_type, Some(hash), + None, ) .await; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 97403a05..56f895c4 100755 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -280,6 +280,7 @@ async fn put_file( total_bytes, &content_type, Some(hash), + None, ) .await; diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index 4cfadada..e2b137ef 100755 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -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, diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 7bbd28ec..525f9f80 100755 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -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(), } } diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index b45e4b73..8f14f8df 100755 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -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::().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 = 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()); } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 79be7983..5377bccc 100755 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -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( .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( 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()?;