Merge pull request #426 from EdouardVanbelle/refactor/etag-centralize
refactor & normalize etag for Nextcloud + fix NFC string (important fix)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user