Merge pull request #426 from EdouardVanbelle/refactor/etag-centralize

refactor & normalize etag for Nextcloud + fix NFC string (important fix)
This commit is contained in:
Dionisio Pozo
2026-06-07 01:26:30 +02:00
committed by GitHub
67 changed files with 2238 additions and 242 deletions
+10 -6
View File
@@ -444,9 +444,11 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// Other standard properties
// ETag — routes through `FolderDto::etag` (= `Folder::etag()`)
// so every WebDAV emitter and HEAD response agree on a single
// value for the same folder.
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.id))))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", folder.etag))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
// Content length (0 for directories)
@@ -505,9 +507,11 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Text(BytesText::new(&modified_at.to_rfc2822())))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
// ETag
// ETag — routes through `FileDto::etag` (= `File::etag()`) so
// PROPFIND, GET, HEAD, PUT-response, and MOVE all emit
// byte-identical values for the same file.
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.id))))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", file.etag))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
Ok(())
@@ -589,7 +593,7 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"\"{}\"",
folder.id
folder.etag
))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
@@ -685,7 +689,7 @@ impl WebDavAdapter {
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
xml_writer.write_event(Event::Text(BytesText::new(&format!(
"\"{}\"",
file.id
file.etag
))))?;
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
}
+4
View File
@@ -125,6 +125,10 @@ pub struct FavoriteResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Routes into `FileDto::content_hash` and feeds
/// `File::compute_etag` to populate `FileDto::etag`.
pub blob_hash: Option<String>,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub favorited_at: DateTime<Utc>,
+23 -4
View File
@@ -62,14 +62,31 @@ pub struct FileDto {
#[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)]
/// Raw BLAKE3 content hash. Populated from `File::content_hash()`.
/// Exposed in REST JSON so API consumers can use it for
/// content-addressable URLs, dedup verification, and integrity
/// audits. Distinct from `etag` (which is an HTTP-only cache
/// token whose formula may grow to include `modified_at` etc.).
pub content_hash: String,
/// Opaque HTTP ETag. Populated from `File::etag()`. Used by
/// WebDAV/NextCloud handlers when emitting `ETag` headers and
/// also exposed in REST JSON so frontends can pass it back
/// through `If-Match` / `If-None-Match` on download / mutation
/// endpoints without a separate HEAD round-trip.
pub etag: String,
}
impl From<File> for FileDto {
fn from(file: File) -> Self {
// Compute the HTTP ETag BEFORE consuming the entity —
// `File::etag()` derives from `blob_hash` + `modified_at`,
// so it must run against the live entity, not against
// already-extracted parts. `content_hash` is just the raw
// blob hash; `etag` is the cache token derived from it.
let etag = file.etag();
let content_hash = file.content_hash().to_string();
// Consume the entity by moving all fields — zero heap allocations
// for id, name, path, folder_id, owner_id (previously 5× .to_string()).
let parts = file.into_parts();
@@ -95,7 +112,8 @@ impl From<File> for FileDto {
size_formatted,
owner_id: parts.owner_id.map(|u| u.to_string()),
sort_date: None,
etag: parts.etag,
content_hash,
etag,
}
}
}
@@ -150,6 +168,7 @@ impl FileDto {
category: Arc::from("Document"),
size_formatted: "0 Bytes".to_string(),
owner_id: None,
content_hash: String::new(),
etag: String::new(),
sort_date: None,
}
+15
View File
@@ -73,11 +73,19 @@ pub struct FolderDto {
/// Human-readable category (always "Folder")
#[schema(value_type = String)]
pub category: Arc<str>,
/// Opaque ETag for HTTP responses. Populated from `Folder::etag()`
/// at conversion time so every WebDAV / NextCloud handler emits
/// the same value, and exposed in REST JSON so the frontend can
/// pass it back through `If-Match` on rename / move endpoints
/// without a separate HEAD round-trip.
pub etag: String,
}
impl From<Folder> for FolderDto {
fn from(folder: Folder) -> Self {
let is_root = folder.parent_id().is_none();
let etag = folder.etag().to_string();
Self {
id: folder.id().to_string(),
@@ -91,6 +99,7 @@ impl From<Folder> for FolderDto {
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
etag,
}
}
}
@@ -141,6 +150,7 @@ impl FolderDto {
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
etag: String::new(),
}
}
}
@@ -171,6 +181,11 @@ pub struct FolderResourceRow {
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Populates `FileDto::content_hash` + `FileDto::etag`
/// on the REST `/api/folders/{id}/resources` listing so API
/// consumers can issue conditional requests against listed files.
pub blob_hash: Option<String>,
// Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String,
+4
View File
@@ -105,6 +105,10 @@ pub struct RecentResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so this listing's
/// `etag` matches GET/HEAD/PROPFIND for the same file.
pub blob_hash: Option<String>,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub accessed_at: DateTime<Utc>,
+7
View File
@@ -127,6 +127,13 @@ pub struct SearchFileResultDto {
pub icon_special_class: String,
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
pub category: String,
/// Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and
/// `File::compute_etag` when search results are converted to
/// `FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()`
/// for backward-compatible deserialisation of cached results
/// that pre-date the column.
#[serde(default)]
pub blob_hash: String,
}
/// A folder search result enriched with server-computed metadata
+6
View File
@@ -61,6 +61,12 @@ pub struct TrashResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so the trash listing's
/// `etag` matches what GET/HEAD/PROPFIND would return for the
/// same file (restorable trash items are conditional-request
/// targets too).
pub blob_hash: Option<String>,
pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
@@ -172,6 +172,10 @@ impl SearchService {
icon_class: get_icon_class(&file.name, &file.mime_type),
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
category: get_category(&file.name, &file.mime_type),
// Carry the content hash through so REPORT/SEARCH
// responses on the NC surface can emit the same ETag
// (`File::compute_etag`) as PROPFIND/GET would.
blob_hash: file.content_hash.clone(),
}
}
+17 -3
View File
@@ -17,6 +17,7 @@ use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::file::File;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
@@ -809,8 +810,10 @@ fn build_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) ->
fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
let path = row.path.clone().unwrap_or_default();
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
id: row.resource_id.to_string(),
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
@@ -834,6 +837,16 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// Route ETag through `File::compute_etag` so trash items
// match GET/HEAD/PROPFIND ETags — a client restoring a
// file may conditional-request it immediately after.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
@@ -842,14 +855,15 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
etag: String::new(),
content_hash,
etag,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
+326
View File
@@ -0,0 +1,326 @@
//! `migrate-nfc-filenames` — one-shot CLI to NFC-normalize
//! `storage.files.name` across an OxiCloud instance.
//!
//! Why: PostgreSQL compares bytes literally and the `UNIQUE`
//! index on `(folder_id, name, user_id) WHERE NOT is_trashed`
//! does not catch Unicode normalization differences. macOS APFS
//! stores filenames in NFD; browsers post NFC. A file uploaded
//! from the web ("café.txt", NFC) and the same name re-uploaded
//! from a NextCloud desktop client on macOS (round-tripped to
//! NFD: `e` + combining acute) lands as two distinct rows, both
//! visible in the listing, both pointing at the same blob.
//!
//! What this does:
//!
//! 1. Scans every non-trashed file row.
//! 2. For each row whose name ≠ NFC(name):
//! - If no other row in the same `(folder_id, user_id)` already
//! holds the NFC form → UPDATE the row's name to NFC.
//! - If a collision exists with **same blob_hash**: trash the
//! newer of the two (`is_trashed = true`, `trashed_at = NOW()`).
//! User can restore from the trash UI if needed.
//! - If a collision exists with **different blob_hash**: rename
//! the newer row to `{nfc_name}.duplicate`, incrementing the
//! suffix (`.duplicate-1`, `.duplicate-2`, …) until a free name
//! is found. Preserves both files; user can inspect and resolve.
//! - In both collision cases, the surviving (older) row's name
//! is also normalized to NFC.
//!
//! Run:
//! `cargo run --bin migrate-nfc-filenames -- --dry-run`
//! `cargo run --bin migrate-nfc-filenames`
//!
//! Folder rows are NOT touched in this pass — trashing a folder
//! affects descendants; that pass is deferred to a follow-up.
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row};
use std::env;
use uuid::Uuid;
use oxicloud::domain::services::path_service::normalize_storage_name;
#[derive(Debug, Clone)]
struct FileRow {
id: Uuid,
folder_id: Option<Uuid>,
user_id: Uuid,
name: String,
blob_hash: String,
created_at: DateTime<Utc>,
}
#[derive(Default)]
struct Stats {
scanned: u64,
already_nfc: u64,
normalized_in_place: u64,
deduped_same_content: u64,
renamed_duplicate: u64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let dry_run = args.iter().any(|a| a == "--dry-run");
let database_url =
env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment");
let pool = PgPool::connect(&database_url).await?;
println!(
"=== NFC filename migration ({}) ===",
if dry_run {
"DRY RUN — no writes"
} else {
"EXECUTING"
}
);
println!();
let rows = load_non_trashed_files(&pool).await?;
println!("Loaded {} non-trashed file rows", rows.len());
println!();
let mut stats = Stats {
scanned: rows.len() as u64,
..Default::default()
};
for row in &rows {
let nfc_name = normalize_storage_name(&row.name);
if nfc_name == row.name {
stats.already_nfc += 1;
continue;
}
// Row is in non-NFC form. Look for a collision in the same
// (folder_id, user_id) scope, including rows that may also
// be non-NFC but happen to normalize to the same NFC value.
let collision = find_collision(&pool, row, &nfc_name).await?;
match collision {
None => {
println!(
"NORMALIZE {} user={} '{}' → '{}'",
row.id, row.user_id, row.name, nfc_name
);
if !dry_run {
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(&nfc_name)
.bind(row.id)
.execute(&pool)
.await?;
}
stats.normalized_in_place += 1;
}
Some(other) => {
// Pick winner/loser by `created_at` — older wins.
let (older, newer) = if row.created_at <= other.created_at {
(row, &other)
} else {
(&other, row)
};
if older.blob_hash == newer.blob_hash {
// Same content → trash the newer; promote older's
// name to NFC if it isn't already.
println!(
"DEDUP newer={} (trash, same blob) older={} user={} hash={}",
newer.id,
older.id,
older.user_id,
&older.blob_hash[..16.min(older.blob_hash.len())]
);
if !dry_run {
sqlx::query(
"UPDATE storage.files
SET is_trashed = TRUE,
trashed_at = NOW()
WHERE id = $1",
)
.bind(newer.id)
.execute(&pool)
.await?;
normalize_survivor_name(&pool, older, &nfc_name).await?;
}
stats.deduped_same_content += 1;
} else {
// Different content → rename newer to a free
// `{nfc_name}.duplicate[-N]`; promote older to NFC.
let disambiguated = find_free_duplicate_name(&pool, newer, &nfc_name).await?;
println!(
"RENAME newer={} (different blob) older={} '{}' → '{}'",
newer.id, older.id, newer.name, disambiguated
);
if !dry_run {
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(&disambiguated)
.bind(newer.id)
.execute(&pool)
.await?;
normalize_survivor_name(&pool, older, &nfc_name).await?;
}
stats.renamed_duplicate += 1;
}
}
}
}
println!();
println!("=== Summary ===");
println!(" scanned : {}", stats.scanned);
println!(
" already in NFC : {}",
stats.already_nfc
);
println!(
" normalized in place (no collision) : {}",
stats.normalized_in_place
);
println!(
" dedup-trashed (same content) : {}",
stats.deduped_same_content
);
println!(
" renamed to .duplicate : {}",
stats.renamed_duplicate
);
if dry_run {
println!();
println!("DRY RUN — no rows were written. Re-run without --dry-run to apply.");
}
Ok(())
}
async fn load_non_trashed_files(pool: &PgPool) -> Result<Vec<FileRow>, Box<dyn std::error::Error>> {
let raw = sqlx::query(
"SELECT id, folder_id, user_id, name, blob_hash, created_at
FROM storage.files
WHERE NOT is_trashed
ORDER BY created_at",
)
.fetch_all(pool)
.await?;
let mut out = Vec::with_capacity(raw.len());
for r in raw {
out.push(FileRow {
id: r.try_get("id")?,
folder_id: r.try_get("folder_id")?,
user_id: r.try_get("user_id")?,
name: r.try_get("name")?,
blob_hash: r.try_get("blob_hash")?,
created_at: r.try_get("created_at")?,
});
}
Ok(out)
}
/// Looks for a row in the same `(folder_id, user_id)` scope whose
/// CURRENT name equals `nfc_name`, excluding the row being processed.
/// The other row may itself be in non-NFC form whose normalized
/// representation happens to differ from `nfc_name`; the collision
/// check is intentionally based on stored bytes (matching the
/// UNIQUE-index semantics that this migration is repairing).
async fn find_collision(
pool: &PgPool,
row: &FileRow,
nfc_name: &str,
) -> Result<Option<FileRow>, Box<dyn std::error::Error>> {
let result = sqlx::query(
"SELECT id, folder_id, user_id, name, blob_hash, created_at
FROM storage.files
WHERE name = $1
AND user_id = $2
AND ($3::uuid IS NULL AND folder_id IS NULL
OR folder_id = $3::uuid)
AND id <> $4
AND NOT is_trashed
LIMIT 1",
)
.bind(nfc_name)
.bind(row.user_id)
.bind(row.folder_id)
.bind(row.id)
.fetch_optional(pool)
.await?;
Ok(result.map(|r| FileRow {
id: r.get("id"),
folder_id: r.get("folder_id"),
user_id: r.get("user_id"),
name: r.get("name"),
blob_hash: r.get("blob_hash"),
created_at: r.get("created_at"),
}))
}
/// Finds a free name in the form `{nfc_name}.duplicate` or
/// `{nfc_name}.duplicate-N` for `N >= 1`, scoped to the row's
/// `(folder_id, user_id)`. Returns the first candidate that does
/// not currently exist as a non-trashed row.
async fn find_free_duplicate_name(
pool: &PgPool,
row: &FileRow,
nfc_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let mut suffix: u32 = 0;
loop {
let candidate = if suffix == 0 {
format!("{}.duplicate", nfc_name)
} else {
format!("{}.duplicate-{}", nfc_name, suffix)
};
let taken: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM storage.files
WHERE name = $1
AND user_id = $2
AND ($3::uuid IS NULL AND folder_id IS NULL
OR folder_id = $3::uuid)
AND id <> $4
AND NOT is_trashed)",
)
.bind(&candidate)
.bind(row.user_id)
.bind(row.folder_id)
.bind(row.id)
.fetch_one(pool)
.await?;
if !taken {
return Ok(candidate);
}
suffix = suffix.saturating_add(1);
// Safety bound — should never trigger under realistic data.
if suffix > 10_000 {
return Err(format!(
"Exhausted .duplicate-N suffixes for '{}' in scope (user={}, folder_id={:?})",
nfc_name, row.user_id, row.folder_id
)
.into());
}
}
}
/// If the surviving (older) row's stored name is not yet in NFC,
/// UPDATE it now that the collision has been resolved.
async fn normalize_survivor_name(
pool: &PgPool,
survivor: &FileRow,
nfc_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if survivor.name == nfc_name {
return Ok(());
}
sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2")
.bind(nfc_name)
.bind(survivor.id)
.execute(pool)
.await?;
Ok(())
}
+164 -19
View File
@@ -1,6 +1,8 @@
use uuid::Uuid;
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
use crate::domain::services::path_service::{
StoragePath, normalize_storage_name, validate_storage_name,
};
// Re-export entity errors from the centralized module
pub use super::entity_errors::{FileError, FileResult};
@@ -21,7 +23,8 @@ pub struct FileParts {
pub created_at: u64,
pub modified_at: u64,
pub owner_id: Option<Uuid>,
pub etag: String,
/// BLAKE3 content hash. See [`File::content_hash`] for semantics.
pub blob_hash: String,
}
/**
@@ -66,8 +69,14 @@ 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,
/// BLAKE3 content hash. Stable across renames/moves, changes only
/// when the file's content bytes change. Source of truth for both
/// content-addressable storage and the HTTP ETag (via
/// [`File::etag`]). Exposed publicly via [`File::content_hash`]
/// so the REST API can surface it as a distinct concept from the
/// ETag (the ETag formula may grow to include `modified_at` etc.,
/// but `content_hash` remains the raw hash).
blob_hash: String,
}
// We no longer need this module, now we use a String directly
@@ -85,7 +94,7 @@ impl Default for File {
created_at: 0,
modified_at: 0,
owner_id: None,
etag: String::new(),
blob_hash: String::new(),
}
}
}
@@ -100,6 +109,7 @@ impl File {
mime_type: String,
folder_id: Option<String>,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
@@ -123,7 +133,7 @@ impl File {
created_at: now,
modified_at: now,
owner_id: None,
etag: String::new(),
blob_hash: String::new(),
})
}
@@ -136,6 +146,7 @@ impl File {
created_at: u64,
modified_at: u64,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
@@ -154,7 +165,7 @@ impl File {
created_at,
modified_at,
owner_id: None,
etag: String::new(),
blob_hash: String::new(),
})
}
@@ -170,7 +181,7 @@ impl File {
modified_at: u64,
owner_id: Option<Uuid>,
) -> FileResult<Self> {
Self::with_timestamps_and_etag(
Self::with_timestamps_and_blob_hash(
id,
name,
storage_path,
@@ -185,7 +196,7 @@ impl File {
}
#[allow(clippy::too_many_arguments)]
pub fn with_timestamps_and_etag(
pub fn with_timestamps_and_blob_hash(
id: String,
name: String,
storage_path: StoragePath,
@@ -195,8 +206,9 @@ impl File {
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
etag: String,
blob_hash: String,
) -> FileResult<Self> {
let name = normalize_storage_name(&name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
}
@@ -215,7 +227,7 @@ impl File {
created_at,
modified_at,
owner_id,
etag,
blob_hash,
})
}
@@ -235,12 +247,67 @@ impl File {
created_at: self.created_at,
modified_at: self.modified_at,
owner_id: self.owner_id,
etag: self.etag,
blob_hash: self.blob_hash,
}
}
pub fn etag(&self) -> &str {
&self.etag
/// Raw BLAKE3 content hash — the cryptographic identity of the
/// file's bytes. Stable across renames, moves, and metadata
/// updates. Changes only when the underlying content changes.
///
/// This is **distinct from [`File::etag`]**: the ETag is an HTTP
/// cache token that may incorporate non-content signals (mtime,
/// permissions, …) in future revisions; `content_hash` is the
/// raw hash, suitable for content-addressable URLs, dedup
/// verification, and integrity audits. Keep both accessible —
/// the API layer can choose to expose `content_hash` even when
/// `etag` grows additional inputs.
pub fn content_hash(&self) -> &str {
&self.blob_hash
}
/// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap
/// in `"…"` themselves at the HTTP boundary.
///
/// This is a thin instance-method wrapper around
/// [`File::compute_etag`] — see that function for the full
/// formula, rationale, and the "single source of truth"
/// guarantee that lets raw-row listings (`/api/folders/{id}/resources`,
/// favorites, trash, recents, REPORT/SEARCH) compute the same
/// value without constructing a full `File` entity.
pub fn etag(&self) -> String {
Self::compute_etag(&self.blob_hash, self.modified_at)
}
/// Pure formula for the file ETag, exposed as a static method so
/// listing handlers that operate on raw SQL rows (rather than
/// fully-constructed `File` entities) route through the same
/// definition.
///
/// **Formula**: `{blob_hash[..16]}-{modified_at}`.
///
/// - The 16-char BLAKE3 prefix is the content identity (64 bits
/// ≈ 10⁻⁹ collision probability over 10M files).
/// - `modified_at` (Unix seconds) catches the `x-oc-mtime`
/// case: NextCloud preserves the client-side mtime on upload,
/// so a "touch-then-resync" of unchanged content still bumps
/// the mtime — without the suffix the ETag wouldn't change
/// and clients would serve stale metadata.
/// - When `blob_hash` is shorter than 16 chars (test fixtures,
/// stub entities) the prefix is just the whole value.
/// - Folder ETags follow a separate formula — see
/// [`crate::domain::entities::folder::Folder::compute_etag`].
///
/// Every handler that emits a file ETag header MUST go through
/// this function (directly or via [`File::etag`] /
/// `FileDto::etag`) so `GET`, `HEAD`, `PROPFIND`, `PUT`
/// response, `MOVE`, and every JSON listing return
/// byte-identical values for the same file. Changing the
/// formula here changes it everywhere — that is the property
/// we want.
pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String {
let prefix: String = blob_hash.chars().take(16).collect();
format!("{}-{}", prefix, modified_at)
}
// Getters
@@ -298,7 +365,11 @@ impl File {
// Create storage_path from string
let storage_path = StoragePath::from_string(&path);
// Create directly without validation to avoid errors in DTO conversions
// Create directly without validation to avoid errors in DTO
// conversions. Still NFC-normalize so even DTO-reconstructed
// entities maintain the storage invariant.
let name = normalize_storage_name(&name);
Self {
id,
name,
@@ -310,7 +381,7 @@ impl File {
created_at,
modified_at,
owner_id: None,
etag: String::new(),
blob_hash: String::new(),
}
}
@@ -318,6 +389,7 @@ impl File {
/// Creates a new version of the file with updated name
pub fn with_name(&self, new_name: String) -> FileResult<Self> {
let new_name = normalize_storage_name(&new_name);
if let Err(reason) = validate_storage_name(&new_name) {
return Err(FileError::InvalidFileName(format!("{new_name}: {reason}")));
}
@@ -348,7 +420,7 @@ impl File {
created_at: self.created_at,
modified_at: now,
owner_id: self.owner_id,
etag: self.etag.clone(),
blob_hash: self.blob_hash.clone(),
})
}
@@ -383,7 +455,7 @@ impl File {
created_at: self.created_at,
modified_at: now,
owner_id: self.owner_id,
etag: self.etag.clone(),
blob_hash: self.blob_hash.clone(),
})
}
@@ -405,7 +477,7 @@ impl File {
created_at: self.created_at,
modified_at: now,
owner_id: self.owner_id,
etag: self.etag.clone(),
blob_hash: self.blob_hash.clone(),
}
}
}
@@ -467,4 +539,77 @@ mod tests {
assert_eq!(renamed.name(), "newname.txt");
assert_eq!(renamed.id(), "123"); // The ID does not change
}
/// The ETag formula is `{blob_hash[..16]}-{modified_at}`. Two
/// fixtures with identical content + mtime must produce
/// byte-identical ETags — that's the invariant every handler
/// relies on when comparing a cached client ETag against a
/// freshly-loaded one.
#[test]
fn test_etag_combines_blob_hash_prefix_and_mtime() {
let file = File::with_timestamps_and_blob_hash(
"id-1".to_string(),
"file.txt".to_string(),
StoragePath::from_string("/file.txt"),
42,
"text/plain".to_string(),
None,
1_000,
2_000,
None,
"abcdef0123456789ZZZZZZZZ".to_string(),
)
.unwrap();
// content_hash stays raw — full blob hash, no truncation.
assert_eq!(file.content_hash(), "abcdef0123456789ZZZZZZZZ");
// etag is the 16-char prefix + "-" + mtime.
assert_eq!(file.etag(), "abcdef0123456789-2000");
}
/// When the blob hash is shorter than 16 chars (test fixtures,
/// stub entities), the prefix degrades to "whatever is there".
/// Production blob hashes are always full BLAKE3 hex (64 chars).
#[test]
fn test_etag_short_blob_hash_uses_full_value() {
let file = File::with_timestamps_and_blob_hash(
"id-1".to_string(),
"file.txt".to_string(),
StoragePath::from_string("/file.txt"),
42,
"text/plain".to_string(),
None,
1_000,
2_000,
None,
"shorthash".to_string(),
)
.unwrap();
assert_eq!(file.etag(), "shorthash-2000");
}
/// `content_hash` is the cryptographic identity of the bytes —
/// it must NEVER change because of metadata operations like
/// rename. The ETag is allowed to change (because `with_name`
/// bumps `modified_at`), but the content hash is not.
#[test]
fn test_content_hash_stable_across_rename() {
let file = File::with_timestamps_and_blob_hash(
"id-1".to_string(),
"file.txt".to_string(),
StoragePath::from_string("/file.txt"),
42,
"text/plain".to_string(),
None,
1_000,
2_000,
None,
"stable-content-hash".to_string(),
)
.unwrap();
let renamed = file.with_name("renamed.txt".to_string()).unwrap();
assert_eq!(renamed.content_hash(), "stable-content-hash");
}
}
+205 -8
View File
@@ -1,6 +1,8 @@
use uuid::Uuid;
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
use crate::domain::services::path_service::{
StoragePath, normalize_storage_name, validate_storage_name,
};
// Re-export entity errors from the centralized module
pub use super::entity_errors::{FolderError, FolderResult};
@@ -30,8 +32,17 @@ pub struct Folder {
/// Creation timestamp
created_at: u64,
/// Last modification timestamp
/// Last modification timestamp of THIS folder row (rename, move,
/// metadata change). Does NOT bump when descendants change —
/// that signal lives on `tree_modified_at`.
modified_at: u64,
/// Latest `modified_at`-equivalent across the entire descendant
/// subtree. Bumped by a PostgreSQL trigger on any file or folder
/// write under this folder's ltree subtree. Source of the
/// HTTP ETag emitted in PROPFIND/GET/HEAD responses — see
/// [`Folder::etag`] for the formula and rationale.
tree_modified_at: u64,
}
// We no longer need this module, now we use a String directly
@@ -47,6 +58,7 @@ impl Default for Folder {
owner_id: None,
created_at: 0,
modified_at: 0,
tree_modified_at: 0,
}
}
}
@@ -70,6 +82,7 @@ impl Folder {
parent_id: Option<String>,
owner_id: Option<Uuid>,
) -> FolderResult<Self> {
let name = normalize_storage_name(&name);
// Validate folder name
if let Err(reason) = validate_storage_name(&name) {
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
@@ -92,10 +105,15 @@ impl Folder {
owner_id,
created_at: now,
modified_at: now,
tree_modified_at: now,
})
}
/// Creates a folder with specific timestamps (for reconstruction)
/// Creates a folder with specific timestamps (for reconstruction).
/// `tree_modified_at` defaults to `modified_at` — appropriate for
/// in-memory construction; database loads should always go via
/// [`Folder::with_timestamps_and_tree`] so the rollup value
/// reflects DB reality.
pub fn with_timestamps(
id: String,
name: String,
@@ -104,7 +122,7 @@ impl Folder {
created_at: u64,
modified_at: u64,
) -> FolderResult<Self> {
Self::with_timestamps_and_owner(
Self::with_timestamps_and_tree(
id,
name,
storage_path,
@@ -112,10 +130,15 @@ impl Folder {
None,
created_at,
modified_at,
modified_at,
)
}
/// Creates a folder with specific timestamps and owner (for DB reconstruction)
/// Creates a folder with specific timestamps and owner (legacy
/// constructor — `tree_modified_at` defaults to `modified_at`).
/// Prefer [`Folder::with_timestamps_and_tree`] for DB reconstruction
/// so the rollup ETag reflects descendant activity, not just this
/// row's own metadata.
pub fn with_timestamps_and_owner(
id: String,
name: String,
@@ -125,12 +148,37 @@ impl Folder {
created_at: u64,
modified_at: u64,
) -> FolderResult<Self> {
// Validate folder name
Self::with_timestamps_and_tree(
id,
name,
storage_path,
parent_id,
owner_id,
created_at,
modified_at,
modified_at,
)
}
/// Full constructor used by the PG repository when reading rows.
/// `tree_modified_at` comes from the trigger-maintained column on
/// `storage.folders` and feeds [`Folder::etag`].
#[allow(clippy::too_many_arguments)]
pub fn with_timestamps_and_tree(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
created_at: u64,
modified_at: u64,
tree_modified_at: u64,
) -> FolderResult<Self> {
let name = normalize_storage_name(&name);
if let Err(reason) = validate_storage_name(&name) {
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
}
// Store the path string for serialization compatibility
let path_string = storage_path.to_string();
Ok(Self {
@@ -142,6 +190,7 @@ impl Folder {
owner_id,
created_at,
modified_at,
tree_modified_at,
})
}
@@ -178,6 +227,54 @@ impl Folder {
self.owner_id
}
/// Latest descendant-write timestamp, maintained by a Postgres
/// trigger that walks the ltree ancestor chain on every file or
/// folder write inside this folder's subtree. See migration
/// `20260625000000_folder_tree_modified_at.sql` for the trigger
/// definition.
pub fn tree_modified_at(&self) -> u64 {
self.tree_modified_at
}
/// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap
/// in `"…"` themselves at the HTTP boundary.
///
/// Thin instance-method wrapper around [`Folder::compute_etag`]
/// — see that function for the formula and the rationale.
/// Raw-row listings (favorites, trash, recents, search) call
/// the static form so the same formula governs every code path.
pub fn etag(&self) -> String {
Self::compute_etag(&self.id, self.tree_modified_at)
}
/// Pure formula for the folder ETag, exposed as a static method
/// so callers that don't have a fully-constructed `Folder` (raw
/// SQL rows in listing handlers, search results, etc.) route
/// through the same definition.
///
/// **Formula**: `{id[..16]}-{tree_modified_at}`.
///
/// - The 16-char UUID prefix gives the folder its identity
/// component — keeps two empty same-mtime folders distinct.
/// - `tree_modified_at` (Unix seconds) is the actual signal:
/// bumped by trigger whenever ANY descendant (file or
/// sub-folder, at any depth) is created, modified, deleted,
/// or moved. This is the contract NextCloud's sync engine
/// relies on — "did anything change inside this collection
/// since I last looked?". Until this column existed, the
/// answer was always "no" because the folder UUID never
/// changed; clients had to do periodic deep PROPFIND walks
/// to discover web-uploaded files.
/// - Renaming the folder itself does NOT change the etag's
/// identity portion (UUID is stable across renames). The
/// trigger does bump `tree_modified_at` on rename via the
/// folder-side trigger, so the etag still changes — which is
/// correct, the parent collection's listing changed.
pub fn compute_etag(id: &str, tree_modified_at: u64) -> String {
let prefix: String = id.chars().take(16).collect();
format!("{}-{}", prefix, tree_modified_at)
}
/// Creates a new Folder instance from a DTO
/// This function is primarily for conversions in batch handlers
pub fn from_dto(
@@ -191,7 +288,14 @@ impl Folder {
// Create storage_path from the string
let storage_path = StoragePath::from_string(&path);
// Create directly without validation to avoid errors in DTO conversions
// Create directly without validation to avoid errors in DTO
// conversions. Still NFC-normalize so DTO-reconstructed
// entities maintain the storage invariant.
// `tree_modified_at` defaults to `modified_at`: DTO
// round-trips lose the real rollup signal, so callers that
// need a freshly-rolled-up etag must reload from the
// repository.
let name = normalize_storage_name(&name);
Self {
id,
name,
@@ -201,6 +305,7 @@ impl Folder {
owner_id: None,
created_at,
modified_at,
tree_modified_at: modified_at,
}
}
@@ -208,6 +313,7 @@ impl Folder {
/// Creates a new version of the folder with updated name
pub fn with_name(&self, new_name: String) -> FolderResult<Self> {
let new_name = normalize_storage_name(&new_name);
if let Err(reason) = validate_storage_name(&new_name) {
return Err(FolderError::InvalidFolderName(format!(
"{new_name}: {reason}"
@@ -238,6 +344,10 @@ impl Folder {
owner_id: self.owner_id,
created_at: self.created_at,
modified_at: now,
// Renaming bumps both self and descendant rollup —
// ancestors' listings now show a new name, so the
// collection has materially changed.
tree_modified_at: now,
})
}
@@ -270,6 +380,7 @@ impl Folder {
owner_id: self.owner_id,
created_at: self.created_at,
modified_at: now,
tree_modified_at: now,
})
}
@@ -343,4 +454,90 @@ mod tests {
assert_eq!(renamed.name(), "new_name");
assert_eq!(renamed.id(), "123"); // The ID doesn't change
}
/// The folder ETag is `{id[..16]}-{tree_modified_at}`. Two
/// fixtures with identical id-prefix + tree_modified_at must
/// produce byte-identical ETags — that's what NC's incremental
/// sync relies on across PROPFIND cycles.
#[test]
fn test_etag_combines_id_prefix_and_tree_modified_at() {
let folder = Folder::with_timestamps_and_tree(
"0123456789abcdefZZZZZZZZ".to_string(),
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
1_000,
2_000,
5_000,
)
.unwrap();
assert_eq!(folder.tree_modified_at(), 5_000);
assert_eq!(folder.etag(), "0123456789abcdef-5000");
}
/// Two folders with the same `tree_modified_at` but different
/// IDs must NOT collide on ETag — the id prefix is the identity
/// portion that keeps them distinct.
#[test]
fn test_etag_distinct_folders_same_tree_mtime() {
let a = Folder::with_timestamps_and_tree(
"aaaaaaaaaaaaaaaaZZZZZZZZ".to_string(),
"a".to_string(),
StoragePath::from_string("/a"),
None,
None,
0,
0,
42,
)
.unwrap();
let b = Folder::with_timestamps_and_tree(
"bbbbbbbbbbbbbbbbZZZZZZZZ".to_string(),
"b".to_string(),
StoragePath::from_string("/b"),
None,
None,
0,
0,
42,
)
.unwrap();
assert_ne!(a.etag(), b.etag());
}
/// `tree_modified_at` is the actual change-detection signal —
/// the trigger bumps it for descendant writes. Renaming the
/// folder bumps both `modified_at` and `tree_modified_at`
/// (the parent collection's listing changed), and the etag
/// must reflect that — otherwise NC won't notice the rename.
#[test]
fn test_etag_changes_when_tree_modified_at_changes() {
let folder_a = Folder::with_timestamps_and_tree(
"abcd1234efgh5678ZZZZZZZZ".to_string(),
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
1_000,
2_000,
3_000,
)
.unwrap();
let folder_b = Folder::with_timestamps_and_tree(
"abcd1234efgh5678ZZZZZZZZ".to_string(),
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
1_000,
2_000,
4_000,
)
.unwrap();
assert_ne!(folder_a.etag(), folder_b.etag());
}
}
+62
View File
@@ -5,6 +5,29 @@
//! infrastructure/services/path_service.rs because it has file system dependencies.
use std::path::PathBuf;
use unicode_normalization::UnicodeNormalization;
/// NFC-normalize a single file or folder name component.
///
/// The storage layer (PostgreSQL `storage.files.name` and
/// `storage.folders.name`) compares bytes literally — there is no
/// Unicode-aware collation in either UNIQUE index. macOS APFS stores
/// filenames in NFD (decomposed: `é` = `e` + U+0301), while browsers
/// and most other clients post NFC (`é` = U+00E9). Without
/// normalization, the same logical filename can land as two distinct
/// rows: one from a web upload, one from a NextCloud desktop client
/// re-upload of the round-tripped name. The UNIQUE index does not
/// catch it because the bytes differ.
///
/// This function is called at every name-receiving boundary (entity
/// constructors, repository path lookups) so the database invariant
/// becomes "every stored name is NFC". A one-shot migration
/// (`migrate-nfc-filenames`) cleans up rows that pre-date this rule.
///
/// Pure function — no I/O, allocates one `String`.
pub fn normalize_storage_name(name: &str) -> String {
name.nfc().collect()
}
/// Validates a single file or folder name component.
///
@@ -251,4 +274,43 @@ mod tests {
assert!(!path.segments().contains(&"..".to_string()));
assert!(!path.segments().contains(&".".to_string()));
}
// ── NFC normalization tests ─────────────────────────────────
/// Plain ASCII names must round-trip identical bytes.
#[test]
fn test_normalize_ascii_unchanged() {
assert_eq!(normalize_storage_name("file.txt"), "file.txt");
assert_eq!(normalize_storage_name("My Documents"), "My Documents");
}
/// The macOS APFS / NextCloud-desktop pathological case: `é`
/// decomposed as `e` + combining acute (U+0301). Stored bytes
/// `65 cc 81` collapse to NFC `c3 a9`.
#[test]
fn test_normalize_nfd_to_nfc() {
let nfd = "caf\u{0065}\u{0301}";
let nfc = "caf\u{00E9}";
assert_ne!(nfd.as_bytes(), nfc.as_bytes());
assert_eq!(normalize_storage_name(nfd), nfc);
}
/// Already-NFC input must round-trip unchanged. This is the
/// idempotence property the boundary normalization relies on.
#[test]
fn test_normalize_nfc_idempotent() {
let nfc = "Capture d\u{2019}\u{00E9}cran.png";
assert_eq!(normalize_storage_name(nfc), nfc);
// And applying twice is the same as once.
assert_eq!(normalize_storage_name(&normalize_storage_name(nfc)), nfc);
}
/// Multi-codepoint NFD sequences (combining acute + grave +
/// typographic apostrophe) all converge to a single NFC form.
#[test]
fn test_normalize_mixed_accents() {
let nfd = "Capture d\u{2019}\u{0065}\u{0301}cran a\u{0300}.png";
let nfc = "Capture d\u{2019}\u{00E9}cran \u{00E0}.png";
assert_eq!(normalize_storage_name(nfd), nfc);
}
}
@@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at,
fld.path::text AS resource_path,
@@ -332,6 +333,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -574,6 +576,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
@@ -131,11 +131,11 @@ impl FileBlobReadRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
etag: String,
blob_hash: String,
owner_id: Option<Uuid>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_and_etag(
File::with_timestamps_and_blob_hash(
id,
name,
storage_path,
@@ -145,7 +145,7 @@ impl FileBlobReadRepository {
created_at as u64,
modified_at as u64,
owner_id,
etag,
blob_hash,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
@@ -226,9 +226,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, etag, uid, sd) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, etag, uid,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?);
sort_dates.push(sd);
}
@@ -410,9 +410,11 @@ 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, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -466,9 +468,11 @@ 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, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -532,9 +536,11 @@ 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, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -598,9 +604,11 @@ impl FileReadPort for FileBlobReadRepository {
})?;
rows.into_iter()
.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)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -699,8 +707,14 @@ impl FileReadPort for FileBlobReadRepository {
return Ok(None);
}
// Last segment is the filename, preceding segments are the folder path
let filename = segments[segments.len() - 1];
// Last segment is the filename, preceding segments are the
// folder path. NFC-normalize the filename so a NextCloud
// client's NFD-encoded path still hits the NFC row stored
// by a web upload — see `normalize_storage_name` for the
// full rationale.
let filename = crate::domain::services::path_service::normalize_storage_name(
segments[segments.len() - 1],
);
let folder_path = segments[..segments.len() - 1].join("/");
let row = if folder_path.is_empty() {
@@ -815,9 +829,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, etag, uid) = row;
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) = row;
let file = FileBlobReadRepository::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, etag, uid,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?;
yield file;
}
@@ -930,8 +944,8 @@ impl FileReadPort for FileBlobReadRepository {
let files = rows
.into_iter()
.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)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1116,8 +1130,8 @@ impl FileReadPort for FileBlobReadRepository {
let files = rows
.into_iter()
.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)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1212,9 +1226,11 @@ 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, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
}
@@ -101,10 +101,10 @@ impl FileBlobWriteRepository {
created_at: i64,
modified_at: i64,
owner_id: Option<Uuid>,
etag: String,
blob_hash: String,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_and_etag(
File::with_timestamps_and_blob_hash(
id,
name,
storage_path,
@@ -114,7 +114,7 @@ impl FileBlobWriteRepository {
created_at as u64,
modified_at as u64,
owner_id,
etag,
blob_hash,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
@@ -20,10 +20,25 @@ use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::path_service::StoragePath;
/// Type alias for folder metadata rows from SQL queries.
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64);
/// Tuple order: id, name, path, parent_id, user_id, created_at,
/// modified_at, tree_modified_at. The trailing `tree_modified_at`
/// feeds [`Folder::etag`] — every SELECT here must include
/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64, i64);
/// Type alias for paginated folder rows (includes total_count).
type FolderRowPaginated = (String, String, String, Option<String>, Uuid, i64, i64, i64);
/// Type alias for paginated folder rows (includes total_count as
/// the last element after `tree_modified_at`).
type FolderRowPaginated = (
String,
String,
String,
Option<String>,
Uuid,
i64,
i64,
i64,
i64,
);
/// Type alias for folder rows with optional user_id.
type FolderRowOptUser = (
@@ -34,6 +49,7 @@ type FolderRowOptUser = (
Option<Uuid>,
i64,
i64,
i64,
);
/// PostgreSQL-backed folder repository.
@@ -68,6 +84,7 @@ impl FolderDbRepository {
///
/// The `path` comes directly from the materialized `path` column — no
/// extra queries needed.
#[allow(clippy::too_many_arguments)]
fn row_to_folder(
id: String,
name: String,
@@ -76,9 +93,10 @@ impl FolderDbRepository {
user_id: Option<Uuid>,
created_at: i64,
modified_at: i64,
tree_modified_at: i64,
) -> Result<Folder, DomainError> {
let storage_path = StoragePath::from_string(&path);
Folder::with_timestamps_and_owner(
Folder::with_timestamps_and_tree(
id,
name,
storage_path,
@@ -86,6 +104,7 @@ impl FolderDbRepository {
user_id,
created_at as u64,
modified_at as u64,
tree_modified_at as u64,
)
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
}
@@ -116,14 +135,15 @@ impl FolderRepository for FolderDbRepository {
));
};
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, $2::uuid, $3)
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&name)
@@ -143,15 +163,25 @@ impl FolderRepository for FolderDbRepository {
DomainError::internal_error("FolderDb", format!("insert: {e}"))
})?;
Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3)
Self::row_to_folder(
row.0,
name,
row.1,
parent_id,
Some(user_id),
row.2,
row.3,
row.4,
)
}
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE id = $1::uuid AND NOT is_trashed
"#,
@@ -162,7 +192,7 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
@@ -174,11 +204,12 @@ impl FolderRepository for FolderDbRepository {
return Err(DomainError::not_found("Folder", "empty path"));
}
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE path = $1 AND NOT is_trashed
"#,
@@ -189,7 +220,7 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
#[allow(clippy::type_complexity)]
@@ -199,7 +230,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid AND NOT is_trashed
ORDER BY name
@@ -213,7 +245,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL AND NOT is_trashed
ORDER BY name
@@ -225,8 +258,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect()
}
@@ -242,7 +275,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name
@@ -257,7 +291,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name
@@ -270,8 +305,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect()
}
@@ -293,6 +328,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND NOT is_trashed
@@ -311,6 +347,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND NOT is_trashed
@@ -327,15 +364,15 @@ impl FolderRepository for FolderDbRepository {
// total_count is identical in every row; 0 when the result set is empty.
let total = if include_total {
Some(rows.first().map_or(0, |r| r.7) as usize)
Some(rows.first().map_or(0, |r| r.8) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect();
Ok((folders?, total))
@@ -358,6 +395,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
@@ -377,6 +415,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
@@ -393,15 +432,15 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
let total = if include_total {
Some(rows.first().map_or(0, |r| r.7) as usize)
Some(rows.first().map_or(0, |r| r.8) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect();
Ok((folders?, total))
@@ -411,14 +450,15 @@ impl FolderRepository for FolderDbRepository {
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET name = $1, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&new_name)
@@ -435,7 +475,7 @@ impl FolderRepository for FolderDbRepository {
})?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn move_folder(
@@ -446,14 +486,15 @@ impl FolderRepository for FolderDbRepository {
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET parent_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(new_parent_id)
@@ -463,7 +504,7 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
@@ -622,7 +663,7 @@ impl FolderRepository for FolderDbRepository {
}
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
@@ -630,7 +671,8 @@ impl FolderRepository for FolderDbRepository {
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&name)
@@ -640,17 +682,18 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
match row {
Some((id, path, ca, ma)) => {
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma)
Some((id, path, ca, ma, tma)) => {
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma)
}
None => {
// Already exists — fetch it
let existing = sqlx::query_as::<_, (String, String, i64, i64)>(
let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
SELECT id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
"#,
@@ -668,6 +711,7 @@ impl FolderRepository for FolderDbRepository {
Some(user_id),
existing.2,
existing.3,
existing.4,
)
}
}
@@ -682,7 +726,8 @@ impl FolderRepository for FolderDbRepository {
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.is_trashed = false \
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
@@ -697,8 +742,8 @@ impl FolderRepository for FolderDbRepository {
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect()
}
@@ -744,7 +789,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
@@ -768,8 +814,8 @@ impl FolderRepository for FolderDbRepository {
return rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect();
}
@@ -780,7 +826,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.parent_id = $1::uuid \
AND fo.user_id = $2 \
@@ -798,7 +845,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND fo.user_id = $1 \
@@ -838,8 +886,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect()
}
@@ -866,7 +914,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
@@ -893,8 +942,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect()
}
@@ -914,7 +963,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid
AND NOT is_trashed
@@ -939,7 +989,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL
AND NOT is_trashed
@@ -962,8 +1013,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect()
}
@@ -1100,6 +1151,7 @@ impl FolderDbRepository {
f.created_at,
f.updated_at AS modified_at,
f.user_id,
NULL::text AS blob_hash,
LOWER(f.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
@@ -1118,6 +1170,7 @@ impl FolderDbRepository {
fm.created_at,
fm.updated_at AS modified_at,
fm.user_id,
fm.blob_hash,
LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order,
1::int AS folder_first
@@ -1241,7 +1294,7 @@ impl FolderDbRepository {
let sql = format!(
"WITH resources AS ({cte_inner}) \
SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, user_id, sort_str, type_order, folder_first \
created_at, modified_at, user_id, blob_hash, sort_str, type_order, folder_first \
FROM resources \
{where_clause} \
{order_clause} \
@@ -1249,7 +1302,8 @@ impl FolderDbRepository {
);
// Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, user_id, sort_str, type_order, folder_first)
// created_at, modified_at, user_id, blob_hash,
// sort_str, type_order, folder_first)
type Row = (
String,
Uuid,
@@ -1260,6 +1314,7 @@ impl FolderDbRepository {
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
Uuid,
Option<String>,
String,
i64,
i32,
@@ -1290,9 +1345,10 @@ impl FolderDbRepository {
created_at: r.6,
modified_at: r.7,
owner_id: r.8,
sort_str: r.9,
type_order: r.10,
folder_first: r.11,
blob_hash: r.9,
sort_str: r.10,
type_order: r.11,
folder_first: r.12,
})
.collect())
}
@@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at,
fld.path::text AS resource_path,
@@ -239,6 +240,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -483,6 +485,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
@@ -261,6 +261,7 @@ impl TrashDbRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
NULL::text AS blob_hash,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
@@ -286,6 +287,7 @@ impl TrashDbRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.blob_hash,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -473,6 +475,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
trashed_at,
deletion_date,
path: row.try_get("resource_path").ok(),
@@ -145,6 +145,7 @@ impl PathResolverService {
match resource_type.as_str() {
"folder" => Ok(ResolvedResource::Folder(FolderDto {
etag: id.clone(),
id,
name: name.clone(),
path: res_path,
@@ -160,6 +161,11 @@ impl PathResolverService {
_ => {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
let sz = size.unwrap_or(0) as u64;
// `content_hash`/`etag` are empty here: this resolver
// path doesn't select `blob_hash` from SQL — callers
// are doing existence/type discrimination, not ETag
// emission. If a caller ever needs an ETag from this
// codepath, widen the SELECT and populate properly.
Ok(ResolvedResource::File(FileDto {
id,
name: name.clone(),
@@ -175,6 +181,7 @@ impl PathResolverService {
size_formatted: format_file_size(sz),
owner_id: uid,
sort_date: None,
content_hash: String::new(),
etag: String::new(),
}))
}
@@ -20,6 +20,7 @@ use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::services::favorites_service::FavoritesService;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -253,8 +254,10 @@ pub async fn list_favorites_resources(
};
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
id: row.resource_id.to_string(),
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
@@ -277,6 +280,18 @@ pub async fn list_favorites_resources(
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// Route ETag through `File::compute_etag` so
// this listing's `etag` byte-equals what
// GET/HEAD/PROPFIND would return for the same
// file. `blob_hash` is `None` only for
// folder rows, which take the other branch.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
@@ -285,7 +300,7 @@ pub async fn list_favorites_resources(
mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(
&row.name, mime,
@@ -294,7 +309,8 @@ pub async fn list_favorites_resources(
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
etag: String::new(),
content_hash,
etag,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File,
+5 -1
View File
@@ -593,7 +593,11 @@ impl FileHandler {
.into_response();
}
let etag = format!("\"{}-{}\"", id, file_dto.modified_at);
// Route through `FileDto::etag` so this REST download
// endpoint, WebDAV/NextCloud GET, HEAD, PROPFIND, and PUT all
// emit the same opaque token for the same file — see
// `File::etag` for the formula.
let etag = format!("\"{}\"", file_dto.etag);
// ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
+20 -2
View File
@@ -26,6 +26,7 @@ use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::di::AppState as GlobalAppState;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -692,8 +693,10 @@ pub async fn list_folder_resources(
.into_iter()
.map(|row| {
if row.resource_type == "folder" {
let resource_id = row.id.to_string();
let dto = FolderDto {
id: row.id.to_string(),
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path: String::new(), // cleared — share recipients must not see hierarchy
parent_id: row.parent_id.map(|u| u.to_string()),
@@ -715,6 +718,20 @@ pub async fn list_folder_resources(
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// `blob_hash` is `Some(_)` for file rows in the
// UNION ALL (`NULL` for folders). Route the
// ETag formula through `File::compute_etag` —
// the single source of truth shared with
// GET/HEAD/PROPFIND/PUT response — so this
// listing's `etag` byte-equals what a
// conditional request would compare against.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.id.to_string(),
name: row.name.clone(),
@@ -730,7 +747,8 @@ pub async fn list_folder_resources(
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
etag: String::new(),
content_hash,
etag,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
+17 -3
View File
@@ -19,6 +19,7 @@ use crate::application::dtos::recent_dto::{
};
use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::services::recent_service::RecentService;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid;
@@ -283,8 +284,10 @@ pub async fn list_recent_resources(
};
if row.resource_type == "folder" {
let resource_id = row.resource_id.to_string();
let dto = FolderDto {
id: row.resource_id.to_string(),
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
@@ -307,6 +310,16 @@ pub async fn list_recent_resources(
.as_deref()
.unwrap_or("application/octet-stream");
let size_bytes = row.size.max(0) as u64;
// Route ETag through `File::compute_etag` so this
// listing matches GET/HEAD/PROPFIND byte-for-byte
// for the same file.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
@@ -315,7 +328,7 @@ pub async fn list_recent_resources(
mime_type: std::sync::Arc::from(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
modified_at: modified_at_u,
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: std::sync::Arc::from(icon_special_class_for(
&row.name, mime,
@@ -324,7 +337,8 @@ pub async fn list_recent_resources(
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
etag: String::new(),
content_hash,
etag,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
+143 -12
View File
@@ -69,6 +69,37 @@ pub(crate) fn encode_uri_path(path: &str) -> String {
.join("/")
}
/// Build the `<D:href>` value for a non-collection (file) resource.
///
/// RFC 4918 §5.2 distinguishes collection (folder) URLs from
/// non-collection URLs by a trailing `/`. Files use NO trailing
/// slash. Mirror of [`webdav_collection_href`] — keep both arms
/// of the choice on the same screen so an "is it a file or a
/// folder?" reviewer can verify both branches at once.
fn webdav_href(path: &str) -> String {
format!("/webdav/{}", encode_uri_path(path))
}
/// Build the `<D:href>` value for a collection (folder) resource.
///
/// Always terminates with `/` — RFC 4918 §5.2 requires collection
/// URLs to end in a slash, and strict WebDAV clients (notably the
/// NextCloud desktop sync engine, which also speaks to this
/// endpoint) abort multi-status parses with
/// `Invalid href "<…>" expected starting with "<requested-url>"`
/// when the response's own-entry href is missing the trailing `/`.
/// PROPPATCH and LOCK responses on folders MUST use this — using
/// [`webdav_href`] for a folder is the bug class this helper
/// exists to prevent.
fn webdav_collection_href(path: &str) -> String {
let h = webdav_href(path);
if h.ends_with('/') {
h
} else {
format!("{}/", h)
}
}
// Create a custom DAV header since it's not in the standard headers
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token");
@@ -353,6 +384,7 @@ async fn handle_propfind(
// Root folder
let root_folder = FolderDto {
id: "root".to_string(),
etag: "root".to_string(),
name: "".to_string(),
path: "".to_string(),
parent_id: None,
@@ -604,12 +636,35 @@ async fn build_streaming_propfind_response(
* @return XML response with property modification results
*/
async fn handle_proppatch(
_state: Arc<AppState>,
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let _user = extract_user(&req)?;
// Resolve the target resource type BEFORE consuming the body so
// we can pick the correct href shape in the multi-status
// response. RFC 4918 §5.2 + strict WebDAV-client parser rules
// require a trailing `/` for collection hrefs; emitting
// `/webdav/foo` for a folder breaks NC-desktop / Cyberduck /
// other multi-status consumers the same way the NC PROPFIND
// bug did. An empty / `/` path is the root, always a
// collection. A path that resolves to neither file nor folder
// (e.g. PROPPATCH on a resource that doesn't exist) defaults
// to non-collection — matches the request-line shape the
// client used, since collection paths conventionally arrive
// with trailing `/` already trimmed by routing.
let is_collection = if path.is_empty() || path == "/" {
true
} else {
state
.applications
.folder_service
.get_folder_by_path(&path)
.await
.is_ok()
};
// Read request body (XML — bounded to 1 MB)
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
.await
@@ -635,8 +690,12 @@ async fn handle_proppatch(
results.push((prop, true));
}
// Generate response
let href = format!("/webdav/{}", encode_uri_path(&path));
// Generate response — collection vs file href chosen above.
let href = if is_collection {
webdav_collection_href(&path)
} else {
webdav_href(&path)
};
let mut response_body = Vec::new();
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
@@ -706,7 +765,7 @@ async fn handle_get(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
@@ -747,7 +806,7 @@ async fn handle_head(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "httpd/unix-directory")
.header(header::CONTENT_LENGTH, 0)
.header(header::ETAG, format!("\"{}\"", folder.id))
.header(header::ETAG, format!("\"{}\"", folder.etag))
.body(Body::empty())
.unwrap());
}
@@ -756,7 +815,7 @@ async fn handle_head(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
@@ -777,7 +836,7 @@ async fn handle_head(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "httpd/unix-directory")
.header(header::CONTENT_LENGTH, 0)
.header(header::ETAG, format!("\"{}\"", folder.id))
.header(header::ETAG, format!("\"{}\"", folder.etag))
.body(Body::empty())
.unwrap());
}
@@ -793,7 +852,7 @@ async fn handle_head(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
@@ -1686,6 +1745,24 @@ async fn handle_lock(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
// Determine collection-vs-file for href shape. Root + known
// folders → collection; everything else (existing files,
// lock-null on a non-existent path) → file. RFC 4918 §9.10.1
// allows LOCK on a non-existent resource (the "lock-null
// resource" pattern used by Office save flows) — that arm
// falls through to the file href shape, matching the
// request-line shape clients send.
let is_collection = if path.is_empty() || path == "/" {
true
} else {
state
.applications
.folder_service
.get_folder_by_path(&path)
.await
.is_ok()
};
// Get the headers that we need
let depth = req
.headers()
@@ -1735,8 +1812,12 @@ async fn handle_lock(
AppError::precondition_failed(format!("Lock token not found or expired: {}", token))
})?;
// Generate response
let href = format!("/webdav/{}", encode_uri_path(&path));
// Generate response — collection vs file href chosen above.
let href = if is_collection {
webdav_collection_href(&path)
} else {
webdav_href(&path)
};
let mut response_body = Vec::new();
WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err(
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
@@ -1771,8 +1852,12 @@ async fn handle_lock(
))
})?;
// Generate response
let href = format!("/webdav/{}", encode_uri_path(&path));
// Generate response — collection vs file href chosen above.
let href = if is_collection {
webdav_collection_href(&path)
} else {
webdav_href(&path)
};
let mut response_body = Vec::new();
WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err(
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
@@ -1836,3 +1921,49 @@ async fn handle_unlock(
.body(Body::empty())
.unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_webdav_href_no_trailing_slash() {
assert_eq!(
webdav_href("Documents/report.pdf"),
"/webdav/Documents/report.pdf"
);
assert_eq!(webdav_href("file.txt"), "/webdav/file.txt");
}
#[test]
fn test_webdav_collection_href_appends_slash_when_missing() {
assert_eq!(webdav_collection_href("Documents"), "/webdav/Documents/");
assert_eq!(
webdav_collection_href("Documents/subfolder"),
"/webdav/Documents/subfolder/"
);
}
#[test]
fn test_webdav_collection_href_idempotent_when_already_slashed() {
// `encode_uri_path` never emits a trailing `/` of its own
// because the path argument is already trimmed by routing,
// but the helper still has to be robust to a path that
// happens to end in `/` — exercise the idempotence path.
assert_eq!(webdav_collection_href("Documents/"), "/webdav/Documents/");
}
#[test]
fn test_webdav_href_preserves_url_encoding() {
// Spaces and Unicode must percent-encode at the segment level,
// not get a verbatim `%20` re-encoded as `%2520`.
assert_eq!(
webdav_href("My Photos/vacation pic.jpg"),
"/webdav/My%20Photos/vacation%20pic.jpg"
);
assert_eq!(
webdav_collection_href("My Photos/2024"),
"/webdav/My%20Photos/2024/"
);
}
}
@@ -114,6 +114,15 @@ pub async fn basic_auth_middleware(
);
return Err(NextcloudAuthError::Unauthorized);
}
// Populate the deferred `user_id` field on the request
// tracing span (declared in `middleware/trace_span.rs::ClientIpMakeSpan`).
// Mirrors what `interfaces/middleware/auth.rs` does for the
// JWT path so the two auth surfaces produce log lines with
// the same structured shape — without this, every NC
// request would appear in the logs with `user_id=-`,
// making it harder to correlate WebDAV / OCS activity to
// a specific principal.
tracing::Span::current().record("user_id", user_id.to_string());
request.extensions_mut().insert(Arc::new(CurrentUser {
id: user_id,
username: uname,
+14 -1
View File
@@ -21,6 +21,7 @@ use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
use crate::domain::entities::file::File;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
use crate::interfaces::nextcloud::webdav_handler::{
@@ -250,6 +251,16 @@ async fn handle_search(
/// Build a `FileDto` from a search file result.
fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto {
// Route ETag through `File::compute_etag` so REPORT/SEARCH hits
// emit the same opaque token NC's sync client cached from the
// earlier PROPFIND walk — without this, NC's conditional-request
// logic on search results disagrees with its own cached state
// and triggers a spurious re-fetch.
let etag = if fr.blob_hash.is_empty() {
String::new()
} else {
File::compute_etag(&fr.blob_hash, fr.modified_at)
};
FileDto {
id: fr.id.clone(),
name: fr.name.clone(),
@@ -267,7 +278,8 @@ 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(),
content_hash: fr.blob_hash.clone(),
etag,
}
}
@@ -276,6 +288,7 @@ fn folder_dto_from_search(
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
) -> FolderDto {
FolderDto {
etag: sr.id.clone(),
id: sr.id.clone(),
name: sr.name.clone(),
path: sr.path.clone(),
+133 -25
View File
@@ -62,10 +62,32 @@ pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result<String, AppE
Ok(format!("{}/{}", home, subpath))
}
/// Build the Nextcloud DAV href for a **collection** (folder). Always
/// terminates with `/` — RFC 4918 §5.2 requires collection URLs to end
/// in a slash, and the Nextcloud desktop client strictly enforces this
/// for the "own entry" href in PROPFIND multi-status responses: a
/// PROPFIND on `/remote.php/dav/files/admin/ext/` whose first response
/// `<d:href>` doesn't end in `/` aborts the parse with
/// `Invalid href "<…>" expected starting with "<requested-url>"` and
/// surfaces as `Network request error "Erreur inconnue" HTTP status
/// 207` in the client log. Files use [`nc_href`] (no trailing slash).
pub fn nc_collection_href(username: &str, subpath: &str) -> String {
let h = nc_href(username, subpath);
if h.ends_with('/') {
h
} else {
format!("{}/", h)
}
}
/// Build the Nextcloud DAV href for a resource.
///
/// Each path segment is URL-encoded individually so filenames with spaces,
/// `#`, `%`, or non-ASCII characters produce valid PROPFIND hrefs.
///
/// Returns NO trailing slash for non-empty subpaths. Callers rendering
/// a **collection** must use [`nc_collection_href`] (or append `/`
/// manually) to satisfy RFC 4918 §5.2 and the NC client's parser.
pub fn nc_href(username: &str, subpath: &str) -> String {
let subpath = subpath.trim_matches('/');
let encoded_user = urlencoding::encode(username);
@@ -120,9 +142,18 @@ pub async fn handle_nc_webdav(
// ──────────────────── OPTIONS ────────────────────
fn handle_options() -> Result<Response<Body>, AppError> {
// Advertise WebDAV compliance classes 1 + 3 only.
// Class 2 (LOCK/UNLOCK) is intentionally omitted because the NC
// surface has no LOCK/UNLOCK dispatch arm — claiming class 2
// would invite clients (notably the NC desktop sync engine) to
// start sending LOCK requests we then 405. Class 3 covers the
// weak-resource-validators behaviour PROPFIND already implements.
// If LOCK is ever wired in here, restore "1, 2, 3" in the same
// commit as the LOCK arm — never split the advertisement from
// the implementation.
Ok(Response::builder()
.status(StatusCode::OK)
.header(HEADER_DAV, "1, 2, 3")
.header(HEADER_DAV, "1, 3")
.header(
header::ALLOW,
"OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH",
@@ -318,11 +349,18 @@ async fn handle_get(
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now);
// ETag comes from `FileDto::etag` (populated from `File::etag()`
// in the `From<File>` impl) — single source of truth, so GET,
// HEAD, PUT-response, MOVE, and PROPFIND all emit byte-identical
// values for the same file. NC's sync engine compares cached
// PROPFIND ETags against GET/HEAD responses; using `file.id` here
// (a UUID) while PROPFIND emitted the blob hash made NC see
// every file as "remotely changed" on first descent.
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
.body(Body::from_stream(std::pin::Pin::from(stream)))
.unwrap())
@@ -370,11 +408,14 @@ async fn handle_head(
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now);
// ETag comes from `FileDto::etag` — see the same comment block on
// the GET handler. HEAD and GET must agree byte-for-byte; pulling
// both from the same DTO field guarantees that.
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
.body(Body::empty())
.unwrap())
@@ -394,23 +435,40 @@ async fn handle_proppatch(
let body_str = String::from_utf8_lossy(&body_bytes);
// Resolve the target resource once — needed for two things:
// 1. Applying the oc:favorite mutation when the PROPPATCH body
// carries one (`item_type` distinguishes file vs folder rows
// in the favorites table).
// 2. Picking the right `<d:href>` shape in the multi-status
// response: collection (folder) hrefs MUST end in `/` per
// RFC 4918 §5.2 — see `nc_collection_href` for the full
// reasoning. Without this distinction the NC desktop client
// parser aborted on PROPFIND; PROPPATCH would hit the same
// wall the moment the user favourited a folder.
//
// When the resource is missing we tolerate it for the no-op
// PROPPATCH path (no favorite directive in the body) — matches
// the prior behaviour. A PROPPATCH that *does* try to set
// favorite on a missing resource still returns NotFound.
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
Some((file.id, "file"))
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
Some((folder.id, "folder"))
} else {
None
};
let is_collection = matches!(resource, Some((_, "folder")));
// Parse oc:favorite value from PROPPATCH XML.
let favorite_value = parse_proppatch_favorite(&body_str);
if let Some(value) = favorite_value {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Determine item_id and item_type.
let (item_id, item_type) =
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
(file.id, "file")
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
(folder.id, "folder")
} else {
return Err(AppError::not_found("Resource not found"));
};
let Some((item_id, item_type)) = resource else {
return Err(AppError::not_found("Resource not found"));
};
if let Some(fav_svc) = state.favorites_service.as_ref() {
if value == 1 {
@@ -431,8 +489,15 @@ async fn handle_proppatch(
}
}
// Return 207 Multi-Status with success response using quick_xml for safe escaping.
let href = nc_href(&user.username, subpath);
// Return 207 Multi-Status with success response using quick_xml
// for safe escaping. Collection vs file href chosen by resource
// type to satisfy the RFC 4918 §5.2 trailing-slash invariant —
// see the comment block at the top of this function.
let href = if is_collection {
nc_collection_href(&user.username, subpath)
} else {
nc_href(&user.username, subpath)
};
let mut buf = Vec::new();
{
let mut xml = Writer::new(&mut buf);
@@ -804,9 +869,13 @@ async fn handle_move(
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
let mut builder = Response::builder().status(StatusCode::CREATED);
if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await {
// Route through `FileDto::etag` so the MOVE response
// matches what a subsequent PROPFIND on the destination
// will return — `moved.id` (UUID) would differ from the
// blob hash and trigger NC's "remote changed" detection.
builder = builder
.header(header::ETAG, format!("\"{}\"", moved.id))
.header("oc-etag", format!("\"{}\"", moved.id));
.header(header::ETAG, format!("\"{}\"", moved.etag))
.header("oc-etag", format!("\"{}\"", moved.etag));
}
return Ok(builder.body(Body::empty()).unwrap());
@@ -935,9 +1004,10 @@ async fn write_nc_multistatus<W: std::io::Write>(
ms.push_attribute(("xmlns:ocs", "http://open-collaboration-services.org/ns"));
xml.write_event(Event::Start(ms)).xml_err()?;
// Current folder entry.
// Current folder entry. Collection hrefs MUST end in `/` (RFC 4918
// §5.2 + strict NC-client enforcement — see `nc_collection_href`).
if let Some(f) = folder {
let href = nc_href(username, subpath);
let href = nc_collection_href(username, subpath);
let file_id = resolve_folder_id(file_id_svc, &f.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
@@ -981,14 +1051,14 @@ async fn write_nc_multistatus<W: std::io::Write>(
)?;
}
// Subfolders.
// Subfolders — also collections, same trailing-slash rule.
for sf in subfolders {
let child_sub = if subpath.is_empty() {
sf.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), sf.name)
};
let href = format!("{}/", nc_href(username, &child_sub));
let href = nc_collection_href(username, &child_sub);
let file_id = resolve_folder_id(file_id_svc, &sf.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
@@ -1047,7 +1117,10 @@ pub fn write_folder_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!("\"{}\"", folder.id))?;
// Route through `FolderDto::etag` (= `Folder::etag()`, currently
// the folder UUID — see the entity for the documented v1 formula
// and the follow-up plan to make it descendant-aware).
write_text_element(xml, "d:getetag", &format!("\"{}\"", folder.etag))?;
write_text_element(xml, "d:getcontenttype", "httpd/unix-directory")?;
write_text_element(xml, "d:getcontentlength", "0")?;
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
@@ -1277,6 +1350,41 @@ mod tests {
assert!(href.contains("file%231.txt"));
}
// ── nc_collection_href ──
// RFC 4918 §5.2 requires a collection URL to end in '/'. The NC
// desktop client at `networkjobs.cpp:234` aborts the PROPFIND
// parse with `Invalid href "<…>" expected starting with
// "<requested-url>"` if the own-entry href is missing the slash.
// These tests pin the helper's behaviour so the regression can't
// come back silently.
#[test]
fn test_collection_href_appends_slash_when_missing() {
assert_eq!(
nc_collection_href("alice", "ext"),
"/remote.php/dav/files/alice/ext/"
);
}
#[test]
fn test_collection_href_idempotent_at_root() {
// Root subpath already ends in '/' — don't double-append.
assert_eq!(
nc_collection_href("alice", ""),
"/remote.php/dav/files/alice/"
);
}
#[test]
fn test_collection_href_preserves_encoding() {
// Wrapping must not re-encode or double-encode already-encoded
// segments.
assert_eq!(
nc_collection_href("alice", "My Photos/2024"),
"/remote.php/dav/files/alice/My%20Photos/2024/"
);
}
// ── extract_nc_subpath_from_dest ──
#[test]
+41 -21
View File
@@ -451,21 +451,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.merge(caldav_protected)
.merge(carddav_protected)
.merge(webdav_protected)
.merge(web_routes)
.layer(
TraceLayer::new_for_http()
.make_span_with(ClientIpMakeSpan)
.on_response(LogBadRequest),
)
.layer(PropagateRequestIdLayer::x_request_id())
.layer(SetRequestIdLayer::x_request_id(UuidRequestId));
.merge(web_routes);
// Mount Nextcloud routes (uses its own Basic Auth middleware)
// Mount Nextcloud routes (uses its own Basic Auth middleware).
// **Merged BEFORE the trace + request-id layers** so NC requests
// get the same `request_id` / `user_id` / `client_ip` span
// fields as every other surface — see
// `interfaces/middleware/trace_span.rs::ClientIpMakeSpan`.
if let Some(nc_router) = nextcloud_router {
app = app.merge(nc_router.with_state(app_state.clone()));
}
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware).
// Same reasoning as NC above: merge before the trace layer so
// WOPI requests appear in the structured log channel.
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
let wopi_api_protected = wopi_api
.layer(axum::middleware::from_fn(csrf_middleware))
@@ -477,6 +476,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.nest("/wopi", wopi_protocol)
.nest("/api/wopi", wopi_api_protected);
}
// ── Trace + request-id layers applied LAST so every route
// merged above (including the conditional NC and WOPI
// surfaces) is wrapped. New protocol routers added later
// only have to be merged before this point to get tracing
// for free — no second site to remember to update.
app = app
.layer(
TraceLayer::new_for_http()
.make_span_with(ClientIpMakeSpan)
.on_response(LogBadRequest),
)
.layer(PropagateRequestIdLayer::x_request_id())
.layer(SetRequestIdLayer::x_request_id(UuidRequestId));
} else {
// Auth disabled — no middleware applied
tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible");
@@ -491,7 +504,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.merge(caldav_router)
.merge(carddav_router)
.merge(webdav_router)
.merge(web_routes)
.merge(web_routes);
// Mount Nextcloud routes — merged BEFORE the trace + request-id
// layers so NC requests get the same span fields as every
// other surface (matches the auth-enabled branch above).
if let Some(nc_router) = nextcloud_router {
app = app.merge(nc_router.with_state(app_state.clone()));
}
// Mount WOPI routes (no auth middleware when auth is disabled).
// Same reasoning: merge before the trace layer.
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api);
}
// ── Trace + request-id layers applied LAST. See the
// auth-enabled branch above for the rationale.
app = app
.layer(
TraceLayer::new_for_http()
.make_span_with(ClientIpMakeSpan)
@@ -499,16 +529,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.layer(PropagateRequestIdLayer::x_request_id())
.layer(SetRequestIdLayer::x_request_id(UuidRequestId));
// Mount Nextcloud routes
if let Some(nc_router) = nextcloud_router {
app = app.merge(nc_router.with_state(app_state.clone()));
}
// Mount WOPI routes (no auth middleware when auth is disabled)
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api);
}
}
// Increase the default body limit to allow large file uploads.