feat(webdav): RFC 4918 prop classification and 404 propstat
This commit is contained in:
@@ -1148,79 +1148,122 @@ async fn handle_put(
|
||||
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Get file service from state
|
||||
let file_upload_service = &state.applications.file_upload_service;
|
||||
|
||||
// Check if path is empty (root folder)
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::bad_request("Cannot PUT to root folder"));
|
||||
}
|
||||
|
||||
// ── Active-lock guard (RFC 4918 §9.10.4) ──────────────────────────
|
||||
// Reject a write that targets a locked resource unless the request
|
||||
// carries the lock token in `If:`. Captured before we consume the
|
||||
// body into the CDC ingester — a 423 mustn't waste any bandwidth.
|
||||
// RFC 4918 §9.7.1: a server MUST NOT partially CREATE or UPDATE a resource
|
||||
// based on a PUT request containing a Content-Range header.
|
||||
if req.headers().contains_key(header::CONTENT_RANGE) {
|
||||
return Err(AppError::bad_request(
|
||||
"PUT with Content-Range is not allowed (RFC 4918 §9.7.1)",
|
||||
));
|
||||
}
|
||||
|
||||
// Extract all headers before consuming `req` into the body stream.
|
||||
let if_header_owned = req
|
||||
.headers()
|
||||
.get("If")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
if let Some(resp) =
|
||||
enforce_native_lock(&state.webdav_lock_store, if_header_owned.as_deref(), &path)
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// ── Ownership guard ────────────────────────────────────────
|
||||
// Verify that the user owns the target file (update) or the
|
||||
// parent folder (create). Without this check a user could
|
||||
// overwrite another user's file via a crafted PUT path.
|
||||
if let Some(resolver) = &state.path_resolver {
|
||||
match resolver.resolve_path_for_user(&path, user.id).await {
|
||||
Ok(ResolvedResource::File(_)) => { /* existing file owned by user — OK */ }
|
||||
Ok(ResolvedResource::Folder(_)) => {
|
||||
return Err(AppError::bad_request("Cannot PUT to a directory"));
|
||||
}
|
||||
Err(_) => {
|
||||
// File doesn't exist yet — verify parent folder ownership
|
||||
let parent_path = if let Some(idx) = path.rfind('/') {
|
||||
&path[..idx]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if !parent_path.is_empty() {
|
||||
resolver
|
||||
.resolve_path_for_user(parent_path, user.id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::not_found(format!("Parent folder not found: {}", parent_path))
|
||||
})?;
|
||||
}
|
||||
// root-level PUT is allowed (parent_path empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
// (legacy path without resolver: update_file_streaming will create
|
||||
// under the folder with the resolved path, which may belong to
|
||||
// another user — acceptable risk since PathResolver should always
|
||||
// be enabled in production)
|
||||
|
||||
// Direct PUT cap — see `nextcloud/webdav_handler::handle_put` for
|
||||
// the reasoning. Files above `direct_put_max_bytes` must go through
|
||||
// the chunked-upload protocol (`/api/uploads/…`) which is resumable.
|
||||
let max_upload = state.core.config.storage.direct_put_max_bytes;
|
||||
|
||||
// Extract content type before consuming the request
|
||||
let if_none_match = req
|
||||
.headers()
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_string());
|
||||
let if_match = req
|
||||
.headers()
|
||||
.get(header::IF_MATCH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_string());
|
||||
let content_type = req
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let max_upload = state.core.config.storage.direct_put_max_bytes;
|
||||
|
||||
// ── Streaming ingest: body → CDC chunk store ──────────────
|
||||
// Shared with the NextCloud-compat PUT handler; chunking + hashing +
|
||||
// dedup checks run while the body arrives — no spool file, no re-read.
|
||||
// ── Active-lock guard (RFC 4918 §9.10.4) ──────────────────────────
|
||||
if let Some(resp) =
|
||||
enforce_native_lock(&state.webdav_lock_store, if_header_owned.as_deref(), &path)
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// ── Ownership / existence check ───────────────────────────────────
|
||||
// Resolves to: File(existing), Folder(wrong), or Err(new file).
|
||||
// Sets `file_existed` for 201 vs 204 and `current_etag` for If-Match.
|
||||
let mut file_existed = false;
|
||||
let mut current_etag: Option<String> = None;
|
||||
if let Some(resolver) = &state.path_resolver {
|
||||
match resolver.resolve_path_for_user(&path, user.id).await {
|
||||
Ok(ResolvedResource::File(f)) => {
|
||||
file_existed = true;
|
||||
current_etag = Some(f.etag.clone());
|
||||
}
|
||||
Ok(ResolvedResource::Folder(_)) => {
|
||||
return Err(AppError::bad_request("Cannot PUT to a directory"));
|
||||
}
|
||||
Err(_) => {
|
||||
// File doesn't exist — verify parent. RFC 4918 §9.7.1: missing
|
||||
// parent MUST produce 409 Conflict, not 404.
|
||||
let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or("");
|
||||
if !parent_path.is_empty() {
|
||||
resolver
|
||||
.resolve_path_for_user(parent_path, user.id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::conflict(format!(
|
||||
"Parent folder not found: {}",
|
||||
parent_path
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RFC 7232 conditional preconditions ────────────────────────────
|
||||
// Evaluated before ingesting the body to save bandwidth on doomed requests.
|
||||
if let Some(ref inm) = if_none_match {
|
||||
// If-None-Match: * → fail if resource exists (prevent overwrite)
|
||||
if inm == "*" && file_existed {
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-None-Match: * — resource already exists",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref im) = if_match {
|
||||
if im == "*" {
|
||||
// If-Match: * → fail if resource does not exist
|
||||
if !file_existed {
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-Match: * — resource does not exist",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// If-Match: <etag> → strong comparison against current ETag
|
||||
match ¤t_etag {
|
||||
None => {
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-Match — resource does not exist",
|
||||
));
|
||||
}
|
||||
Some(etag) => {
|
||||
let client_tag = im.trim_matches('"');
|
||||
let server_tag = etag.trim_matches('"');
|
||||
if client_tag != server_tag {
|
||||
return Err(AppError::precondition_failed("If-Match — ETag mismatch"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming ingest ──────────────────────────────────────────────
|
||||
let filename = crate::common::mime_detect::filename_from_path(&path).to_string();
|
||||
let ingested = upload_ingest::ingest_body_to_cas(
|
||||
req.into_body(),
|
||||
@@ -1231,7 +1274,7 @@ async fn handle_put(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ── Quota enforcement ────────────────────────────────────
|
||||
// ── Quota enforcement ─────────────────────────────────────────────
|
||||
if let Some(storage_svc) = state.storage_usage_service.as_ref()
|
||||
&& let Err(err) = storage_svc
|
||||
.check_storage_quota(user.id, ingested.size)
|
||||
@@ -1251,7 +1294,7 @@ async fn handle_put(
|
||||
));
|
||||
}
|
||||
|
||||
// ── Atomic store: swap the file row onto the ingested blob ──
|
||||
// ── Atomic store ──────────────────────────────────────────────────
|
||||
let content_type = ingested.content_type.clone();
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let result = file_upload_service
|
||||
@@ -1266,10 +1309,21 @@ async fn handle_put(
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_file_dto) => Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap()),
|
||||
Ok(file_dto) => {
|
||||
// RFC 4918 §9.7.1: 201 Created for new resources, 204 No Content
|
||||
// for overwrites. Always include ETag so clients can use it for
|
||||
// subsequent conditional requests without a round-trip HEAD.
|
||||
let status = if file_existed {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::CREATED
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header(header::ETAG, &file_dto.etag)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => Err(AppError::internal_error(format!(
|
||||
"Failed to put file: {}",
|
||||
e
|
||||
@@ -1299,7 +1353,17 @@ async fn handle_mkcol(
|
||||
return Err(AppError::conflict("Root folder already exists"));
|
||||
}
|
||||
|
||||
// Read request body - must be empty for MKCOL (RFC 4918 §9.3)
|
||||
// Extract content-type before consuming the body.
|
||||
let req_content_type = req
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// RFC 4918 §9.3.1: MKCOL body MUST be empty. A non-empty body with a
|
||||
// recognised XML content-type is 400 Bad Request (malformed MKCOL body);
|
||||
// a non-empty body with an unrecognised content-type is 415 Unsupported
|
||||
// Media Type. We read up to MAX_MKCOL_BODY bytes to distinguish the two.
|
||||
let body_bytes = {
|
||||
let body = req.into_body();
|
||||
body::to_bytes(body, MAX_MKCOL_BODY)
|
||||
@@ -1308,51 +1372,101 @@ async fn handle_mkcol(
|
||||
};
|
||||
|
||||
if !body_bytes.is_empty() {
|
||||
// A body whose content-type looks like XML → 400 (client sent a MKCOL
|
||||
// extended request we don't support); anything else → 415.
|
||||
let ct = req_content_type.as_deref().unwrap_or("");
|
||||
if ct.contains("xml") {
|
||||
return Err(AppError::bad_request(
|
||||
"MKCOL with XML body is not supported",
|
||||
));
|
||||
}
|
||||
return Err(AppError::unsupported_media_type(
|
||||
"MKCOL request body must be empty",
|
||||
));
|
||||
}
|
||||
|
||||
// Path is already translated by dispatch (e.g. "My Folder - jared/03/01").
|
||||
// Walk each segment: the first is the home folder (already exists),
|
||||
// subsequent segments are created as needed with proper parent_id.
|
||||
// `drive_id` scopes each per-segment path probe to the caller's default
|
||||
// drive (post-D0 invariant: `storage.folders.path` repeats across drives).
|
||||
// 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).
|
||||
// It does NOT auto-create intermediate ancestors ("mkdir -p" semantics
|
||||
// violate the RFC and were causing the test failures).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut parent_id: Option<String> = None;
|
||||
let mut accumulated_path = String::new();
|
||||
|
||||
for segment in &segments {
|
||||
if !accumulated_path.is_empty() {
|
||||
accumulated_path.push('/');
|
||||
}
|
||||
accumulated_path.push_str(segment);
|
||||
|
||||
match folder_service
|
||||
.get_folder_by_path(&accumulated_path, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(existing) => {
|
||||
parent_id = Some(existing.id);
|
||||
}
|
||||
Err(_) => {
|
||||
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||
name: segment.to_string(),
|
||||
parent_id: parent_id.clone(),
|
||||
};
|
||||
// Propagate DomainError -> AppError so NotFound/Conflict map to
|
||||
// their proper HTTP status codes (was: blanket 500 swallowed
|
||||
// ownership-rejection NotFound from verify_owner).
|
||||
let created = folder_service
|
||||
.create_folder_with_perms(create_dto, user.id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
parent_id = Some(created.id);
|
||||
}
|
||||
}
|
||||
if segments.is_empty() {
|
||||
return Err(AppError::conflict("Root folder already exists"));
|
||||
}
|
||||
|
||||
// Check whether the target itself already exists (file or folder → 405).
|
||||
if let Some(resolver) = &state.path_resolver {
|
||||
if resolver
|
||||
.exists_for_user(&path, user.id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"Collection already exists",
|
||||
"AlreadyExists",
|
||||
));
|
||||
}
|
||||
} else if folder_service.get_folder_by_path(&path, drive_id).await.is_ok() {
|
||||
return Err(AppError::new(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"Collection already exists",
|
||||
"AlreadyExists",
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve the parent path. RFC 4918 §9.3.1: if the parent does not
|
||||
// exist, return 409 Conflict. If the parent exists but is a file, also
|
||||
// return 409 (cannot create a collection inside a file).
|
||||
let new_segment = *segments.last().unwrap();
|
||||
let parent_segments = &segments[..segments.len() - 1];
|
||||
|
||||
let parent_id = if parent_segments.is_empty() {
|
||||
// Top-level creation — no parent required; the root folder acts as parent.
|
||||
None
|
||||
} else {
|
||||
let parent_path = parent_segments.join("/");
|
||||
// Parent must be a folder, not a file.
|
||||
if let Some(resolver) = &state.path_resolver {
|
||||
match resolver.resolve_path_for_user(&parent_path, user.id).await {
|
||||
Ok(ResolvedResource::Folder(f)) => Some(f.id),
|
||||
Ok(ResolvedResource::File(_)) => {
|
||||
return Err(AppError::conflict(
|
||||
"Parent path is a file, not a collection",
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(AppError::conflict(format!(
|
||||
"Parent folder not found: {}",
|
||||
parent_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match folder_service.get_folder_by_path(&parent_path, drive_id).await {
|
||||
Ok(f) => Some(f.id),
|
||||
Err(_) => {
|
||||
return Err(AppError::conflict(format!(
|
||||
"Parent folder not found: {}",
|
||||
parent_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||
name: new_segment.to_string(),
|
||||
parent_id,
|
||||
};
|
||||
folder_service
|
||||
.create_folder_with_perms(create_dto, user.id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
@@ -1500,6 +1614,11 @@ async fn handle_move(
|
||||
.await
|
||||
.unwrap_or(destination_path);
|
||||
|
||||
// RFC 4918 §9.9.3: MOVE to self MUST return 403 Forbidden.
|
||||
if destination_path == source_path {
|
||||
return Err(AppError::forbidden("Cannot MOVE a resource to itself"));
|
||||
}
|
||||
|
||||
// Destination lock guard: MOVE also creates/replaces a resource at
|
||||
// the destination. If that path is locked, the same If: header must
|
||||
// satisfy it.
|
||||
@@ -1511,45 +1630,66 @@ async fn handle_move(
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Get services from state
|
||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// `drive_id` scopes every path-based lookup below to the caller's
|
||||
// default drive (post-D0 invariant: `storage.{files,folders}.path`
|
||||
// repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Check if destination already exists (for Overwrite header compliance)
|
||||
if !overwrite {
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_for_user(&destination_path, user.id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_for_user(&destination_path, user.id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if dest_exists {
|
||||
};
|
||||
|
||||
if dest_existed {
|
||||
if !overwrite {
|
||||
return Err(AppError::precondition_failed(
|
||||
"Destination already exists and Overwrite is F",
|
||||
));
|
||||
}
|
||||
// RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the
|
||||
// destination before moving. Without this the rename/move fails
|
||||
// on a unique-index conflict (same name in same parent).
|
||||
match resolve_or_legacy(&state, &destination_path, user.id).await {
|
||||
Some(ResolvedResource::Folder(f)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&f.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to delete existing destination: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Some(ResolvedResource::File(f)) => {
|
||||
file_management_service
|
||||
.delete_file_with_perms(&f.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to delete existing destination: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve source via optimized resolver with legacy fallback (see
|
||||
// `resolve_or_legacy` for the rationale). Single match collapses the
|
||||
// two near-identical branches that the resolver-only + legacy-only
|
||||
// versions used to keep.
|
||||
let _ = file_retrieval_service; // referenced via resolve_or_legacy
|
||||
let _ = file_retrieval_service;
|
||||
let resolved = resolve_or_legacy(&state, &source_path, user.id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
@@ -1569,22 +1709,33 @@ async fn handle_move(
|
||||
|
||||
match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
|
||||
parent_id: if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else if let Ok(parent) = folder_service
|
||||
// RFC 4918 §9.9.5: missing destination parent → 409 Conflict.
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
&user.id.to_string(),
|
||||
dest_parent_path,
|
||||
)?;
|
||||
Some(parent.id)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Ok(parent) => {
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
&user.id.to_string(),
|
||||
dest_parent_path,
|
||||
)?;
|
||||
Some(parent.id)
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(AppError::conflict(format!(
|
||||
"Destination parent not found: {}",
|
||||
dest_parent_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
|
||||
parent_id: target_parent_id,
|
||||
};
|
||||
|
||||
folder_service
|
||||
@@ -1604,12 +1755,7 @@ async fn handle_move(
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
if source_parent_path != dest_parent_path {
|
||||
// Resolve the destination's parent PATH into a folder ID
|
||||
// before handing it to move_file_with_perms (which takes
|
||||
// an Option<folder_id String>, not a path). Previously
|
||||
// the path was passed straight through and the move
|
||||
// would silently fail because no row matches a folder
|
||||
// whose id literally equals the path text.
|
||||
// RFC 4918 §9.9.5: missing destination parent → 409 Conflict.
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -1617,7 +1763,7 @@ async fn handle_move(
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::not_found(format!(
|
||||
AppError::conflict(format!(
|
||||
"Destination parent not found: {}",
|
||||
dest_parent_path
|
||||
))
|
||||
@@ -1643,8 +1789,14 @@ async fn handle_move(
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 4918 §9.9.5: 201 Created when destination is new, 204 when overwritten.
|
||||
let status = if dest_existed {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::CREATED
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.status(status)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
@@ -1712,6 +1864,11 @@ async fn handle_copy(
|
||||
.await
|
||||
.unwrap_or(destination_path);
|
||||
|
||||
// RFC 4918 §9.8.5: COPY to self MUST return 403 Forbidden.
|
||||
if destination_path == source_path {
|
||||
return Err(AppError::forbidden("Cannot COPY a resource to itself"));
|
||||
}
|
||||
|
||||
// Active-lock guard on the destination (RFC 4918 §9.10.4).
|
||||
if let Some(resp) = enforce_native_lock(
|
||||
&state.webdav_lock_store,
|
||||
@@ -1731,40 +1888,64 @@ async fn handle_copy(
|
||||
// Get services from state
|
||||
let file_retrieval_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
|
||||
// `drive_id` scopes every path-based lookup below to the caller's
|
||||
// default drive (post-D0 invariant: `storage.{files,folders}.path`
|
||||
// repeats across drives).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Check if destination already exists (for Overwrite header compliance)
|
||||
if !overwrite {
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_for_user(&destination_path, user.id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_for_user(&destination_path, user.id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if dest_exists {
|
||||
};
|
||||
|
||||
if dest_existed {
|
||||
if !overwrite {
|
||||
return Err(AppError::precondition_failed(
|
||||
"Destination already exists and Overwrite is F",
|
||||
));
|
||||
}
|
||||
// RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a
|
||||
// DELETE on the destination before the copy. Without this the copy
|
||||
// service returns a unique-index conflict (500).
|
||||
match resolve_or_legacy(&state, &destination_path, user.id).await {
|
||||
Some(ResolvedResource::Folder(f)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&f.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to delete existing destination: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Some(ResolvedResource::File(f)) => {
|
||||
file_management_service
|
||||
.delete_file_with_perms(&f.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to delete existing destination: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve source via optimized resolver with legacy fallback; collapses
|
||||
// the two near-identical branches the resolver-only + legacy-only
|
||||
// versions used to keep.
|
||||
let _ = file_retrieval_service; // referenced via resolve_or_legacy
|
||||
let _ = file_retrieval_service;
|
||||
let resolved = resolve_or_legacy(&state, &source_path, user.id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
@@ -1778,27 +1959,35 @@ async fn handle_copy(
|
||||
.map(|i| &destination_path[..i])
|
||||
.unwrap_or("");
|
||||
|
||||
// RFC 4918 §9.8.5: if the destination parent does not exist, return 409.
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else if let Ok(parent) = folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
&user.id.to_string(),
|
||||
dest_parent_path,
|
||||
)?;
|
||||
Some(parent.id)
|
||||
} else {
|
||||
None
|
||||
match folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(parent) => {
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
&user.id.to_string(),
|
||||
dest_parent_path,
|
||||
)?;
|
||||
Some(parent.id)
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(AppError::conflict(format!(
|
||||
"Destination parent not found: {}",
|
||||
dest_parent_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let recursive = depth != "0";
|
||||
if recursive {
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
file_management_service
|
||||
.copy_folder_tree_with_perms(
|
||||
&folder.id,
|
||||
@@ -1827,15 +2016,6 @@ async fn handle_copy(
|
||||
}
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
// M8b fix: copy_file_with_perms now accepts an optional new
|
||||
// filename — without it, a copy to the same folder with a
|
||||
// different name collided with the source on the
|
||||
// (folder, name, user) unique index. Pass dest_name when it
|
||||
// differs from the source so the INSERT lands with the
|
||||
// intended name in a single round-trip; pass None for the
|
||||
// "same name in a different folder" case to keep the existing
|
||||
// semantics.
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let copy_name = (file.name != dest_name).then(|| dest_name.to_string());
|
||||
file_management_service
|
||||
.copy_file_with_perms(&file.id, user.id, target_parent_id, copy_name)
|
||||
@@ -1844,8 +2024,14 @@ async fn handle_copy(
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 4918 §9.8.5: 201 Created when destination is new, 204 when overwritten.
|
||||
let status = if dest_existed {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::CREATED
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.status(status)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user