fix(filename): fix uniform encoding encoding (NFC)
This commit is contained in:
@@ -36,6 +36,7 @@ use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::path_service::normalize_storage_name;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::{DeadPropertyStore, ResourceRef};
|
||||
use crate::interfaces::errors::AppError;
|
||||
@@ -2452,6 +2453,18 @@ async fn handle_mkcol(
|
||||
));
|
||||
}
|
||||
|
||||
// Capture the URL-path segments BEFORE scope-resolution rewrites `path`
|
||||
// to `scope.db_path` — we need the original request URL to reconstruct
|
||||
// the canonical `Content-Location` when the last segment gets NFC-
|
||||
// normalized. The last segment is the same either way (it's the target
|
||||
// resource name), but the URL prefix (including any `@drive/<selector>`
|
||||
// routing tokens the client used) is only preserved here.
|
||||
let request_url_segments: Vec<String> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
|
||||
// RFC 4918 §9.3.1: MKCOL on an existing URL MUST return 405.
|
||||
// RFC 4918 §9.3.1: MKCOL without an existing parent MUST return 409.
|
||||
// This handler only creates a single collection (the last path segment).
|
||||
@@ -2556,8 +2569,15 @@ async fn handle_mkcol(
|
||||
}
|
||||
};
|
||||
|
||||
// NFC-normalize the client-supplied last segment so we can emit
|
||||
// `Content-Location` when the canonical URL differs from what the
|
||||
// client sent. The repo layer normalizes again on the way to the DB
|
||||
// (idempotently — `is_nfc_quick` returns immediately for already-NFC
|
||||
// input); doing it here too gives the handler a cheap way to know
|
||||
// whether the URL changed. See AtalayaLabs/OxiCloud#706.
|
||||
let normalized_segment = normalize_storage_name(new_segment);
|
||||
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||
name: new_segment.to_string(),
|
||||
name: normalized_segment.clone(),
|
||||
parent_id,
|
||||
};
|
||||
folder_service
|
||||
@@ -2565,10 +2585,57 @@ async fn handle_mkcol(
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
// If the client sent an NFD name (macOS Finder, some Android sync
|
||||
// clients) and we canonicalised it, tell them the authoritative URL
|
||||
// via `Content-Location` (RFC 7231 §3.1.4.2). Well-behaved clients
|
||||
// (NextCloud desktop, rclone) update their local index; naive
|
||||
// clients ignore the header (safely — status stays 201). Emitting
|
||||
// only when the segment actually changed keeps the wire clean on
|
||||
// the common ASCII / already-NFC path.
|
||||
let mut response = Response::builder().status(StatusCode::CREATED);
|
||||
if normalized_segment != new_segment {
|
||||
response = response.header(
|
||||
"Content-Location",
|
||||
canonical_collection_url(&request_url_segments, &normalized_segment),
|
||||
);
|
||||
}
|
||||
Ok(response.body(Body::empty()).unwrap())
|
||||
}
|
||||
|
||||
/// Reconstruct the canonical `Content-Location` value for a WebDAV
|
||||
/// resource whose last URL segment was NFC-normalized server-side.
|
||||
///
|
||||
/// Takes the original request-URL segments (as split by `/` after the
|
||||
/// `/webdav/` prefix) and the canonical last-segment string, and
|
||||
/// returns a full `/webdav/…/` URL with each segment individually
|
||||
/// percent-encoded. Collection responses append a trailing `/` per
|
||||
/// RFC 4918 §5.2.
|
||||
fn canonical_collection_url(request_url_segments: &[String], canonical_last: &str) -> String {
|
||||
let mut out = String::with_capacity(
|
||||
request_url_segments.iter().map(|s| s.len()).sum::<usize>() + canonical_last.len() + 16,
|
||||
);
|
||||
out.push_str("/webdav/");
|
||||
// Walk all segments except the last; the last is replaced with the
|
||||
// canonical (normalized) form.
|
||||
let prefix = if request_url_segments.len() > 1 {
|
||||
&request_url_segments[..request_url_segments.len() - 1]
|
||||
} else {
|
||||
&[][..]
|
||||
};
|
||||
for seg in prefix {
|
||||
let _ = std::fmt::Write::write_fmt(
|
||||
&mut out,
|
||||
format_args!("{}/", utf8_percent_encode(seg, PATH_SEGMENT_ENCODE_SET)),
|
||||
);
|
||||
}
|
||||
let _ = std::fmt::Write::write_fmt(
|
||||
&mut out,
|
||||
format_args!(
|
||||
"{}/",
|
||||
utf8_percent_encode(canonical_last, PATH_SEGMENT_ENCODE_SET)
|
||||
),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::path_service::normalize_storage_name;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{
|
||||
@@ -1295,6 +1296,13 @@ async fn handle_mkcol(
|
||||
}
|
||||
let (target_name, parent_segments) = segments.split_last().expect("checked non-empty above");
|
||||
|
||||
// NFC-normalize the client-supplied last segment so we can emit
|
||||
// `Content-Location` if the canonical URL differs. Repo also
|
||||
// normalizes (idempotent — `is_nfc_quick` fast path). See
|
||||
// AtalayaLabs/OxiCloud#706 for the class of bug this closes on the
|
||||
// NC surface (macOS Finder / NC desktop client emit NFD on macOS).
|
||||
let normalized_target = normalize_storage_name(target_name);
|
||||
|
||||
// Take POC's `chroot`-based root resolution (drive-aware mount
|
||||
// point) but keep HEAD's parent_path lookup pattern — the
|
||||
// continuation below uses `get_folder_by_path(&parent_path,
|
||||
@@ -1320,7 +1328,7 @@ async fn handle_mkcol(
|
||||
};
|
||||
|
||||
let dto = CreateFolderDto {
|
||||
name: target_name.to_string(),
|
||||
name: normalized_target.clone(),
|
||||
parent_id: Some(parent_folder.id.clone()),
|
||||
};
|
||||
// AuthZ audit #7 (2026-07-12): route `_with_perms` errors through
|
||||
@@ -1332,10 +1340,25 @@ async fn handle_mkcol(
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
// Emit Content-Location (RFC 7231 §3.1.4.2) only when the URL
|
||||
// canonicalization actually changed something — keeps the common
|
||||
// ASCII / already-NFC path clean. Well-behaved clients (NC desktop,
|
||||
// rclone) update their local index; naive clients ignore the
|
||||
// header safely (status stays 201).
|
||||
let mut response = Response::builder().status(StatusCode::CREATED);
|
||||
if normalized_target != *target_name {
|
||||
let mut canonical_subpath = String::with_capacity(subpath.len());
|
||||
for seg in parent_segments {
|
||||
canonical_subpath.push_str(seg);
|
||||
canonical_subpath.push('/');
|
||||
}
|
||||
canonical_subpath.push_str(&normalized_target);
|
||||
response = response.header(
|
||||
"Content-Location",
|
||||
nc_collection_href(&user.username, &canonical_subpath),
|
||||
);
|
||||
}
|
||||
Ok(response.body(Body::empty()).unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── DELETE ────────────────────
|
||||
|
||||
Reference in New Issue
Block a user