Files
Oxicloud/src/interfaces/api/handlers/webdav_handler.rs
T

2342 lines
88 KiB
Rust
Raw Normal View History

2025-04-04 01:48:55 +02:00
/**
* WebDAV Handler Module
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* This module implements the WebDAV protocol (RFC 4918) endpoints for OxiCloud.
* It provides a complete WebDAV server implementation that allows clients to
* perform file operations over HTTP, including reading, writing, and manipulating
* files and directories.
*/
use axum::{
2025-04-09 00:21:20 +02:00
Router,
2026-02-14 01:29:34 +01:00
body::{self, Body},
http::{HeaderName, Request, StatusCode, header},
2025-04-04 01:48:55 +02:00
response::Response,
};
use bytes::{Buf, Bytes};
2026-02-14 01:29:34 +01:00
use chrono::Utc;
use quick_xml::Writer;
2026-02-14 01:29:34 +01:00
use uuid::Uuid;
2025-04-04 01:48:55 +02:00
use crate::application::adapters::webdav_adapter::{
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter,
};
use crate::application::dtos::file_dto::FileDto;
2025-04-04 21:31:41 +02:00
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase};
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::folder_service::FolderService;
2026-02-14 01:29:34 +01:00
use crate::common::di::AppState;
2026-06-19 07:49:33 +02:00
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::range_requests::{not_modified_response, range_response};
2026-03-03 01:49:18 +01:00
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use std::sync::Arc;
2025-04-04 21:31:41 +02:00
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
/// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
/// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
/// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'!')
.remove(b'$')
.remove(b'&')
.remove(b'\'')
.remove(b'(')
.remove(b')')
.remove(b'*')
.remove(b'+')
.remove(b',')
.remove(b';')
.remove(b'=')
.remove(b':')
.remove(b'@');
/// Percent-encode a single URI path segment (folder/file name).
fn encode_path_segment(segment: &str) -> String {
utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string()
}
/// Percent-encode a full slash-separated path, encoding each segment individually.
pub(crate) fn encode_uri_path(path: &str) -> String {
use std::fmt::Write as _;
// `utf8_percent_encode` returns a `Display` adapter, so write each encoded
// segment straight into `out` — avoids a String per segment and the joined
// Vec the previous `.map(...).collect::<Vec<_>>().join("/")` allocated on
// every PROPFIND href.
let mut out = String::with_capacity(path.len() + 8);
for (i, segment) in path.split('/').enumerate() {
if i > 0 {
out.push('/');
}
let _ = write!(
out,
"{}",
utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET)
);
}
out
}
2026-06-06 18:18:13 +02:00
/// 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)
}
}
2025-04-04 21:31:41 +02:00
// 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");
2025-04-10 01:43:25 +02:00
// const HEADER_IF: HeaderName = HeaderName::from_static("if");
2025-04-04 01:48:55 +02:00
/// Maximum body size for XML-based WebDAV requests (PROPFIND, PROPPATCH, LOCK).
/// 1 MB is generous — a typical PROPFIND body is < 1 KB.
const MAX_XML_BODY: usize = 1_048_576;
/// Maximum body size for MKCOL requests (RFC 4918: body must be empty).
const MAX_MKCOL_BODY: usize = 4096;
/// Batch size for streaming PROPFIND — files and folders are fetched in pages
/// of this size to keep memory constant regardless of folder contents.
/// `pub(crate)` so the NextCloud PROPFIND handler streams with the same
/// page size.
pub(crate) const PROPFIND_BATCH_SIZE: i64 = 500;
// ────────────────────────────────────────────────────────────────────────
// Security helpers (Sol.1 — handler-level user extraction & ownership guard)
// ────────────────────────────────────────────────────────────────────────
/// Extract the authenticated [`CurrentUser`] from the request extensions.
///
/// Every mutating or data-returning WebDAV handler **must** call this so
/// that the real `user.id` is available for ownership checks and for the
/// user-scoped `PathResolverService` methods.
fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
req.extensions()
.get::<Arc<CurrentUser>>()
.cloned()
.map(AuthUser)
.ok_or_else(|| AppError::unauthorized("Authentication required"))
}
/// Assert that a resolved resource belongs to `user_id`.
///
/// Used in the legacy (no-PathResolver) fallback paths where
/// `get_folder_by_path` / `get_file_by_path` are not user-scoped.
/// Returns `AppError::not_found` on mismatch so we don't leak the
/// existence of another user's resource.
fn assert_owner(owner_id: Option<&str>, user_id: &str, path: &str) -> Result<(), AppError> {
match owner_id {
Some(oid) if oid == user_id => Ok(()),
_ => Err(AppError::not_found(format!("Resource not found: {}", path))),
}
}
2025-04-04 01:48:55 +02:00
/**
* Creates and returns the WebDAV router with all required endpoints.
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* This function sets up all WebDAV method handlers following RFC 4918,
* mapping HTTP methods to appropriate WebDAV operations.
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* @return Router configured with WebDAV endpoints
*/
pub fn webdav_routes() -> Router<Arc<AppState>> {
// Three explicit routes to avoid Axum trailing-slash gaps
// (same pattern used for CalDAV/CardDAV)
2025-04-04 01:48:55 +02:00
Router::new()
2025-04-04 22:00:29 +02:00
.route("/webdav/{*path}", axum::routing::any(handle_webdav_methods))
.route("/webdav/", axum::routing::any(handle_webdav_methods_root))
.route("/webdav", axum::routing::any(handle_webdav_methods_root))
}
/// Reject paths that contain path-traversal segments (`.` or `..`).
///
/// Although deeper layers (PathResolver, StoragePath) also filter these out,
/// blocking them at the HTTP boundary provides defense-in-depth and ensures
/// no handler ever receives a traversal attempt.
fn reject_path_traversal(path: &str) -> Result<(), AppError> {
for segment in path.split('/') {
if segment == ".." || segment == "." {
return Err(AppError::bad_request(
"Path must not contain '.' or '..' segments",
));
}
}
Ok(())
}
/// Extract the resource path from the request URI, stripping the `/webdav/` prefix
/// and percent-decoding the result so that folder/file names with spaces and
/// special characters match the values stored in the database.
fn extract_webdav_path(uri: &axum::http::Uri) -> String {
let raw = uri.path();
let encoded = if let Some(rest) = raw.strip_prefix("/webdav/") {
rest.trim_end_matches('/')
} else if raw == "/webdav" {
""
} else {
// Fallback: split-based extraction
let trimmed = raw.strip_prefix('/').unwrap_or(raw);
trimmed.trim_end_matches('/')
};
// Decode percent-encoded characters (e.g. %20 → space)
2026-03-03 01:49:18 +01:00
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
}
async fn handle_webdav_methods_root(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
) -> Result<Response<Body>, AppError> {
handle_webdav_dispatch(state, req, String::new()).await
2025-04-04 21:31:41 +02:00
}
async fn handle_webdav_methods(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
) -> Result<Response<Body>, AppError> {
let path = extract_webdav_path(req.uri());
reject_path_traversal(&path)?;
handle_webdav_dispatch(state, req, path).await
}
/// If `path` doesn't already start with the user's home folder name, prepend
/// the home folder path so downstream services can find the resource in the DB.
/// Returns `None` when the path already includes the prefix or resolution fails.
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> {
let folder_service = &state.applications.folder_service;
let home_folders = folder_service
.list_folders_with_perms(None, user_id)
.await
.ok()?;
let home = home_folders.first()?;
if path.starts_with(&home.name) {
None // Already prefixed
} else {
Some(format!("{}/{}", home.path, path))
}
}
2026-06-19 07:49:33 +02:00
/// Native WebDAV protocol entry: resolve the caller's default drive
/// once per handler so every downstream path-based lookup
/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`)
/// can pass the same `drive_id` scope.
///
/// Post-D0 `storage.{folders,files}.path` repeats across drives — the
/// scope is mandatory. Native WebDAV today lives in a single-drive
/// surface (one default drive per user), so the lookup is unambiguous.
/// Multi-drive support via path segments (`/webdav/drives/<uuid>/…`)
/// is tracked separately and will derive `drive_id` directly from the
/// URL instead of going through `find_default_for_user`.
async fn resolve_drive_id_for_native_webdav(
state: &Arc<AppState>,
user_id: Uuid,
) -> Result<Uuid, AppError> {
state
.drive_repo
.find_default_for_user(user_id)
.await
.map(|d| d.drive.id)
.map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)))
}
async fn handle_webdav_dispatch(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
2026-02-14 01:29:34 +01:00
// Translate WebDAV path → DB path by prepending user's home folder
// prefix when the path doesn't already include it.
// Extract user_id before any async call to keep the future Send.
let path = if !path.is_empty() && method.as_str() != "OPTIONS" {
let user_id = req.extensions().get::<Arc<CurrentUser>>().map(|u| u.id);
if let Some(uid) = user_id {
resolve_webdav_path(&state, uid, &path)
.await
.unwrap_or(path)
} else {
path
}
} else {
path
};
2025-04-04 21:31:41 +02:00
match method.as_str() {
"OPTIONS" => handle_options(path).await,
"GET" => handle_get(state, req, path).await,
"HEAD" => handle_head(state, req, path).await,
"PUT" => handle_put(state, req, path).await,
"MKCOL" => handle_mkcol(state, req, path).await,
"DELETE" => handle_delete(state, req, path).await,
"MOVE" => handle_move(state, req, path).await,
"COPY" => handle_copy(state, req, path).await,
"PROPFIND" => handle_propfind(state, req, path).await,
"PROPPATCH" => handle_proppatch(state, req, path).await,
"LOCK" => handle_lock(state, req, path).await,
"UNLOCK" => handle_unlock(state, req, path).await,
2026-02-14 01:29:34 +01:00
_ => Err(AppError::method_not_allowed(format!(
"Method not allowed: {}",
method
))),
2025-04-04 21:31:41 +02:00
}
2025-04-04 01:48:55 +02:00
}
/**
* Handles OPTIONS requests to advertise WebDAV capabilities.
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* This handler responds with the DAV header indicating WebDAV compliance
* level and the methods supported by this WebDAV server.
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* @param state The application state containing service dependencies
* @param path The requested resource path
* @return HTTP response with appropriate WebDAV headers
*/
2026-02-14 01:29:34 +01:00
async fn handle_options(_path: String) -> Result<Response<Body>, AppError> {
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
2025-04-04 01:48:55 +02:00
.status(StatusCode::OK)
2025-04-04 21:31:41 +02:00
.header(HEADER_DAV, "1, 2") // Class 1 and 2 WebDAV support
2026-02-14 01:29:34 +01:00
.header(
header::ALLOW,
"OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK",
)
2025-04-04 21:31:41 +02:00
.body(Body::empty())
.unwrap())
2025-04-04 01:48:55 +02:00
}
/**
* Handles PROPFIND requests to retrieve resource properties.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler processes WebDAV PROPFIND requests according to RFC 4918,
* retrieving properties of files and folders in the specified path.
*
* **Security hardening (Sol.2):** `Depth: infinity` is rejected with
* `403 Forbidden` and the RFC 4918 `propfind-finite-depth` precondition
* error body. The default depth when the header is absent is `1`.
*
* **Streaming response (Sol.3):** For `Depth: 1`, files and sub-folders
* are fetched in batches of `PROPFIND_BATCH_SIZE` and the XML response
* is written incrementally to a streaming body. Memory usage is O(batch)
* regardless of how many children the folder contains.
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* @param state The application state containing service dependencies
* @param req The HTTP request containing the PROPFIND XML body
* @param path The requested resource path
* @return 207 Multi-Status XML response with resource properties
2025-04-04 01:48:55 +02:00
*/
async fn handle_propfind(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
// ── 1. Extract and validate Depth header ─────────────────────
2026-02-14 01:29:34 +01:00
let depth = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Depth")
2025-04-04 01:48:55 +02:00
.and_then(|v| v.to_str().ok())
.unwrap_or("1");
// RFC 4918 §9.1: servers MAY reject Depth:infinity with 403
if depth == "infinity" {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<D:error xmlns:D="DAV:">
<D:propfind-finite-depth/>
</D:error>"#;
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(body))
.unwrap());
}
2026-02-14 01:29:34 +01:00
// Normalize: anything other than "0" or "1" is treated as "0"
let depth = match depth {
"0" | "1" => depth,
_ => "0",
};
let depth_owned = depth.to_string();
// ── 2. Authenticate ──────────────────────────────────────────
let user = extract_user(&req)?;
2026-02-14 01:29:34 +01:00
// Client-facing path for href construction — must be extracted before
// req.into_body() consumes the request. The `path` parameter already has
// the home-folder prefix prepended (e.g. `admin/docs`) so it's correct for
// DB lookups but wrong for WebDAV hrefs (clients see `/webdav/docs`).
let client_path = extract_webdav_path(req.uri());
// ── 3. Parse PROPFIND XML body ───────────────────────────────
2025-04-04 21:31:41 +02:00
let body_bytes = {
let body = req.into_body();
body::to_bytes(body, MAX_XML_BODY)
2025-04-04 21:31:41 +02:00
.await
2026-02-14 01:29:34 +01:00
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
2025-04-04 01:48:55 +02:00
};
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
let propfind_request = if body_bytes.is_empty() {
PropFindRequest {
prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp,
}
2025-04-04 01:48:55 +02:00
} else {
2025-04-04 21:31:41 +02:00
WebDavAdapter::parse_propfind(body_bytes.reader()).map_err(|e| {
AppError::bad_request(format!("Failed to parse PROPFIND request: {}", e))
})?
};
2026-02-14 01:29:34 +01:00
// ── 4. Services ──────────────────────────────────────────────
let folder_service = state.applications.folder_service.clone();
let file_retrieval_service = state.applications.file_retrieval_service.clone();
2026-02-14 01:29:34 +01:00
// Use client-facing path for hrefs so responses match the request URL.
let base_href = if client_path.is_empty() || client_path == "/" {
"/webdav/".to_string()
} else {
format!("/webdav/{}/", encode_uri_path(&client_path))
};
2026-02-14 01:29:34 +01:00
// ── 5. Determine target resource ─────────────────────────────
2025-04-04 21:31:41 +02:00
if path.is_empty() || path == "/" {
// Root folder
2025-04-04 21:31:41 +02:00
let root_folder = FolderDto {
id: "root".to_string(),
etag: "root".to_string(),
2025-04-04 21:31:41 +02:00
name: "".to_string(),
path: "".to_string(),
parent_id: None,
owner_id: None,
2026-06-19 07:49:33 +02:00
// Synthetic root folder for PROPFIND on `/`; not an
// actual DB row, so drive_id has no meaningful value.
drive_id: Uuid::nil(),
2025-04-04 21:31:41 +02:00
created_at: Utc::now().timestamp() as u64,
modified_at: Utc::now().timestamp() as u64,
is_root: true,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
2026-06-19 10:51:13 +02:00
// §14 provenance not applicable to the synthetic root.
created_by: None,
updated_by: None,
2025-04-04 01:48:55 +02:00
};
2026-02-14 01:29:34 +01:00
return build_streaming_propfind_response(
root_folder,
None, // folder_id = None → root children
&depth_owned,
2025-04-04 21:31:41 +02:00
&base_href,
propfind_request,
folder_service,
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
path.clone(),
2026-02-14 01:29:34 +01:00
)
.await;
}
2026-02-14 01:29:34 +01:00
// Single-query path resolution: folder OR file in one DB round-trip
if let Some(resolver) = &state.path_resolver {
match resolver.resolve_path_for_user(&path, user.id).await {
Ok(ResolvedResource::Folder(folder)) => {
let folder_id = folder.id.clone();
return build_streaming_propfind_response(
folder,
Some(folder_id),
&depth_owned,
&base_href,
propfind_request,
folder_service,
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
path.clone(),
)
.await;
}
Ok(ResolvedResource::File(file)) => {
let dead_props = state
.webdav_dead_props
.get_all(&path, user.id)
.await
.unwrap_or_default();
let file_href = webdav_href(&client_path);
let mut buf = Vec::with_capacity(1024);
{
let mut xml_writer = Writer::new(&mut buf);
WebDavAdapter::write_multistatus_start(&mut xml_writer)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_file_entry_with_dead_props(
2026-03-03 01:49:18 +01:00
&mut xml_writer,
&file,
&propfind_request,
&file_href,
&dead_props,
2026-03-03 01:49:18 +01:00
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_multistatus_end(&mut xml_writer)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
}
Err(_) => {}
}
} else {
2026-06-19 07:49:33 +02:00
// Fallback: legacy double-query path when PathResolver is unavailable.
// `drive_id` is mandatory post-D0 for path-based lookups — derive
// the caller's default drive once and reuse it for both probes.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
let folder_id = folder.id.clone();
return build_streaming_propfind_response(
folder,
Some(folder_id),
&depth_owned,
&base_href,
propfind_request,
folder_service,
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
path.clone(),
)
.await;
}
2026-06-19 07:49:33 +02:00
if let Ok(file) = file_retrieval_service
.get_file_by_path(&path, drive_id)
.await
{
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
let dead_props = state
.webdav_dead_props
.get_all(&path, user.id)
.await
.unwrap_or_default();
let file_href = webdav_href(&client_path);
let mut buf = Vec::with_capacity(1024);
{
let mut xml_writer = Writer::new(&mut buf);
WebDavAdapter::write_multistatus_start(&mut xml_writer)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_file_entry_with_dead_props(
2026-03-03 01:49:18 +01:00
&mut xml_writer,
&file,
&propfind_request,
&file_href,
&dead_props,
2026-03-03 01:49:18 +01:00
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
WebDavAdapter::write_multistatus_end(&mut xml_writer)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
}
}
2026-02-14 01:29:34 +01:00
Err(AppError::not_found(format!("Resource not found: {}", path)))
}
/// Builds a streaming 207 Multi-Status PROPFIND response.
///
/// The XML is written incrementally: first the folder itself, then children
/// (sub-folders and files) are fetched in batches of `PROPFIND_BATCH_SIZE`.
/// Each batch is serialised to XML and sent as a chunk, so memory stays
/// constant at O(batch_size) regardless of the total number of children.
#[allow(clippy::too_many_arguments)]
async fn build_streaming_propfind_response(
folder: FolderDto,
folder_id: Option<String>,
depth: &str,
base_href: &str,
propfind_request: PropFindRequest,
folder_service: std::sync::Arc<FolderService>,
file_retrieval_service: std::sync::Arc<FileRetrievalService>,
user_id: Uuid,
dead_props_store: Arc<
crate::infrastructure::services::webdav_dead_property_store::DeadPropertyStore,
>,
folder_internal_path: String,
) -> Result<Response<Body>, AppError> {
let depth = depth.to_string();
let base_href = base_href.to_string();
let propfind_request = Arc::new(propfind_request);
let stream = async_stream::try_stream! {
// ── XML header + <D:multistatus> + folder entry ──────────
let mut buf = Vec::with_capacity(4096);
{
let mut w = Writer::new(&mut buf);
let folder_dead = dead_props_store.get_all(&folder_internal_path, user_id).await
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_multistatus_start(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, &folder, &propfind_request, &base_href, &folder_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
// ── Children (only if Depth == 1) ────────────────────────
if depth == "1" {
let pagination = crate::application::dtos::pagination::PaginationRequestDto {
page: 0,
page_size: PROPFIND_BATCH_SIZE as usize,
};
let fid_ref = folder_id.as_deref();
// Stream sub-folders in pages (user-scoped)
let mut page = 0usize;
loop {
let pag = crate::application::dtos::pagination::PaginationRequestDto {
page,
page_size: pagination.page_size,
};
let result = folder_service
.list_folders_paginated_with_perms(fid_ref, user_id, &pag)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
if result.items.is_empty() {
break;
}
let mut chunk = Vec::with_capacity(result.items.len() * 800);
{
let mut w = Writer::new(&mut chunk);
for subfolder in &result.items {
let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name));
let child_path = format!("{}/{}", folder_internal_path, subfolder.name);
let child_dead = dead_props_store.get_all(&child_path, user_id).await
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_folder_entry_with_dead_props(&mut w, subfolder, &propfind_request, &href, &child_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
}
let has_more = result.pagination.has_next;
yield Bytes::from(chunk);
if !has_more {
break;
}
page += 1;
}
// Stream files in pages (user-scoped)
let mut offset: i64 = 0;
loop {
let batch: Vec<FileDto> = file_retrieval_service
.list_files_batch_with_perms(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
if batch.is_empty() {
break;
}
let batch_len = batch.len();
let mut chunk = Vec::with_capacity(batch_len * 800);
{
let mut w = Writer::new(&mut chunk);
for file in &batch {
let href = format!("{}{}", base_href, encode_path_segment(&file.name));
let child_path = format!("{}/{}", folder_internal_path, file.name);
let child_dead = dead_props_store.get_all(&child_path, user_id).await
.map_err(|e| std::io::Error::other(e.to_string()))?;
WebDavAdapter::write_file_entry_with_dead_props(&mut w, file, &propfind_request, &href, &child_dead)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
}
yield Bytes::from(chunk);
if (batch_len as i64) < PROPFIND_BATCH_SIZE {
break;
}
offset += batch_len as i64;
2025-04-04 21:31:41 +02:00
}
}
// ── Close </D:multistatus> ───────────────────────────────
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
WebDavAdapter::write_multistatus_end(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap())
2025-04-04 01:48:55 +02:00
}
2025-04-04 21:31:41 +02:00
/**
* Handles PROPPATCH requests to set or remove resource properties.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler processes WebDAV PROPPATCH requests according to RFC 4918,
* modifying properties of files and folders in the specified path.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The requested resource path
* @param req The HTTP request containing the PROPPATCH XML body
* @return XML response with property modification results
*/
async fn handle_proppatch(
2026-06-06 18:18:13 +02:00
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
2026-06-18 23:02:17 +02:00
let user = extract_user(&req)?;
// Client-facing path for href construction (without home folder prefix).
let client_path = extract_webdav_path(req.uri());
2026-02-14 01:29:34 +01:00
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
// so a lock on the target must release them via `If:`. Captured
// before the body is consumed below so a rejected request doesn't
// even parse the XML.
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);
}
2026-06-06 18:18:13 +02:00
// 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 {
2026-06-19 07:49:33 +02:00
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
2026-06-06 18:18:13 +02:00
state
.applications
.folder_service
2026-06-19 07:49:33 +02:00
.get_folder_by_path(&path, drive_id)
2026-06-06 18:18:13 +02:00
.await
.is_ok()
};
// Read request body (XML — bounded to 1 MB)
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
2025-04-04 21:31:41 +02:00
.await
.map_err(|e| {
AppError::payload_too_large(format!("PROPPATCH body too large or unreadable: {}", e))
})?;
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
2026-02-14 01:29:34 +01:00
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
// Apply operations in document order (RFC 4918 §9.2).
let dead_props = &state.webdav_dead_props;
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
for op in &ops {
match op {
PropPatchOp::Set(pv) => {
dead_props
.set(&path, user.id, pv.name.clone(), pv.value.clone())
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to store dead property: {e}"))
})?;
results.push((&pv.name, true));
}
PropPatchOp::Remove(name) => {
dead_props.remove(&path, user.id, name).await.map_err(|e| {
AppError::internal_error(format!("Failed to remove dead property: {e}"))
})?;
results.push((name, true));
}
}
2025-04-04 21:31:41 +02:00
}
2026-02-14 01:29:34 +01:00
// Generate response — use client-facing path so href matches the request URL.
2026-06-06 18:18:13 +02:00
let href = if is_collection {
webdav_collection_href(&client_path)
2026-06-06 18:18:13 +02:00
} else {
webdav_href(&client_path)
2026-06-06 18:18:13 +02:00
};
2025-04-04 21:31:41 +02:00
let mut response_body = Vec::new();
2026-02-14 01:29:34 +01:00
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
)?;
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap())
}
2025-04-04 01:48:55 +02:00
/**
* Handles GET requests to retrieve file contents.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler retrieves the contents of a file at the specified path.
2026-02-14 01:29:34 +01:00
*
2025-04-04 01:48:55 +02:00
* @param state The application state containing service dependencies
2025-04-04 21:31:41 +02:00
* @param user The authenticated user information
* @param path The requested resource path
* @return HTTP response with file contents
2025-04-04 01:48:55 +02:00
*/
async fn handle_get(
state: Arc<AppState>,
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
2025-04-04 21:31:41 +02:00
// Get file service from state
let file_retrieval_service = &state.applications.file_retrieval_service;
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
// Check if path is empty (root folder)
if path.is_empty() || path == "/" {
2025-04-04 01:48:55 +02:00
return Err(AppError::bad_request("Cannot GET a directory"));
}
2026-02-14 01:29:34 +01:00
// Resolve file — user-scoped when PathResolver is available
let file = if let Some(resolver) = &state.path_resolver {
match resolver.resolve_path_for_user(&path, user.id).await {
Ok(ResolvedResource::File(f)) => f,
Ok(ResolvedResource::Folder(_)) => {
return Err(AppError::bad_request("Cannot GET a directory"));
}
Err(_) => {
return Err(AppError::not_found(format!("File not found: {}", path)));
}
}
} else {
2026-06-19 07:49:33 +02:00
// Legacy fallback — fetch + ownership check. `drive_id` is the
// path-lookup scope post-D0 (`storage.files.path` repeats across
// drives), derived once from the caller's default drive.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let f = file_retrieval_service
2026-06-19 07:49:33 +02:00
.get_file_by_path(&path, drive_id)
.await
.map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?;
assert_owner(f.owner_id.as_deref(), &user.id.to_string(), &path)?;
f
};
2026-02-14 01:29:34 +01:00
let etag = format!("\"{}\"", file.etag);
// Conditional GET — clients revalidating a cached copy get a 304
// instead of the full body.
if let Some(resp) = not_modified_response(req.headers(), &etag) {
return Ok(resp);
}
// Recent recording deliberately does NOT fire here: native WebDAV
// is overwhelmingly a sync-engine surface (rclone, davfs2, Finder
// mounts) and a first descent would push every synced file into
// Recent, drowning out the SPA's "what I actually opened" signal.
// See memory note `project_recent_session_intent.md` — the planned
// session-intent gate (interactive JWT vs app-password) will turn
// this back on for the rare human-driven DAV access.
// Range Requests — mount-style clients (rclone, davfs2, Finder) read
// by ranges; serve 206/416 instead of re-sending the whole file on
// every seek or resume.
if let Some(resp) = range_response(req.headers(), &file, &etag, file_retrieval_service).await {
return Ok(resp);
}
// Stream file content — constant ~64 KB memory regardless of file size
let stream = file_retrieval_service
.get_file_stream(&file.id)
2026-02-14 01:29:34 +01:00
.await
.map_err(|e| AppError::internal_error(format!("Failed to stream file: {}", e)))?;
2026-02-14 01:29:34 +01:00
// Build streaming response using Content-Length from metadata
2025-04-04 01:48:55 +02:00
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, etag)
.header(header::ACCEPT_RANGES, "bytes")
2026-02-14 01:29:34 +01:00
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
.unwrap_or_else(Utc::now)
.to_rfc2822(),
)
.body(Body::from_stream(Box::into_pin(stream)))
2025-04-04 01:48:55 +02:00
.unwrap())
}
/**
* Handles HEAD requests — same as GET but returns only headers, no body.
*/
async fn handle_head(
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let file_retrieval_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
if path.is_empty() || path == "/" {
// Root folder — return collection headers
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "httpd/unix-directory")
.header(header::CONTENT_LENGTH, 0)
.body(Body::empty())
.unwrap());
}
// Single-query path resolution (user-scoped)
if let Some(resolver) = &state.path_resolver {
match resolver.resolve_path_for_user(&path, user.id).await {
Ok(ResolvedResource::Folder(folder)) => {
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "httpd/unix-directory")
.header(header::CONTENT_LENGTH, 0)
.header(header::ETAG, format!("\"{}\"", folder.etag))
.body(Body::empty())
.unwrap());
}
Ok(ResolvedResource::File(file)) => {
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag))
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
.unwrap_or_else(Utc::now)
.to_rfc2822(),
)
.body(Body::empty())
.unwrap());
}
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))),
}
}
2026-06-19 07:49:33 +02:00
// Fallback: legacy double-query path (with ownership check).
// `drive_id` is the path-lookup scope post-D0 — derive once and
// reuse for both the folder and file probes.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "httpd/unix-directory")
.header(header::CONTENT_LENGTH, 0)
.header(header::ETAG, format!("\"{}\"", folder.etag))
.body(Body::empty())
.unwrap());
}
// Try as file — use metadata only, never load content for HEAD
2026-02-14 01:29:34 +01:00
let file = file_retrieval_service
2026-06-19 07:49:33 +02:00
.get_file_by_path(&path, drive_id)
2026-02-14 01:29:34 +01:00
.await
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?;
assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?;
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.etag))
2026-02-14 01:29:34 +01:00
.header(
header::LAST_MODIFIED,
chrono::DateTime::<Utc>::from_timestamp(file.created_at as i64, 0)
.unwrap_or_else(Utc::now)
.to_rfc2822(),
)
.body(Body::empty())
.unwrap())
}
/// Resolve `path` to a user-owned resource using the optimized
/// PathResolver first, falling back to the legacy `get_folder_by_path` /
/// `get_file_by_path` lookups (the same ones GET uses) when the
/// optimized resolver returns NotFound.
///
/// **Why the fallback exists**: the optimized resolver and the read-side
/// `get_*_by_path` repositories don't always agree on what a "path"
/// looks like. The drive-refactor migration rewrote the `path` column
/// to strip the `My Folder - <user>/` prefix that the WebDAV dispatcher
/// (`resolve_webdav_path`) still prepends — leaving an inconsistency
/// where files PUT through the WebDAV surface stay reachable by GET
/// (legacy lookup) but invisible to the optimized resolver (strict
/// path-match). MOVE / DELETE / COPY previously 404'd on every
/// root-level file because they only used the optimized resolver.
///
/// Ownership is enforced in both branches: the optimized resolver
/// includes `user_id = $4` in its SQL; the fallback runs `assert_owner`
/// explicitly so a foreign-owned hit can't leak through.
async fn resolve_or_legacy(
state: &Arc<AppState>,
path: &str,
user_id: Uuid,
) -> Option<ResolvedResource> {
if let Some(resolver) = &state.path_resolver
&& let Ok(r) = resolver.resolve_path_for_user(path, user_id).await
{
return Some(r);
}
2026-06-19 07:49:33 +02:00
// Path-lookup scope post-D0 — derive the caller's default drive
// for both legacy probes. `find_default_for_user` returning Err
// (e.g. external user, or boot before the lifecycle hook fired)
// means no fallback resolution is possible: return None.
let drive_id = state
.drive_repo
.find_default_for_user(user_id)
.await
.ok()?
.drive
.id;
let user_id_str = user_id.to_string();
let folder_service = &state.applications.folder_service;
2026-06-19 07:49:33 +02:00
if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await
&& folder.owner_id.as_deref() == Some(&user_id_str)
{
return Some(ResolvedResource::Folder(folder));
}
let file_retrieval = &state.applications.file_retrieval_service;
2026-06-19 07:49:33 +02:00
if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await
&& file.owner_id.as_deref() == Some(&user_id_str)
{
return Some(ResolvedResource::File(file));
}
None
}
/// Extract every `<...>` token from a WebDAV `If:` header value.
///
/// RFC 4918 §10.4 defines a richer grammar (tagged-list / no-tag-list of
/// `(Condition)` items), but for our purposes the only thing that matters
/// is what lock tokens the caller is claiming to hold. Forgivingly scoop
/// every angle-bracketed value and let the caller compare against the
/// active lock token(s).
fn extract_if_header_tokens(if_header: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut inside = false;
for c in if_header.chars() {
match (inside, c) {
(false, '<') => {
inside = true;
current.clear();
}
(true, '>') => {
inside = false;
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
(true, c) => current.push(c),
_ => {}
}
}
out
}
/// RFC 4918 §9.10.4 — if `path` is locked, every mutating request MUST
/// carry the lock's token in its `If:` header. Returns `Some(Response)`
/// with a 423 Locked response when the request must be rejected; `None`
/// when the path is unlocked or the caller's `If:` header carries the
/// matching token (the cheap-and-cheerful submission check).
///
/// Shared by `handle_put` now and will be reused by `handle_delete`,
/// `handle_move`, `handle_copy`, and `handle_proppatch` when each of
/// those gets the same enforcement.
fn enforce_native_lock(
lock_store: &crate::infrastructure::services::webdav_lock_service::WebDavLockStore,
if_header: Option<&str>,
path: &str,
) -> Option<Response<Body>> {
2026-06-22 21:37:14 +02:00
// Check the exact path, then walk up parent collections for depth-infinity
// locks (RFC 4918 §6.1: a lock on a collection with Depth: infinity also
// covers all descendant members).
let entry = lock_store.get_by_path(path).or_else(|| {
let mut p = path;
loop {
let idx = p.rfind('/')?;
p = &p[..idx];
if p.is_empty() {
return None;
}
if let Some(e) = lock_store.get_by_path(p)
&& e.info.depth.eq_ignore_ascii_case("infinity")
{
return Some(e);
2026-06-22 21:37:14 +02:00
}
}
});
if let Some(entry) = entry {
// Resource is locked: caller must supply the matching token in If:.
if let Some(h) = if_header
&& extract_if_header_tokens(h)
.iter()
.any(|t| t == &entry.info.token)
{
return None;
}
return Some(
Response::builder()
.status(StatusCode::LOCKED)
.body(Body::empty())
.unwrap(),
);
}
// Resource is not locked. If the If: header references lock tokens (not
// resource-tag URLs), every such token must be active somewhere in the
// store. A stale or fabricated token (e.g. DAV:no-lock) never matches,
// so the If: condition fails → 412 Precondition Failed (RFC 4918 §10.4).
if let Some(h) = if_header {
let tokens = extract_if_header_tokens(h);
let lock_refs: Vec<_> = tokens
.iter()
2026-06-22 21:37:14 +02:00
.filter(|t| !t.starts_with("http://") && !t.starts_with("https://"))
.collect();
if !lock_refs.is_empty()
&& !lock_refs
.iter()
.any(|t| lock_store.get_by_token(t).is_some())
{
return Some(
Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
.body(Body::empty())
.unwrap(),
);
}
}
2026-06-22 21:37:14 +02:00
None
}
2025-04-04 01:48:55 +02:00
/**
* Handles PUT requests to create or update files.
2026-02-14 01:29:34 +01:00
*
* **Streaming implementation**: the request body is streamed straight into
* the CDC chunk store (FastCDC + BLAKE3 while the bytes arrive — no spool
* file, no re-read; peak RAM is bounded regardless of file size), then the
* file row is atomically swapped onto the ingested blob via
* `update_file_streaming`.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param path The requested resource path
* @param req The HTTP request containing the file contents
* @return HTTP response indicating success
2025-04-04 01:48:55 +02:00
*/
async fn handle_put(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
use crate::interfaces::upload_ingest;
let user = extract_user(&req)?;
let file_upload_service = &state.applications.file_upload_service;
2026-02-14 01:29:34 +01:00
if path.is_empty() || path == "/" {
return Err(AppError::bad_request("Cannot PUT to root folder"));
2025-04-04 21:31:41 +02:00
}
2026-02-14 01:29:34 +01:00
// 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());
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;
// ── 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 &current_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"));
}
}
}
}
}
2026-02-14 01:29:34 +01:00
// ── 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(),
&state.core.dedup_service,
&filename,
&content_type,
max_upload,
)
.await?;
// ── 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)
.await
{
upload_ingest::discard_ingested(&state.core.dedup_service, &ingested).await;
tracing::warn!(
"⛔ WEBDAV PUT REJECTED (quota): user={}, file={}, size={}",
user.id,
path,
ingested.size
);
return Err(AppError::new(
StatusCode::INSUFFICIENT_STORAGE,
err.message,
"QuotaExceeded",
));
}
// ── Atomic store ──────────────────────────────────────────────────
let content_type = ingested.content_type.clone();
2026-06-19 07:49:33 +02:00
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let result = file_upload_service
2026-06-19 10:51:13 +02:00
.update_file_streaming(
&path,
drive_id,
ingested.stored(),
&content_type,
None,
user.id,
)
.await;
2026-02-14 01:29:34 +01:00
match result {
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())
}
2026-02-14 01:29:34 +01:00
Err(e) => Err(AppError::internal_error(format!(
"Failed to put file: {}",
2026-02-14 01:29:34 +01:00
e
))),
2025-04-04 21:31:41 +02:00
}
2025-04-04 01:48:55 +02:00
}
/**
2025-04-04 21:31:41 +02:00
* Handles MKCOL requests to create folders.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler creates a new folder at the specified path.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The requested resource path
* @return HTTP response indicating success
2025-04-04 01:48:55 +02:00
*/
async fn handle_mkcol(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
2025-04-04 21:31:41 +02:00
let folder_service = &state.applications.folder_service;
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
if path.is_empty() || path == "/" {
return Err(AppError::conflict("Root folder already exists"));
}
2026-02-14 01:29:34 +01:00
// 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.
2025-04-04 21:31:41 +02:00
let body_bytes = {
let body = req.into_body();
body::to_bytes(body, MAX_MKCOL_BODY)
2025-04-04 21:31:41 +02:00
.await
.map_err(|e| AppError::payload_too_large(format!("MKCOL body too large: {}", e)))?
2025-04-04 21:31:41 +02:00
};
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
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",
));
}
2026-02-14 01:29:34 +01:00
return Err(AppError::unsupported_media_type(
"MKCOL request body must be empty",
));
2025-04-04 21:31:41 +02:00
}
2026-02-14 01:29:34 +01:00
// 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).
2026-06-19 07:49:33 +02:00
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();
2026-02-14 01:29:34 +01:00
if segments.is_empty() {
return Err(AppError::conflict("Root folder already exists"));
}
2026-02-14 01:29:34 +01:00
// 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)
2026-06-19 01:08:07 +02:00
.await
.unwrap_or(false)
2026-06-19 01:08:07 +02:00
{
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)?;
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
2025-04-04 01:48:55 +02:00
}
/**
2025-04-04 21:31:41 +02:00
* Handles DELETE requests to remove files or folders.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler deletes a file or folder at the specified path.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The requested resource path
* @return HTTP response indicating success
2025-04-04 01:48:55 +02:00
*/
async fn handle_delete(
state: Arc<AppState>,
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
// Active-lock guard (RFC 4918 §9.10.4).
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);
}
2025-04-04 21:31:41 +02:00
// Get services from state
let file_retrieval_service = &state.applications.file_retrieval_service;
let file_management_service = &state.applications.file_management_service;
2025-04-04 21:31:41 +02:00
let folder_service = &state.applications.folder_service;
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
// Check if path is empty (root folder)
if path.is_empty() || path == "/" {
return Err(AppError::forbidden("Cannot delete root folder"));
}
2026-02-14 01:29:34 +01:00
// Resolve via optimized resolver, falling back to the legacy
// double-query lookup (the one GET uses). Necessary because the
// optimized resolver and the read repositories disagree on path
// shape for some files; see `resolve_or_legacy` docs.
let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere
match resolve_or_legacy(&state, &path, user.id).await {
Some(ResolvedResource::Folder(folder)) => {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
}
Some(ResolvedResource::File(file)) => {
file_management_service
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
}
None => return Err(AppError::not_found(format!("Resource not found: {}", path))),
2025-04-04 21:31:41 +02:00
}
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
2025-04-04 01:48:55 +02:00
}
/**
2025-04-04 21:31:41 +02:00
* Handles MOVE requests to rename or relocate files or folders.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler moves a file or folder from one path to another.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The source resource path
* @param req The HTTP request containing the destination path
* @return HTTP response indicating success
2025-04-04 01:48:55 +02:00
*/
2025-04-04 21:31:41 +02:00
async fn handle_move(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let source_path = path;
2026-02-14 01:29:34 +01:00
// Captured up front so a rejected MOVE doesn't run any DB work.
let if_header_owned = req
.headers()
.get("If")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
// removes the source resource, which counts as modifying it.
if let Some(resp) = enforce_native_lock(
&state.webdav_lock_store,
if_header_owned.as_deref(),
&source_path,
) {
return Ok(resp);
}
2025-04-04 21:31:41 +02:00
// Get destination from Destination header
2026-02-14 01:29:34 +01:00
let destination = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Destination")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::bad_request("Destination header required"))?
.to_string();
2026-02-14 01:29:34 +01:00
// Overwrite header (RFC 4918 §9.8.4): T = overwrite, F = fail if exists
2026-02-14 01:29:34 +01:00
let overwrite = req
.headers()
.get("Overwrite")
.and_then(|v| v.to_str().ok())
2026-02-14 01:29:34 +01:00
.unwrap_or("T")
!= "F";
// Extract destination path from URL and percent-decode it
2025-04-04 21:31:41 +02:00
let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
let after_prefix = &destination[webdav_prefix + 8..];
let trimmed = after_prefix.trim_end_matches('/');
percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
2025-04-04 21:31:41 +02:00
} else {
return Err(AppError::bad_request("Invalid destination URL"));
};
2026-02-14 01:29:34 +01:00
// SECURITY: reject path-traversal in destination
reject_path_traversal(&destination_path)?;
// Normalize destination through the SAME path-prefixing that
// `resolve_webdav_path` applied to `source_path` during dispatch.
// Without this, comparing source_parent_path (already prefixed with
// the user's home folder name) against dest_parent_path (raw from
// the URL, no prefix) always reports "different parent" — even for a
// pure rename at the same level — and breaks the move/rename branch
// selection below.
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
.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.
if let Some(resp) = enforce_native_lock(
&state.webdav_lock_store,
if_header_owned.as_deref(),
&destination_path,
) {
return Ok(resp);
}
let file_retrieval_service = &state.applications.file_retrieval_service;
let file_management_service = &state.applications.file_management_service;
2025-04-04 21:31:41 +02:00
let folder_service = &state.applications.folder_service;
2026-02-14 01:29:34 +01:00
2026-06-19 07:49:33 +02:00
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
// 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)
2026-03-03 01:49:18 +01:00
.await
.is_ok()
};
if dest_existed {
if !overwrite {
2026-02-14 01:29:34 +01:00
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 => {}
}
}
2026-02-14 01:29:34 +01:00
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)))?;
let dest_name = destination_path
.rsplit('/')
.next()
.unwrap_or(&destination_path);
let dest_parent_path = destination_path
.rfind('/')
.map(|i| &destination_path[..i])
.unwrap_or("");
let source_parent_path = source_path
.rfind('/')
.map(|i| &source_path[..i])
.unwrap_or("");
match resolved {
ResolvedResource::Folder(folder) => {
// 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
2026-06-19 07:49:33 +02:00
.get_folder_by_path(dest_parent_path, drive_id)
2026-06-19 01:08:07 +02:00
.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
)));
}
}
};
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
parent_id: target_parent_id,
2025-04-04 21:31:41 +02:00
};
2026-02-14 01:29:34 +01:00
folder_service
.move_folder_with_perms(&folder.id, move_dto, user.id)
2026-02-14 01:29:34 +01:00
.await
.map_err(AppError::from)?;
2026-02-14 01:29:34 +01:00
if folder.name != dest_name {
let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto {
name: dest_name.to_string(),
};
folder_service
.rename_folder_with_perms(&folder.id, rename_dto, user.id)
.await
.map_err(AppError::from)?;
}
}
ResolvedResource::File(file) => {
if source_parent_path != dest_parent_path {
// RFC 4918 §9.9.5: missing destination parent → 409 Conflict.
let target_parent_id = if dest_parent_path.is_empty() {
None
} else {
let parent = folder_service
2026-06-19 07:49:33 +02:00
.get_folder_by_path(dest_parent_path, drive_id)
.await
.map_err(|_| {
AppError::conflict(format!(
"Destination parent not found: {}",
dest_parent_path
))
})?;
assert_owner(
parent.owner_id.as_deref(),
&user.id.to_string(),
dest_parent_path,
)?;
Some(parent.id)
};
file_management_service
.move_file_with_perms(&file.id, user.id, target_parent_id)
.await
.map_err(AppError::from)?;
}
if file.name != dest_name {
file_management_service
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(AppError::from)?;
}
}
2025-04-04 21:31:41 +02:00
}
2026-02-14 01:29:34 +01:00
// Migrate dead properties to the new path (RFC 4918 §9.9 — MOVE preserves properties).
state
.webdav_dead_props
.rename_resource(&source_path, user.id, &destination_path)
.await
.map_err(|e| AppError::internal_error(format!("Failed to migrate dead properties: {e}")))?;
// 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
};
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(status)
2025-04-04 21:31:41 +02:00
.body(Body::empty())
.unwrap())
2025-04-04 01:48:55 +02:00
}
/**
2025-04-04 21:31:41 +02:00
* Handles COPY requests to duplicate files or folders.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler copies a file or folder from one path to another.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The source resource path
* @param req The HTTP request containing the destination path
* @return HTTP response indicating success
2025-04-04 01:48:55 +02:00
*/
2025-04-04 21:31:41 +02:00
async fn handle_copy(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let source_path = path;
2026-02-14 01:29:34 +01:00
// Captured up front (cheap; used below for the destination lock guard).
// COPY doesn't mutate the source, so no source lock check — only the
// destination needs to clear (RFC 4918 §9.10.4).
let if_header_owned = req
.headers()
.get("If")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
2025-04-04 21:31:41 +02:00
// Get destination from Destination header
2026-02-14 01:29:34 +01:00
let destination = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Destination")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::bad_request("Destination header required"))?
.to_string();
2026-02-14 01:29:34 +01:00
// Overwrite header (RFC 4918 §9.8.4): T = overwrite, F = fail if exists
2026-02-14 01:29:34 +01:00
let overwrite = req
.headers()
.get("Overwrite")
.and_then(|v| v.to_str().ok())
2026-02-14 01:29:34 +01:00
.unwrap_or("T")
!= "F";
// Extract destination path from URL and percent-decode it
2025-04-04 21:31:41 +02:00
let destination_path = if let Some(webdav_prefix) = destination.find("/webdav/") {
let after_prefix = &destination[webdav_prefix + 8..];
let trimmed = after_prefix.trim_end_matches('/');
percent_decode_str(trimmed).decode_utf8_lossy().into_owned()
2025-04-04 21:31:41 +02:00
} else {
return Err(AppError::bad_request("Invalid destination URL"));
};
2026-02-14 01:29:34 +01:00
// SECURITY: reject path-traversal in destination
reject_path_traversal(&destination_path)?;
// Normalize through the same path-prefixing the dispatcher applied
// to source_path. See the long comment in handle_move for why this
// matters — same root-cause class of asymmetric-path bugs.
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
.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,
if_header_owned.as_deref(),
&destination_path,
) {
return Ok(resp);
}
2025-04-04 21:31:41 +02:00
// Get depth from Depth header
2026-02-14 01:29:34 +01:00
let depth = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Depth")
.and_then(|v| v.to_str().ok())
.unwrap_or("infinity");
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
// 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;
2026-02-14 01:29:34 +01:00
2026-06-19 07:49:33 +02:00
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
// 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)
2026-03-03 01:49:18 +01:00
.await
.is_ok()
};
if dest_existed {
if !overwrite {
2026-02-14 01:29:34 +01:00
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 => {}
}
}
2026-02-14 01:29:34 +01:00
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)))?;
let dest_name = destination_path
.rsplit('/')
.next()
.unwrap_or(&destination_path);
let dest_parent_path = destination_path
.rfind('/')
.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
2025-04-04 21:31:41 +02:00
} else {
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
)));
}
}
};
2026-02-14 01:29:34 +01:00
match resolved {
ResolvedResource::Folder(folder) => {
let recursive = depth != "0";
if recursive {
file_management_service
.copy_folder_tree_with_perms(
&folder.id,
user.id,
target_parent_id,
Some(dest_name.to_string()),
)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to copy folder tree: {}", e))
})?;
} else {
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
name: dest_name.to_string(),
parent_id: target_parent_id,
};
folder_service
.create_folder_with_perms(create_dto, user.id)
.await
.map_err(|e| {
2026-03-03 01:49:18 +01:00
AppError::internal_error(format!(
"Failed to create destination folder: {}",
e
))
})?;
}
}
ResolvedResource::File(file) => {
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)
.await
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
}
2025-04-04 21:31:41 +02:00
}
2026-02-14 01:29:34 +01:00
// 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
};
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(status)
2025-04-04 21:31:41 +02:00
.body(Body::empty())
.unwrap())
2025-04-04 01:48:55 +02:00
}
/**
2025-04-04 21:31:41 +02:00
* Handles LOCK requests to lock resources.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler processes WebDAV LOCK requests according to RFC 4918,
* creating a lock on a file or folder.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The requested resource path
* @param req The HTTP request containing the LOCK XML body
* @return XML response with lock information
2025-04-04 01:48:55 +02:00
*/
async fn handle_lock(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
2026-02-14 01:29:34 +01:00
2026-06-06 18:18:13 +02:00
// 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 {
2026-06-19 07:49:33 +02:00
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
2026-06-06 18:18:13 +02:00
state
.applications
.folder_service
2026-06-19 07:49:33 +02:00
.get_folder_by_path(&path, drive_id)
2026-06-06 18:18:13 +02:00
.await
.is_ok()
};
2025-04-04 21:31:41 +02:00
// Get the headers that we need
2026-02-14 01:29:34 +01:00
let depth = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Depth")
.and_then(|v| v.to_str().ok())
.unwrap_or("infinity")
.to_string();
2026-02-14 01:29:34 +01:00
let timeout = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Timeout")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
2026-02-14 01:29:34 +01:00
let if_header_value = req
.headers()
2025-04-04 21:31:41 +02:00
.get("If")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
// Extract the body separately to avoid borrow issues
let body_bytes = {
// Convert the request into a body
let body = req.into_body();
2026-02-14 01:29:34 +01:00
// Read request body (LOCK is XML, 1 MB is more than enough)
body::to_bytes(body, MAX_XML_BODY)
2025-04-04 21:31:41 +02:00
.await
2026-02-14 01:29:34 +01:00
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?
2025-04-04 21:31:41 +02:00
};
2026-02-14 01:29:34 +01:00
let lock_store = &state.webdav_lock_store;
2025-04-04 21:31:41 +02:00
// Check if this is a lock refresh (If header with a lock token)
if let Some(if_header) = if_header_value {
// Extract lock token from If header
let token = if_header
.trim()
.trim_start_matches("(<")
.trim_end_matches(">)")
.to_string();
2026-02-14 01:29:34 +01:00
// Refresh the lock in the store (extends TTL)
let entry = lock_store
.refresh(&token, timeout.as_deref())
.ok_or_else(|| {
AppError::precondition_failed(format!("Lock token not found or expired: {}", token))
})?;
2026-02-14 01:29:34 +01:00
2026-06-06 18:18:13 +02:00
// Generate response — collection vs file href chosen above.
let href = if is_collection {
webdav_collection_href(&path)
} else {
webdav_href(&path)
};
2025-04-04 21:31:41 +02:00
let mut response_body = Vec::new();
WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err(
2026-02-14 01:29:34 +01:00
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
)?;
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.header(HEADER_LOCK_TOKEN, format!("<{}>", entry.info.token))
2025-04-04 21:31:41 +02:00
.body(Body::from(response_body))
.unwrap())
} else if !body_bytes.is_empty() {
// Parse lock request
2026-02-14 01:29:34 +01:00
let (scope, type_, owner) = WebDavAdapter::parse_lockinfo(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse LOCK request: {}", e)))?;
2025-04-04 21:31:41 +02:00
let token = format!("opaquelocktoken:{}", Uuid::new_v4());
let lock_info = LockInfo {
token,
owner: owner.or(Some(user.id.to_string())),
2025-04-04 21:31:41 +02:00
depth: depth.to_string(),
timeout,
scope,
type_,
};
2026-02-14 01:29:34 +01:00
// Try to acquire the lock (conflict detection via moka store)
let entry = lock_store.acquire(&path, lock_info).map_err(|existing| {
AppError::locked(format!(
"Resource already locked by token {}",
existing.info.token
))
})?;
2026-06-06 18:18:13 +02:00
// Generate response — collection vs file href chosen above.
let href = if is_collection {
webdav_collection_href(&path)
} else {
webdav_href(&path)
};
2025-04-04 21:31:41 +02:00
let mut response_body = Vec::new();
WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err(
2026-02-14 01:29:34 +01:00
|e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)),
)?;
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.header(HEADER_LOCK_TOKEN, format!("<{}>", entry.info.token))
2025-04-04 21:31:41 +02:00
.body(Body::from(response_body))
.unwrap())
} else {
Err(AppError::bad_request("Invalid LOCK request"))
}
2025-04-04 01:48:55 +02:00
}
/**
2025-04-04 21:31:41 +02:00
* Handles UNLOCK requests to remove locks from resources.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* This handler processes WebDAV UNLOCK requests according to RFC 4918,
* removing a lock from a file or folder.
2026-02-14 01:29:34 +01:00
*
2025-04-04 21:31:41 +02:00
* @param state The application state containing service dependencies
* @param user The authenticated user information
* @param path The requested resource path
* @param req The HTTP request containing the lock token
* @return HTTP response indicating success
2025-04-04 01:48:55 +02:00
*/
async fn handle_unlock(
state: Arc<AppState>,
2025-04-04 21:31:41 +02:00
req: Request<Body>,
_path: String,
2025-04-04 21:31:41 +02:00
) -> Result<Response<Body>, AppError> {
let _user = extract_user(&req)?;
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
// Get lock token from Lock-Token header
2026-02-14 01:29:34 +01:00
let lock_token = req
.headers()
2025-04-04 21:31:41 +02:00
.get("Lock-Token")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::bad_request("Lock-Token header required"))?;
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
// Extract token from header value (format: <token>)
let token = lock_token
2025-04-04 21:31:41 +02:00
.trim()
.trim_start_matches('<')
.trim_end_matches('>')
.to_string();
2026-02-14 01:29:34 +01:00
// Remove the lock from the store
if !state.webdav_lock_store.release(&token) {
// RFC 4918 §9.11.1: If the lock does not exist, return 409 Conflict
return Err(AppError::conflict(format!(
"Lock token not found or already expired: {}",
token
)));
}
2026-02-14 01:29:34 +01:00
2025-04-04 21:31:41 +02:00
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
2026-02-14 01:29:34 +01:00
}
2026-06-06 18:18:13 +02:00
#[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/"
);
}
}