From 93ae7ab14223ccb81f6193d8b3a48a9565a963fb Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Sat, 11 Jul 2026 18:52:41 +0200 Subject: [PATCH] feat(webdav): extend HTTP PATCH to the NextCloud surface (RFC 5789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NextCloud-compatible WebDAV surface (/remote.php/dav/…) had no PATCH dispatch arm at all — requests fell through to 405 — unlike the plain-file surface (see the sibling commit on this repo's rfc-5789-http-patch work). Adds handle_patch to nextcloud/webdav_handler.rs, reusing the plain surface's X-Update-Range mechanism directly instead of duplicating it: - api/handlers/webdav_handler.rs::parse_update_range is now pub(crate) so both surfaces share the same header-parsing/validation logic. - upload_ingest::ingest_range_patch_to_cas (already surface-agnostic) splices the request body between the file's untouched prefix/suffix byte ranges and re-ingests through the same content-addressable pipeline handle_put uses. Follows this file's own handle_put conventions rather than the plain handler's: no active-lock guard (the NC surface has no LOCK/UNLOCK dispatch arm at all) and no explicit storage-quota check (handle_put doesn't do one either on this surface) — matching the sibling handler instead of importing behavior the NC surface doesn't otherwise have. Adds PATCH to the OPTIONS Allow header. Also fixes a pre-existing clippy::useless_borrows_in_formatting warning in thumbnail_service.rs (unrelated to this change, but blocking a clean clippy run on this branch). Adds tests/api/nc_webdav_patch.hurl covering explicit-range and append PATCH, the Content-Range rejection, the missing-header 400, and PATCH on a nonexistent file. --- src/interfaces/api/handlers/webdav_handler.rs | 6 +- src/interfaces/nextcloud/webdav_handler.rs | 179 +++++++++++++++++- tests/api/nc_webdav_patch.hurl | 172 +++++++++++++++++ 3 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 tests/api/nc_webdav_patch.hurl diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 4b8d546e..d487990b 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -2006,7 +2006,11 @@ async fn handle_put( /// range isn't supported, use `append` or PUT for that. /// /// Returns `(start, end)`; `end` is `None` for `append`. -fn parse_update_range(header: &str, size: u64) -> Result<(u64, Option), AppError> { +/// +/// `pub(crate)` so the NextCloud-surface PATCH handler +/// (`nextcloud/webdav_handler.rs::handle_patch`) can reuse it instead of +/// duplicating the parsing logic. +pub(crate) fn parse_update_range(header: &str, size: u64) -> Result<(u64, Option), AppError> { let header = header.trim(); if header.eq_ignore_ascii_case("append") { return Ok((size, None)); diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index b868eaaf..124bae99 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -5,11 +5,13 @@ use axum::{ }; use bytes::{Buf, Bytes}; use chrono::Utc; +use futures::stream::{self, Stream}; use quick_xml::{ Writer, events::{BytesEnd, BytesStart, BytesText, Event}, }; use std::collections::{HashMap, HashSet}; +use std::pin::Pin; use std::sync::Arc; use uuid::Uuid; @@ -29,12 +31,12 @@ use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::infrastructure::services::webdav_dead_property_store::ResourceRef; use crate::interfaces::api::handlers::webdav_handler::{ - PROPFIND_BATCH_SIZE, dead_props_for, file_dead_props, files_dead_props_map, folder_dead_props, - folders_dead_props_map, + PROPFIND_BATCH_SIZE, dead_props_for, file_dead_props, files_dead_props_map, folder_dead_props, parse_update_range, + folders_dead_props_map, streamed_file_dead_props, }; use crate::interfaces::errors::AppError; use crate::interfaces::range_requests::{not_modified_response, range_response}; -use crate::interfaces::upload_ingest::ingest_body_to_cas; +use crate::interfaces::upload_ingest::{ingest_body_to_cas, ingest_range_patch_to_cas}; /// Extension trait to map XML write errors to `String` concisely. trait XmlResultExt { @@ -232,6 +234,7 @@ pub async fn handle_nc_webdav( "PROPFIND" => handle_propfind(state, req, &session, &subpath).await, "GET" => handle_get(state, &session, &subpath, req.headers()).await, "PUT" => handle_put(state, req, &session, &subpath).await, + "PATCH" => handle_patch(state, req, &session, &subpath).await, "MKCOL" => handle_mkcol(state, &session, &subpath).await, "DELETE" => handle_delete(state, &session, &subpath).await, "MOVE" => handle_move(state, req, &session, &subpath).await, @@ -267,7 +270,7 @@ fn handle_options() -> Result, AppError> { .header(HEADER_DAV, "1, 3") .header( header::ALLOW, - "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH", + "OPTIONS, GET, HEAD, PUT, PATCH, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH", ) .body(Body::empty()) .unwrap()) @@ -963,6 +966,174 @@ async fn handle_put( .unwrap()) } +// ──────────────────── PATCH ──────────────────── + +/// Handles PATCH requests (RFC 5789) for partial byte-range content +/// updates on the NextCloud file surface — extends the plain WebDAV +/// surface's `X-Update-Range` mechanism (see +/// `api/handlers/webdav_handler.rs::handle_patch` / `parse_update_range`) +/// here. New content is assembled by splicing the request body between +/// the file's untouched prefix/suffix byte ranges and re-ingesting the +/// result as one continuous stream through the same content-addressable +/// pipeline `handle_put` uses ([`ingest_range_patch_to_cas`]) — unedited +/// chunks on either side of the edit typically dedup for free. +/// +/// No active-lock guard here — the NC surface has no LOCK/UNLOCK dispatch +/// arm at all (see `handle_options`'s doc comment), matching `handle_put` +/// above, which has the same omission. +async fn handle_patch( + state: Arc, + req: Request, + session: &crate::interfaces::nextcloud::session::NcSession, + subpath: &str, +) -> Result, AppError> { + let chroot = session.require_chroot()?; + let internal_path = nc_to_internal_path(chroot, subpath)?; + let file_service = &state.applications.file_retrieval_service; + let upload_service = &state.applications.file_upload_service; + + if subpath.is_empty() || subpath == "/" { + return Err(AppError::bad_request("Cannot PATCH the root folder")); + } + + // RFC 5789 doesn't define Content-Range semantics; this server uses a + // dedicated `X-Update-Range` header instead (see `parse_update_range`) + // to avoid ambiguity with HTTP Range-Request semantics. + if req.headers().contains_key(header::CONTENT_RANGE) { + return Err(AppError::bad_request( + "PATCH must not use Content-Range; use the X-Update-Range header instead", + )); + } + + let update_range_header = req + .headers() + .get("X-Update-Range") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| AppError::bad_request("PATCH requires an X-Update-Range header"))?; + + // Extract all headers before consuming `req` into the body stream. + let if_none_match = req + .headers() + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let if_match = req + .headers() + .get(header::IF_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let content_length = req + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); + let claimed_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; + + // ── Existence check ─────────────────────────────────────────────── + // Unlike PUT, PATCH requires an existing file — a partial update of + // nothing isn't meaningful. + let file = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await + .map_err(|_| AppError::not_found(format!("File not found: {}", internal_path)))?; + + // ── RFC 7232 conditional preconditions ──────────────────────────── + let current_etag = Some(file.etag.as_str()); + if let Some(ref value) = if_none_match + && if_none_match_precondition_fails(value, current_etag) + { + return Ok(precondition_failed_response()); + } + if let Some(ref value) = if_match + && if_match_precondition_fails(value, current_etag) + { + return Ok(precondition_failed_response()); + } + + // ── Range parsing + validation ───────────────────────────────────── + let (start, end) = parse_update_range(&update_range_header, file.size)?; + if let (Some(end), Some(len)) = (end, content_length) { + let expected = end - start + 1; + if len != expected { + return Err(AppError::bad_request(format!( + "Content-Length {len} does not match X-Update-Range span {expected}" + ))); + } + } + + // ── Splice prefix/suffix around the patched span ─────────────────── + let prefix_stream: Pin> + Send>> = + if start == 0 { + Box::pin(stream::empty()) + } else { + Box::into_pin( + file_service + .get_file_range_stream_with_perms(&file.id, session.user.id, 0, Some(start)) + .await + .map_err(AppError::from)?, + ) + }; + let suffix_stream: Pin> + Send>> = match end + { + Some(end) if end + 1 < file.size => Box::into_pin( + file_service + .get_file_range_stream_with_perms(&file.id, session.user.id, end + 1, None) + .await + .map_err(AppError::from)?, + ), + _ => Box::pin(stream::empty()), + }; + + let filename = filename_from_path(subpath).to_string(); + let ingested = ingest_range_patch_to_cas( + prefix_stream, + req.into_body(), + suffix_stream, + &state.core.dedup_service, + &filename, + &claimed_type, + max_upload, + ) + .await?; + + // ── Atomic store ────────────────────────────────────────────────── + let new_size = ingested.size; + let content_type = ingested.content_type.clone(); + let stored = upload_service + .update_file_streaming_with_perms( + &internal_path, + chroot.drive_id, + ingested.stored(), + &content_type, + None, + session.user.id, + ) + .await + .map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?; + + // Everything from `start` to the new EOF reflects the patch (the + // untouched suffix, if any, may have shifted when the body's length + // differs from the replaced span). + let range_end = new_size.saturating_sub(1); + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::ETAG, format!("\"{}\"", stored.etag)) + .header("oc-etag", format!("\"{}\"", stored.etag)) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, range_end, new_size), + ) + .body(Body::empty()) + .unwrap()) +} + // ──────────────────── MKCOL ──────────────────── async fn handle_mkcol( diff --git a/tests/api/nc_webdav_patch.hurl b/tests/api/nc_webdav_patch.hurl new file mode 100644 index 00000000..53d50b24 --- /dev/null +++ b/tests/api/nc_webdav_patch.hurl @@ -0,0 +1,172 @@ +# ============================================================= +# OxiCloud — NextCloud HTTP PATCH partial content updates (RFC 5789) +# ============================================================= +# The NextCloud-compatible WebDAV surface (/remote.php/dav/…) had no +# PATCH dispatch arm at all (fell through to 405), unlike the plain-file +# surface (see api/handlers/webdav_handler.rs::handle_patch). See +# nextcloud/webdav_handler.rs::handle_patch, which reuses the plain +# surface's `parse_update_range` and `upload_ingest:: +# ingest_range_patch_to_cas` — both surface-agnostic. +# +# Coverage: +# 1. PATCH an explicit byte range (`X-Update-Range: bytes=-`) +# → 204, Content-Range header, and the resulting content reflects +# the patched span with the untouched prefix/suffix intact. +# 2. PATCH with `X-Update-Range: append` → 204, content grows. +# 3. PATCH with a Content-Range header → 400 (must use X-Update-Range). +# 4. PATCH without X-Update-Range → 400. +# 5. PATCH on a nonexistent file → 404. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup 1 — JWT login (to mint the app password used for NC Basic Auth). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +jwt: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Setup 2 — Mint an app password for NC Basic Auth. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{jwt}} +Content-Type: application/json +{ "label": "nc_webdav_patch hurl test" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Seed a 10-byte probe file: "0123456789". +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} +Content-Type: text/plain +``` +0123456789 +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PATCH bytes 3-5 ("345") with "XYZ". +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} +X-Update-Range: bytes=3-5 +Content-Type: text/plain +``` +XYZ +``` + +HTTP 204 +[Asserts] +header "Content-Range" == "bytes 3-9/10" + + +GET {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +body == "012XYZ6789" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — PATCH append. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} +X-Update-Range: append +Content-Type: text/plain +``` +END +``` + +HTTP 204 +[Asserts] +header "Content-Range" == "bytes 10-12/13" + + +GET {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +body == "012XYZ6789END" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Content-Range header on PATCH is rejected. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} +X-Update-Range: bytes=0-2 +Content-Range: bytes 0-2/13 +Content-Type: text/plain +``` +abc +``` + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Missing X-Update-Range header. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} +Content-Type: text/plain +``` +abc +``` + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PATCH on a nonexistent file → 404. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-does-not-exist.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} +X-Update-Range: append +Content-Type: text/plain +``` +abc +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Cleanup +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{jwt}} +HTTP 200