/** * WebDAV Handler Module * * 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::{ Router, body::{self, Body}, http::{HeaderName, Request, StatusCode, header}, response::Response, }; use bytes::{Buf, Bytes}; use chrono::Utc; use quick_xml::Writer; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, }; use crate::application::dtos::file_dto::FileDto; 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; use crate::common::di::AppState; 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}; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use std::sync::Arc; /// 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::>().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 } /// Build the `` 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 `` 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 ""` /// when the response's own-entry href is missing the trailing `/`. /// PROPPATCH and LOCK responses on folders MUST use this — using /// [`webdav_href`] for a folder is the bug class this helper /// exists to prevent. fn webdav_collection_href(path: &str) -> String { let h = webdav_href(path); if h.ends_with('/') { h } else { format!("{}/", h) } } // Create a custom DAV header since it's not in the standard headers const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); const HEADER_LOCK_TOKEN: HeaderName = HeaderName::from_static("lock-token"); // const HEADER_IF: HeaderName = HeaderName::from_static("if"); /// 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) -> Result { req.extensions() .get::>() .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))), } } /** * Creates and returns the WebDAV router with all required endpoints. * * This function sets up all WebDAV method handlers following RFC 4918, * mapping HTTP methods to appropriate WebDAV operations. * * @return Router configured with WebDAV endpoints */ pub fn webdav_routes() -> Router> { // Three explicit routes to avoid Axum trailing-slash gaps // (same pattern used for CalDAV/CardDAV) Router::new() .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) percent_decode_str(encoded).decode_utf8_lossy().into_owned() } async fn handle_webdav_methods_root( axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, AppError> { handle_webdav_dispatch(state, req, String::new()).await } async fn handle_webdav_methods( axum::extract::State(state): axum::extract::State>, req: Request, ) -> Result, 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, user_id: Uuid, path: &str) -> Option { 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)) } } /// 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//…`) /// 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, user_id: Uuid, ) -> Result { 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, req: Request, path: String, ) -> Result, AppError> { let method = req.method().clone(); // 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::>().map(|u| u.id); if let Some(uid) = user_id { resolve_webdav_path(&state, uid, &path) .await .unwrap_or(path) } else { path } } else { path }; 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, _ => Err(AppError::method_not_allowed(format!( "Method not allowed: {}", method ))), } } /** * Handles OPTIONS requests to advertise WebDAV capabilities. * * This handler responds with the DAV header indicating WebDAV compliance * level and the methods supported by this WebDAV server. * * @param state The application state containing service dependencies * @param path The requested resource path * @return HTTP response with appropriate WebDAV headers */ async fn handle_options(_path: String) -> Result, AppError> { Ok(Response::builder() .status(StatusCode::OK) .header(HEADER_DAV, "1, 2") // Class 1 and 2 WebDAV support .header( header::ALLOW, "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK", ) .body(Body::empty()) .unwrap()) } /** * Handles PROPFIND requests to retrieve resource properties. * * 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. * * @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 */ async fn handle_propfind( state: Arc, req: Request, path: String, ) -> Result, AppError> { // ── 1. Extract and validate Depth header ───────────────────── let depth = req .headers() .get("Depth") .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#" "#; return Ok(Response::builder() .status(StatusCode::FORBIDDEN) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(body)) .unwrap()); } // 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)?; // 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 ─────────────────────────────── let body_bytes = { let body = req.into_body(); body::to_bytes(body, MAX_XML_BODY) .await .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; let propfind_request = if body_bytes.is_empty() { PropFindRequest { prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp, } } else { WebDavAdapter::parse_propfind(body_bytes.reader()).map_err(|e| { AppError::bad_request(format!("Failed to parse PROPFIND request: {}", e)) })? }; // ── 4. Services ────────────────────────────────────────────── let folder_service = state.applications.folder_service.clone(); let file_retrieval_service = state.applications.file_retrieval_service.clone(); // 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)) }; // ── 5. Determine target resource ───────────────────────────── if path.is_empty() || path == "/" { // Root folder let root_folder = FolderDto { id: "root".to_string(), etag: "root".to_string(), name: "".to_string(), path: "".to_string(), parent_id: None, owner_id: None, // Synthetic root folder for PROPFIND on `/`; not an // actual DB row, so drive_id has no meaningful value. drive_id: Uuid::nil(), 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"), // §14 provenance not applicable to the synthetic root. created_by: None, updated_by: None, }; return build_streaming_propfind_response( root_folder, None, // folder_id = None → root children &depth_owned, &base_href, propfind_request, folder_service, file_retrieval_service, user.id, state.webdav_dead_props.clone(), path.clone(), ) .await; } // 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( &mut xml_writer, &file, &propfind_request, &file_href, &dead_props, ) .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 { // 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; } 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( &mut xml_writer, &file, &propfind_request, &file_href, &dead_props, ) .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(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, depth: &str, base_href: &str, propfind_request: PropFindRequest, folder_service: std::sync::Arc, file_retrieval_service: std::sync::Arc, user_id: Uuid, dead_props_store: Arc< crate::infrastructure::services::webdav_dead_property_store::DeadPropertyStore, >, folder_internal_path: String, ) -> Result, 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 + + 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 = 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; } } // ── Close ─────────────────────────────── 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 { 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()) } /** * Handles PROPPATCH requests to set or remove resource properties. * * This handler processes WebDAV PROPPATCH requests according to RFC 4918, * modifying properties of files and folders in the specified path. * * @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( state: Arc, req: Request, path: String, ) -> Result, AppError> { let user = extract_user(&req)?; // Client-facing path for href construction (without home folder prefix). let client_path = extract_webdav_path(req.uri()); // 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); } // 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 { let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; state .applications .folder_service .get_folder_by_path(&path, drive_id) .await .is_ok() }; // Read request body (XML — bounded to 1 MB) let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY) .await .map_err(|e| { AppError::payload_too_large(format!("PROPPATCH body too large or unreadable: {}", e)) })?; let ops = WebDavAdapter::parse_proppatch(body_bytes.reader()) .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)); } } } // Generate response — use client-facing path so href matches the request URL. let href = if is_collection { webdav_collection_href(&client_path) } else { webdav_href(&client_path) }; let mut response_body = Vec::new(); WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( |e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)), )?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(response_body)) .unwrap()) } /** * Handles GET requests to retrieve file contents. * * This handler retrieves the contents of a file at the specified path. * * @param state The application state containing service dependencies * @param user The authenticated user information * @param path The requested resource path * @return HTTP response with file contents */ async fn handle_get( state: Arc, req: Request, path: String, ) -> Result, AppError> { let user = extract_user(&req)?; // Get file service from state let file_retrieval_service = &state.applications.file_retrieval_service; // Check if path is empty (root folder) if path.is_empty() || path == "/" { return Err(AppError::bad_request("Cannot GET a directory")); } // 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 { // 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 .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 }; 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) .await .map_err(|e| AppError::internal_error(format!("Failed to stream file: {}", e)))?; // Build streaming response using Content-Length from metadata 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") .header( header::LAST_MODIFIED, chrono::DateTime::::from_timestamp(file.created_at as i64, 0) .unwrap_or_else(Utc::now) .to_rfc2822(), ) .body(Body::from_stream(Box::into_pin(stream))) .unwrap()) } /** * Handles HEAD requests — same as GET but returns only headers, no body. */ async fn handle_head( state: Arc, req: Request, path: String, ) -> Result, 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::::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))), } } // 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 let file = file_retrieval_service .get_file_by_path(&path, drive_id) .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)) .header( header::LAST_MODIFIED, chrono::DateTime::::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 - /` 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, path: &str, user_id: Uuid, ) -> Option { if let Some(resolver) = &state.path_resolver && let Ok(r) = resolver.resolve_path_for_user(path, user_id).await { return Some(r); } // 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; 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; 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 { 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> { // 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); } } }); 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() .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(), ); } } None } /** * Handles PUT requests to create or update files. * * **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`. * * @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 */ async fn handle_put( state: Arc, req: Request, path: String, ) -> Result, AppError> { use crate::interfaces::upload_ingest; let user = extract_user(&req)?; let file_upload_service = &state.applications.file_upload_service; if path.is_empty() || path == "/" { return Err(AppError::bad_request("Cannot PUT to root folder")); } // 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 = 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: → strong comparison against current ETag match ¤t_etag { None => { return Err(AppError::precondition_failed( "If-Match — resource does not exist", )); } Some(etag) => { let client_tag = im.trim_matches('"'); let server_tag = etag.trim_matches('"'); if client_tag != server_tag { return Err(AppError::precondition_failed("If-Match — ETag mismatch")); } } } } } // ── Streaming ingest ────────────────────────────────────────────── let filename = crate::common::mime_detect::filename_from_path(&path).to_string(); let ingested = upload_ingest::ingest_body_to_cas( req.into_body(), &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(); let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let result = file_upload_service .update_file_streaming( &path, drive_id, ingested.stored(), &content_type, None, user.id, ) .await; 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()) } Err(e) => Err(AppError::internal_error(format!( "Failed to put file: {}", e ))), } } /** * Handles MKCOL requests to create folders. * * This handler creates a new folder at the specified path. * * @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 */ async fn handle_mkcol( state: Arc, req: Request, path: String, ) -> Result, AppError> { let user = extract_user(&req)?; let folder_service = &state.applications.folder_service; if path.is_empty() || path == "/" { return Err(AppError::conflict("Root folder already exists")); } // Extract content-type before consuming the body. let req_content_type = req .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); // RFC 4918 §9.3.1: MKCOL body MUST be empty. A non-empty body with a // recognised XML content-type is 400 Bad Request (malformed MKCOL body); // a non-empty body with an unrecognised content-type is 415 Unsupported // Media Type. We read up to MAX_MKCOL_BODY bytes to distinguish the two. let body_bytes = { let body = req.into_body(); body::to_bytes(body, MAX_MKCOL_BODY) .await .map_err(|e| AppError::payload_too_large(format!("MKCOL body too large: {}", e)))? }; if !body_bytes.is_empty() { // A body whose content-type looks like XML → 400 (client sent a MKCOL // extended request we don't support); anything else → 415. let ct = req_content_type.as_deref().unwrap_or(""); if ct.contains("xml") { return Err(AppError::bad_request( "MKCOL with XML body is not supported", )); } return Err(AppError::unsupported_media_type( "MKCOL request body must be empty", )); } // RFC 4918 §9.3.1: MKCOL on an existing URL MUST return 405. // RFC 4918 §9.3.1: MKCOL without an existing parent MUST return 409. // This handler only creates a single collection (the last path segment). // It does NOT auto-create intermediate ancestors ("mkdir -p" semantics // violate the RFC and were causing the test failures). let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { return Err(AppError::conflict("Root folder already exists")); } // Check whether the target itself already exists (file or folder → 405). if let Some(resolver) = &state.path_resolver { if resolver .exists_for_user(&path, user.id) .await .unwrap_or(false) { return Err(AppError::new( StatusCode::METHOD_NOT_ALLOWED, "Collection already exists", "AlreadyExists", )); } } else if folder_service .get_folder_by_path(&path, drive_id) .await .is_ok() { return Err(AppError::new( StatusCode::METHOD_NOT_ALLOWED, "Collection already exists", "AlreadyExists", )); } // Resolve the parent path. RFC 4918 §9.3.1: if the parent does not // exist, return 409 Conflict. If the parent exists but is a file, also // return 409 (cannot create a collection inside a file). let new_segment = *segments.last().unwrap(); let parent_segments = &segments[..segments.len() - 1]; let parent_id = if parent_segments.is_empty() { // Top-level creation — no parent required; the root folder acts as parent. None } else { let parent_path = parent_segments.join("/"); // Parent must be a folder, not a file. if let Some(resolver) = &state.path_resolver { match resolver.resolve_path_for_user(&parent_path, user.id).await { Ok(ResolvedResource::Folder(f)) => Some(f.id), Ok(ResolvedResource::File(_)) => { return Err(AppError::conflict( "Parent path is a file, not a collection", )); } Err(_) => { return Err(AppError::conflict(format!( "Parent folder not found: {}", parent_path ))); } } } else { match folder_service .get_folder_by_path(&parent_path, drive_id) .await { Ok(f) => Some(f.id), Err(_) => { return Err(AppError::conflict(format!( "Parent folder not found: {}", parent_path ))); } } } }; let create_dto = crate::application::dtos::folder_dto::CreateFolderDto { name: new_segment.to_string(), parent_id, }; folder_service .create_folder_with_perms(create_dto, user.id) .await .map_err(AppError::from)?; Ok(Response::builder() .status(StatusCode::CREATED) .body(Body::empty()) .unwrap()) } /** * Handles DELETE requests to remove files or folders. * * This handler deletes a file or folder at the specified path. * * @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 */ async fn handle_delete( state: Arc, req: Request, path: String, ) -> Result, 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); } // Get services from state let file_retrieval_service = &state.applications.file_retrieval_service; let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; // Check if path is empty (root folder) if path.is_empty() || path == "/" { return Err(AppError::forbidden("Cannot delete root folder")); } // 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))), } Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) .unwrap()) } /** * Handles MOVE requests to rename or relocate files or folders. * * This handler moves a file or folder from one path to another. * * @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 */ async fn handle_move( state: Arc, req: Request, path: String, ) -> Result, AppError> { let user = extract_user(&req)?; let source_path = path; // 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); } // Get destination from Destination header let destination = req .headers() .get("Destination") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::bad_request("Destination header required"))? .to_string(); // Overwrite header (RFC 4918 §9.8.4): T = overwrite, F = fail if exists let overwrite = req .headers() .get("Overwrite") .and_then(|v| v.to_str().ok()) .unwrap_or("T") != "F"; // Extract destination path from URL and percent-decode it 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() } else { return Err(AppError::bad_request("Invalid destination URL")); }; // 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; let folder_service = &state.applications.folder_service; 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) .await .is_ok() }; if dest_existed { if !overwrite { return Err(AppError::precondition_failed( "Destination already exists and Overwrite is F", )); } // RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the // destination before moving. Without this the rename/move fails // on a unique-index conflict (same name in same parent). match resolve_or_legacy(&state, &destination_path, user.id).await { Some(ResolvedResource::Folder(f)) => { folder_service .delete_folder_with_perms(&f.id, user.id) .await .map_err(|e| { AppError::internal_error(format!( "Failed to delete existing destination: {}", e )) })?; } Some(ResolvedResource::File(f)) => { file_management_service .delete_file_with_perms(&f.id, user.id) .await .map_err(|e| { AppError::internal_error(format!( "Failed to delete existing destination: {}", e )) })?; } None => {} } } 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 .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 ))); } } }; let move_dto = crate::application::dtos::folder_dto::MoveFolderDto { parent_id: target_parent_id, }; folder_service .move_folder_with_perms(&folder.id, move_dto, user.id) .await .map_err(AppError::from)?; 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 .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)?; } } } // 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 }; Ok(Response::builder() .status(status) .body(Body::empty()) .unwrap()) } /** * Handles COPY requests to duplicate files or folders. * * This handler copies a file or folder from one path to another. * * @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 */ async fn handle_copy( state: Arc, req: Request, path: String, ) -> Result, AppError> { let user = extract_user(&req)?; let source_path = path; // 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()); // Get destination from Destination header let destination = req .headers() .get("Destination") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::bad_request("Destination header required"))? .to_string(); // Overwrite header (RFC 4918 §9.8.4): T = overwrite, F = fail if exists let overwrite = req .headers() .get("Overwrite") .and_then(|v| v.to_str().ok()) .unwrap_or("T") != "F"; // Extract destination path from URL and percent-decode it 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() } else { return Err(AppError::bad_request("Invalid destination URL")); }; // 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); } // Get depth from Depth header let depth = req .headers() .get("Depth") .and_then(|v| v.to_str().ok()) .unwrap_or("infinity"); // 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; 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) .await .is_ok() }; if dest_existed { if !overwrite { return Err(AppError::precondition_failed( "Destination already exists and Overwrite is F", )); } // RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a // DELETE on the destination before the copy. Without this the copy // service returns a unique-index conflict (500). match resolve_or_legacy(&state, &destination_path, user.id).await { Some(ResolvedResource::Folder(f)) => { folder_service .delete_folder_with_perms(&f.id, user.id) .await .map_err(|e| { AppError::internal_error(format!( "Failed to delete existing destination: {}", e )) })?; } Some(ResolvedResource::File(f)) => { file_management_service .delete_file_with_perms(&f.id, user.id) .await .map_err(|e| { AppError::internal_error(format!( "Failed to delete existing destination: {}", e )) })?; } None => {} } } 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 } 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 ))); } } }; 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| { 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)))?; } } // RFC 4918 §9.8.5: 201 Created when destination is new, 204 when overwritten. let status = if dest_existed { StatusCode::NO_CONTENT } else { StatusCode::CREATED }; Ok(Response::builder() .status(status) .body(Body::empty()) .unwrap()) } /** * Handles LOCK requests to lock resources. * * This handler processes WebDAV LOCK requests according to RFC 4918, * creating a lock on a file or folder. * * @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 */ async fn handle_lock( state: Arc, req: Request, path: String, ) -> Result, AppError> { let user = extract_user(&req)?; // Determine collection-vs-file for href shape. Root + known // folders → collection; everything else (existing files, // lock-null on a non-existent path) → file. RFC 4918 §9.10.1 // allows LOCK on a non-existent resource (the "lock-null // resource" pattern used by Office save flows) — that arm // falls through to the file href shape, matching the // request-line shape clients send. let is_collection = if path.is_empty() || path == "/" { true } else { let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; state .applications .folder_service .get_folder_by_path(&path, drive_id) .await .is_ok() }; // Get the headers that we need let depth = req .headers() .get("Depth") .and_then(|v| v.to_str().ok()) .unwrap_or("infinity") .to_string(); let timeout = req .headers() .get("Timeout") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let if_header_value = req .headers() .get("If") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); // Extract the body separately to avoid borrow issues let body_bytes = { // Convert the request into a body let body = req.into_body(); // Read request body (LOCK is XML, 1 MB is more than enough) body::to_bytes(body, MAX_XML_BODY) .await .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))? }; let lock_store = &state.webdav_lock_store; // 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(); // 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)) })?; // Generate response — collection vs file href chosen above. let href = if is_collection { webdav_collection_href(&path) } else { webdav_href(&path) }; let mut response_body = Vec::new(); WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err( |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), )?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .header(HEADER_LOCK_TOKEN, format!("<{}>", entry.info.token)) .body(Body::from(response_body)) .unwrap()) } else if !body_bytes.is_empty() { // Parse lock request let (scope, type_, owner) = WebDavAdapter::parse_lockinfo(body_bytes.reader()) .map_err(|e| AppError::bad_request(format!("Failed to parse LOCK request: {}", e)))?; let token = format!("opaquelocktoken:{}", Uuid::new_v4()); let lock_info = LockInfo { token, owner: owner.or(Some(user.id.to_string())), depth: depth.to_string(), timeout, scope, type_, }; // 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 )) })?; // Generate response — collection vs file href chosen above. let href = if is_collection { webdav_collection_href(&path) } else { webdav_href(&path) }; let mut response_body = Vec::new(); WebDavAdapter::generate_lock_response(&mut response_body, &entry.info, &href).map_err( |e| AppError::internal_error(format!("Failed to generate LOCK response: {}", e)), )?; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .header(HEADER_LOCK_TOKEN, format!("<{}>", entry.info.token)) .body(Body::from(response_body)) .unwrap()) } else { Err(AppError::bad_request("Invalid LOCK request")) } } /** * Handles UNLOCK requests to remove locks from resources. * * This handler processes WebDAV UNLOCK requests according to RFC 4918, * removing a lock from a file or folder. * * @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 */ async fn handle_unlock( state: Arc, req: Request, _path: String, ) -> Result, AppError> { let _user = extract_user(&req)?; // Get lock token from Lock-Token header let lock_token = req .headers() .get("Lock-Token") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::bad_request("Lock-Token header required"))?; // Extract token from header value (format: ) let token = lock_token .trim() .trim_start_matches('<') .trim_end_matches('>') .to_string(); // 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 ))); } Ok(Response::builder() .status(StatusCode::NO_CONTENT) .body(Body::empty()) .unwrap()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_webdav_href_no_trailing_slash() { assert_eq!( webdav_href("Documents/report.pdf"), "/webdav/Documents/report.pdf" ); assert_eq!(webdav_href("file.txt"), "/webdav/file.txt"); } #[test] fn test_webdav_collection_href_appends_slash_when_missing() { assert_eq!(webdav_collection_href("Documents"), "/webdav/Documents/"); assert_eq!( webdav_collection_href("Documents/subfolder"), "/webdav/Documents/subfolder/" ); } #[test] fn test_webdav_collection_href_idempotent_when_already_slashed() { // `encode_uri_path` never emits a trailing `/` of its own // because the path argument is already trimmed by routing, // but the helper still has to be robust to a path that // happens to end in `/` — exercise the idempotence path. assert_eq!(webdav_collection_href("Documents/"), "/webdav/Documents/"); } #[test] fn test_webdav_href_preserves_url_encoding() { // Spaces and Unicode must percent-encode at the segment level, // not get a verbatim `%20` re-encoded as `%2520`. assert_eq!( webdav_href("My Photos/vacation pic.jpg"), "/webdav/My%20Photos/vacation%20pic.jpg" ); assert_eq!( webdav_collection_href("My Photos/2024"), "/webdav/My%20Photos/2024/" ); } }