Merge pull request #207 from jaredwolff/fix/nextcloud-sync-conflict
Fix Nextcloud sync conflict caused by static UUID ETags
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
+9
-6
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user