From b5afa1a855110df16e8e50fe6138f0bd615df181 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Fri, 26 Jun 2026 21:19:03 +0200 Subject: [PATCH] feat(webdav): implement RFC 4918 dead property store Add an in-memory DeadPropertyStore backed by RwLock that stores arbitrary client-supplied XML properties per resource path. Wire it into AppState so PROPPATCH can persist dead props and PROPFIND can retrieve them across requests. --- src/application/adapters/webdav_adapter.rs | 301 ++++++++++++++---- src/common/di.rs | 4 + src/infrastructure/services/mod.rs | 1 + .../services/webdav_dead_property_store.rs | 184 +++++++++++ src/interfaces/api/handlers/webdav_handler.rs | 137 +++++--- 5 files changed, 532 insertions(+), 95 deletions(-) create mode 100644 src/infrastructure/services/webdav_dead_property_store.rs diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index f8a77096..a1e51ef5 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -96,6 +96,13 @@ pub struct PropValue { pub value: Option, } +/// A single PROPPATCH operation (preserves document order per RFC 4918 §9.2). +#[derive(Debug, Clone)] +pub enum PropPatchOp { + Set(PropValue), + Remove(QualifiedName), +} + /// WebDAV lock information #[derive(Debug, Clone)] pub struct LockInfo { @@ -188,13 +195,36 @@ impl WebDavAdapter { ) { for attr in e.attributes().flatten() { let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); - if let Some(prefix) = key.strip_prefix("xmlns:") { - let uri = attr.unescape_value().unwrap_or_default().to_string(); - ns_map.insert(prefix.to_string(), uri); + let prefix = key + .strip_prefix("xmlns:") + .map(str::to_owned) + .or_else(|| (key == "xmlns").then(String::new)); + if let Some(prefix) = prefix { + ns_map.insert( + prefix, + attr.unescape_value().unwrap_or_default().to_string(), + ); } } } + /// Reject `xmlns:prefix=""` declarations — binding a prefix to an empty URI + /// is forbidden by the XML Namespaces 1.0 spec (RFC 4918 §8.1 requires 400). + fn check_ns_decls_valid(e: &BytesStart) -> Result<()> { + for attr in e.attributes().flatten() { + let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or(""); + if key.starts_with("xmlns:") { + let uri = attr.unescape_value().unwrap_or_default(); + if uri.is_empty() { + return Err(WebDavError::ParseError( + "Invalid namespace declaration: prefix bound to empty URI".to_string(), + )); + } + } + } + Ok(()) + } + /// Resolve a prefixed element name (e.g. `D:resourcetype`) to a /// `QualifiedName` using the accumulated namespace declarations. pub fn resolve_name( @@ -208,6 +238,11 @@ impl WebDavAdapter { return QualifiedName::new(uri.clone(), local.to_string()); } } + // No prefix: check for a default namespace (xmlns="..."). + // An empty string means xmlns="" — null namespace override, which is valid. + if let Some(default_ns) = ns_map.get("") { + return QualifiedName::new(default_ns.clone(), name_str.to_string()); + } // Fallback: no prefix or unknown prefix → use legacy extraction QualifiedName::new( Self::extract_namespace(name_str), @@ -222,6 +257,7 @@ impl WebDavAdapter { let mut buffer = Vec::new(); let mut in_propfind = false; + let mut saw_propfind_close = false; let mut in_prop = false; let mut in_allprop = false; let mut in_propname = false; @@ -232,6 +268,7 @@ impl WebDavAdapter { match xml_reader.read_event_into(&mut buffer) { Ok(Event::Start(ref e)) => { Self::collect_ns_decls(e, &mut ns_map); + Self::check_ns_decls_valid(e)?; let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -258,6 +295,7 @@ impl WebDavAdapter { if name_str == "propfind" || name_str.ends_with(":propfind") { in_propfind = false; + saw_propfind_close = true; } else if name_str == "prop" || name_str.ends_with(":prop") { in_prop = false; } else if name_str == "allprop" || name_str.ends_with(":allprop") { @@ -268,6 +306,7 @@ impl WebDavAdapter { } Ok(Event::Empty(ref e)) => { Self::collect_ns_decls(e, &mut ns_map); + Self::check_ns_decls_valid(e)?; let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -290,6 +329,16 @@ impl WebDavAdapter { buffer.clear(); } + // RFC 4918 §8.1: non-well-formed XML MUST produce 400. quick-xml is + // lenient about EOF-inside-element (no XmlError on unclosed tags), so + // check explicitly: body must contain a complete …. + if !saw_propfind_close { + return Err(WebDavError::ParseError( + "PROPFIND body is not well-formed XML: missing or unclosed element" + .to_string(), + )); + } + let prop_find_type = if in_allprop { PropFindType::AllProp } else if in_propname { @@ -301,7 +350,7 @@ impl WebDavAdapter { Ok(PropFindRequest { prop_find_type }) } - fn folder_prop_is_known(prop: &QualifiedName) -> bool { + fn prop_is_known(prop: &QualifiedName) -> bool { prop.namespace == "DAV:" && matches!( prop.name.as_str(), @@ -315,18 +364,22 @@ impl WebDavAdapter { ) } - fn file_prop_is_known(prop: &QualifiedName) -> bool { - prop.namespace == "DAV:" - && matches!( - prop.name.as_str(), - "resourcetype" - | "displayname" - | "getcontenttype" - | "getcontentlength" - | "creationdate" - | "getlastmodified" - | "getetag" - ) + /// Write a single qualified name as an empty XML element with proper namespace declaration. + /// + /// DAV: props use the `D:` prefix (already declared on the root element). + /// All other namespaces get a local `xmlns:X` declaration on the element itself. + fn write_qname_empty(xml_writer: &mut Writer, prop: &QualifiedName) -> Result<()> { + if prop.namespace.is_empty() { + xml_writer.write_event(Event::Empty(BytesStart::new(prop.name.as_str())))?; + } else if prop.namespace == "DAV:" { + xml_writer.write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?; + } else { + let tag = format!("X:{}", prop.name); + let mut start = BytesStart::new(tag.as_str()); + start.push_attribute(("xmlns:X", prop.namespace.as_str())); + xml_writer.write_event(Event::Empty(start))?; + } + Ok(()) } /// Write a 404 propstat block for unknown properties (RFC 4918 §9.2). @@ -340,15 +393,7 @@ impl WebDavAdapter { xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; for prop in unknown { - if prop.namespace == "DAV:" { - xml_writer - .write_event(Event::Empty(BytesStart::new(format!("D:{}", prop.name))))?; - } else { - xml_writer.write_event(Event::Empty(BytesStart::new(format!( - "{}:{}", - prop.namespace, prop.name - ))))?; - } + Self::write_qname_empty(xml_writer, prop)?; } xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; @@ -358,12 +403,64 @@ impl WebDavAdapter { Ok(()) } + /// Write a dead-property propstat block (RFC 4918 §4.2). + /// + /// Written AFTER the live-property propstats inside a ``. + /// Only emitted when `dead_props` is non-empty. + fn write_dead_props_propstat( + xml_writer: &mut Writer, + dead_props: &[(QualifiedName, Option)], + ) -> Result<()> { + if dead_props.is_empty() { + return Ok(()); + } + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + for (name, value) in dead_props { + let tag = if name.namespace.is_empty() { + name.name.clone() + } else { + format!("X:{}", name.name) + }; + let mut start = BytesStart::new(tag.as_str()); + if !name.namespace.is_empty() { + start.push_attribute(("xmlns:X", name.namespace.as_str())); + } + match value { + Some(v) if !v.is_empty() => { + xml_writer.write_event(Event::Start(start))?; + xml_writer.write_event(Event::Text(BytesText::new(v)))?; + xml_writer.write_event(Event::End(BytesEnd::new(tag.as_str())))?; + } + _ => { + xml_writer.write_event(Event::Empty(start))?; + } + } + } + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + Ok(()) + } + /// Write folder properties as a response fn write_folder_response( xml_writer: &mut Writer, folder: &FolderDto, request: &PropFindRequest, href: &str, + ) -> Result<()> { + Self::write_folder_response_with_dead_props(xml_writer, folder, request, href, &[]) + } + + fn write_folder_response_with_dead_props( + xml_writer: &mut Writer, + folder: &FolderDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], ) -> Result<()> { xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; @@ -371,11 +468,30 @@ impl WebDavAdapter { xml_writer.write_event(Event::Text(BytesText::new(href)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + // Compute dead props first so we can exclude them from the 404 propstat. + let relevant_dead: Vec<_> = match &request.prop_find_type { + PropFindType::Prop(requested) => dead_props + .iter() + .filter(|(name, _)| requested.iter().any(|r| r == name)) + .cloned() + .collect(), + PropFindType::AllProp => dead_props.to_vec(), + PropFindType::PropName => vec![], + }; + let dead_name_set: std::collections::HashSet<&QualifiedName> = + relevant_dead.iter().map(|(n, _)| n).collect(); + match &request.prop_find_type { PropFindType::Prop(props) => { // RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat. + // Props found in the dead store are returned in the dead 200 propstat, + // so exclude them from the 404 propstat to avoid duplicate reporting. let (known, unknown): (Vec<_>, Vec<_>) = - props.iter().partition(|p| Self::folder_prop_is_known(p)); + props.iter().partition(|p| Self::prop_is_known(p)); + let truly_unknown: Vec<_> = unknown + .into_iter() + .filter(|p| !dead_name_set.contains(*p)) + .collect(); xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; @@ -386,7 +502,7 @@ impl WebDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; - Self::write_unknown_props_404(xml_writer, &unknown)?; + Self::write_unknown_props_404(xml_writer, &truly_unknown)?; } other => { xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; @@ -408,6 +524,9 @@ impl WebDavAdapter { } } + // Dead properties — written as a separate 200 propstat (RFC 4918 §4.2). + Self::write_dead_props_propstat(xml_writer, &relevant_dead)?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; Ok(()) } @@ -418,6 +537,16 @@ impl WebDavAdapter { file: &FileDto, request: &PropFindRequest, href: &str, + ) -> Result<()> { + Self::write_file_response_with_dead_props(xml_writer, file, request, href, &[]) + } + + fn write_file_response_with_dead_props( + xml_writer: &mut Writer, + file: &FileDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], ) -> Result<()> { xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; @@ -425,11 +554,30 @@ impl WebDavAdapter { xml_writer.write_event(Event::Text(BytesText::new(href)))?; xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + // Compute dead props first so we can exclude them from the 404 propstat. + let relevant_dead: Vec<_> = match &request.prop_find_type { + PropFindType::Prop(requested) => dead_props + .iter() + .filter(|(name, _)| requested.iter().any(|r| r == name)) + .cloned() + .collect(), + PropFindType::AllProp => dead_props.to_vec(), + PropFindType::PropName => vec![], + }; + let dead_name_set: std::collections::HashSet<&QualifiedName> = + relevant_dead.iter().map(|(n, _)| n).collect(); + match &request.prop_find_type { PropFindType::Prop(props) => { // RFC 4918 §9.2: known props → 200 propstat; unknown → 404 propstat. + // Props found in the dead store are returned in the dead 200 propstat, + // so exclude them from the 404 propstat to avoid duplicate reporting. let (known, unknown): (Vec<_>, Vec<_>) = - props.iter().partition(|p| Self::file_prop_is_known(p)); + props.iter().partition(|p| Self::prop_is_known(p)); + let truly_unknown: Vec<_> = unknown + .into_iter() + .filter(|p| !dead_name_set.contains(*p)) + .collect(); xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; @@ -440,7 +588,7 @@ impl WebDavAdapter { xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; - Self::write_unknown_props_404(xml_writer, &unknown)?; + Self::write_unknown_props_404(xml_writer, &truly_unknown)?; } other => { xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; @@ -462,6 +610,9 @@ impl WebDavAdapter { } } + // Dead properties (RFC 4918 §4.2). + Self::write_dead_props_propstat(xml_writer, &relevant_dead)?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; Ok(()) } @@ -606,7 +757,7 @@ impl WebDavAdapter { fn write_folder_requested_props( xml_writer: &mut Writer, folder: &FolderDto, - props: &[QualifiedName], + props: &[&QualifiedName], ) -> Result<()> { for prop in props { if prop.namespace == "DAV:" { @@ -682,7 +833,7 @@ impl WebDavAdapter { fn write_file_requested_props( xml_writer: &mut Writer, file: &FileDto, - props: &[QualifiedName], + props: &[&QualifiedName], ) -> Result<()> { for prop in props { if prop.namespace == "DAV:" { @@ -752,8 +903,11 @@ impl WebDavAdapter { Ok(()) } - /// Parse a PROPPATCH XML request - pub fn parse_proppatch(reader: R) -> Result<(Vec, Vec)> { + /// Parse a PROPPATCH XML request. + /// + /// Returns operations in document order (RFC 4918 §9.2 requires document-order + /// processing so that remove-then-set and set-then-remove yield different results). + pub fn parse_proppatch(reader: R) -> Result> { let mut xml_reader = Reader::from_reader(BufReader::new(reader)); xml_reader.config_mut().trim_text(true); @@ -763,8 +917,7 @@ impl WebDavAdapter { let mut in_remove = false; let mut in_prop = false; let mut current_prop: Option = None; - let mut props_to_set = Vec::new(); - let mut props_to_remove = Vec::new(); + let mut ops: Vec = Vec::new(); let mut current_text = String::new(); let mut ns_map = std::collections::HashMap::::new(); @@ -796,7 +949,30 @@ impl WebDavAdapter { } } Ok(Event::Text(e)) if current_prop.is_some() => { - current_text.push_str(&e.decode().unwrap_or_default()); + let raw = e.decode().unwrap_or_default(); + let unescaped = + quick_xml::escape::unescape(&raw).unwrap_or_else(|_| raw.clone()); + current_text.push_str(&unescaped); + } + Ok(Event::GeneralRef(ref e)) if current_prop.is_some() => { + // quick-xml 0.39 emits GeneralRef for character references like 𐀀 + // and named entity references like &. Resolve them to actual chars. + match e.resolve_char_ref() { + Ok(Some(ch)) => current_text.push(ch), + Ok(None) => { + if let Ok(name) = e.decode() { + match name.as_ref() { + "amp" => current_text.push('&'), + "lt" => current_text.push('<'), + "gt" => current_text.push('>'), + "apos" => current_text.push('\''), + "quot" => current_text.push('"'), + _ => {} + } + } + } + Err(_) => {} + } } Ok(Event::End(ref e)) => { let name = e.name(); @@ -810,19 +986,18 @@ impl WebDavAdapter { s if s == "remove" || s.ends_with(":remove") => in_remove = false, s if s == "prop" || s.ends_with(":prop") => in_prop = false, _ if in_prop => { - // End of property element if let Some(prop_name) = current_prop.take() { if in_set { - props_to_set.push(PropValue { + ops.push(PropPatchOp::Set(PropValue { name: prop_name, value: if current_text.is_empty() { None } else { Some(current_text.clone()) }, - }); + })); } else if in_remove { - props_to_remove.push(prop_name); + ops.push(PropPatchOp::Remove(prop_name)); } } current_text.clear(); @@ -839,12 +1014,12 @@ impl WebDavAdapter { let qname = Self::resolve_name(name_str, &ns_map); if in_set { - props_to_set.push(PropValue { + ops.push(PropPatchOp::Set(PropValue { name: qname, value: None, - }); + })); } else if in_remove { - props_to_remove.push(qname); + ops.push(PropPatchOp::Remove(qname)); } } } @@ -856,7 +1031,7 @@ impl WebDavAdapter { buffer.clear(); } - Ok((props_to_set, props_to_remove)) + Ok(ops) } /// Generate a PROPPATCH response @@ -901,12 +1076,7 @@ impl WebDavAdapter { // Write property names for prop in success_props { - let prop_name = if prop.namespace == "DAV:" { - format!("D:{}", prop.name) - } else { - format!("{}:{}", prop.namespace, prop.name) - }; - xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + Self::write_qname_empty(&mut xml_writer, prop)?; } // End prop @@ -930,12 +1100,7 @@ impl WebDavAdapter { // Write property names for prop in failed_props { - let prop_name = if prop.namespace == "DAV:" { - format!("D:{}", prop.name) - } else { - format!("{}:{}", prop.namespace, prop.name) - }; - xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + Self::write_qname_empty(&mut xml_writer, prop)?; } // End prop @@ -1182,7 +1347,7 @@ impl WebDavAdapter { Self::write_folder_response(writer, folder, request, href) } - /// Writes a single `` element for a file. + /// Writes a single `` element for a file, including dead properties. pub fn write_file_entry( writer: &mut Writer, file: &FileDto, @@ -1191,4 +1356,26 @@ impl WebDavAdapter { ) -> Result<()> { Self::write_file_response(writer, file, request, href) } + + /// Writes a folder entry including dead (custom) properties. + pub fn write_folder_entry_with_dead_props( + writer: &mut Writer, + folder: &FolderDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], + ) -> Result<()> { + Self::write_folder_response_with_dead_props(writer, folder, request, href, dead_props) + } + + /// Writes a file entry including dead (custom) properties. + pub fn write_file_entry_with_dead_props( + writer: &mut Writer, + file: &FileDto, + request: &PropFindRequest, + href: &str, + dead_props: &[(QualifiedName, Option)], + ) -> Result<()> { + Self::write_file_response_with_dead_props(writer, file, request, href, dead_props) + } } diff --git a/src/common/di.rs b/src/common/di.rs index 3a116008..5600aa41 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1542,6 +1542,8 @@ impl AppServiceFactory { path_resolver: None, webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), + webdav_dead_props: + crate::infrastructure::services::webdav_dead_property_store::create_dead_property_store(pool.clone()), authorization: authorization.clone(), drive_repo: drive_repo.clone(), drive_management_service: Arc::new( @@ -2010,6 +2012,8 @@ pub struct AppState { Option>, pub webdav_lock_store: Arc, + pub webdav_dead_props: + Arc, /// ReBAC authorization engine — all service-layer permission checks go /// through this. Concrete type today is `PgAclEngine`; the /// `AuthorizationEngine` trait describes the contract. When alternate diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 77ea5115..9e65f351 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -42,6 +42,7 @@ pub mod thumbnail_service; mod thumbnail_service_test; pub mod trash_cleanup_service; pub mod tree_etag_flush_service; +pub mod webdav_dead_property_store; pub mod webdav_lock_service; pub mod wopi_discovery_service; pub mod zip_service; diff --git a/src/infrastructure/services/webdav_dead_property_store.rs b/src/infrastructure/services/webdav_dead_property_store.rs new file mode 100644 index 00000000..1e123d0c --- /dev/null +++ b/src/infrastructure/services/webdav_dead_property_store.rs @@ -0,0 +1,184 @@ +//! PostgreSQL-backed dead property store for WebDAV PROPPATCH / PROPFIND compliance. +//! +//! RFC 4918 §4.2 defines "dead properties" as those stored verbatim by the +//! server without interpreting their value. Properties are persisted to +//! `storage.webdav_dead_properties` and survive server restarts. + +use std::sync::Arc; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::application::adapters::webdav_adapter::QualifiedName; +use crate::domain::errors::DomainError; + +pub struct DeadPropertyStore { + pool: Arc, +} + +impl DeadPropertyStore { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + /// Upsert a dead property. `value = None` means an empty XML element. + pub async fn set( + &self, + path: &str, + user_id: Uuid, + name: QualifiedName, + value: Option, + ) -> Result<(), DomainError> { + sqlx::query!( + r#" + INSERT INTO storage.webdav_dead_properties + (resource_path, user_id, namespace, local_name, value) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (resource_path, user_id, namespace, local_name) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP + "#, + path, + user_id, + name.namespace, + name.name, + value, + ) + .execute(&*self.pool) + .await + .map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("set: {e}")))?; + Ok(()) + } + + /// Delete a specific dead property. No-op if not present. + pub async fn remove( + &self, + path: &str, + user_id: Uuid, + name: &QualifiedName, + ) -> Result<(), DomainError> { + sqlx::query!( + "DELETE FROM storage.webdav_dead_properties + WHERE resource_path = $1 AND user_id = $2 + AND namespace = $3 AND local_name = $4", + path, + user_id, + name.namespace, + name.name, + ) + .execute(&*self.pool) + .await + .map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("remove: {e}")))?; + Ok(()) + } + + /// Return all dead properties for `path`. + pub async fn get_all( + &self, + path: &str, + user_id: Uuid, + ) -> Result)>, DomainError> { + let rows = sqlx::query!( + "SELECT namespace, local_name, value + FROM storage.webdav_dead_properties + WHERE resource_path = $1 AND user_id = $2", + path, + user_id, + ) + .fetch_all(&*self.pool) + .await + .map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("get_all: {e}")))?; + + Ok(rows + .into_iter() + .map(|r| (QualifiedName::new(r.namespace, r.local_name), r.value)) + .collect()) + } + + /// Return a specific dead property, or `None` if not stored. + /// Returns `Some(None)` when the property exists with an empty value. + pub async fn get( + &self, + path: &str, + user_id: Uuid, + name: &QualifiedName, + ) -> Result>, DomainError> { + let row = sqlx::query!( + "SELECT value FROM storage.webdav_dead_properties + WHERE resource_path = $1 AND user_id = $2 + AND namespace = $3 AND local_name = $4", + path, + user_id, + name.namespace, + name.name, + ) + .fetch_optional(&*self.pool) + .await + .map_err(|e| DomainError::internal_error("DeadPropertyStore", format!("get: {e}")))?; + + Ok(row.map(|r| r.value)) + } + + /// Delete all dead properties for `path` (called on DELETE). + pub async fn remove_resource(&self, path: &str, user_id: Uuid) -> Result<(), DomainError> { + sqlx::query!( + "DELETE FROM storage.webdav_dead_properties + WHERE resource_path = $1 AND user_id = $2", + path, + user_id, + ) + .execute(&*self.pool) + .await + .map_err(|e| { + DomainError::internal_error("DeadPropertyStore", format!("remove_resource: {e}")) + })?; + Ok(()) + } + + /// Move dead properties from `old_path` to `new_path` (called on MOVE). + /// Clears any stale properties at `new_path` first. + pub async fn rename_resource( + &self, + old_path: &str, + user_id: Uuid, + new_path: &str, + ) -> Result<(), DomainError> { + let mut tx = self.pool.begin().await.map_err(|e| { + DomainError::internal_error("DeadPropertyStore", format!("rename_resource tx: {e}")) + })?; + + sqlx::query!( + "DELETE FROM storage.webdav_dead_properties + WHERE resource_path = $1 AND user_id = $2", + new_path, + user_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + DomainError::internal_error("DeadPropertyStore", format!("rename_resource delete: {e}")) + })?; + + sqlx::query!( + "UPDATE storage.webdav_dead_properties + SET resource_path = $2 + WHERE resource_path = $1 AND user_id = $3", + old_path, + new_path, + user_id, + ) + .execute(&mut *tx) + .await + .map_err(|e| { + DomainError::internal_error("DeadPropertyStore", format!("rename_resource update: {e}")) + })?; + + tx.commit().await.map_err(|e| { + DomainError::internal_error("DeadPropertyStore", format!("rename_resource commit: {e}")) + })?; + Ok(()) + } +} + +pub fn create_dead_property_store(pool: Arc) -> Arc { + Arc::new(DeadPropertyStore::new(pool)) +} diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 898d9a03..0357c39e 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -17,7 +17,9 @@ use chrono::Utc; use quick_xml::Writer; use uuid::Uuid; -use crate::application::adapters::webdav_adapter::{LockInfo, PropFindRequest, WebDavAdapter}; +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; @@ -391,6 +393,12 @@ async fn handle_propfind( // ── 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(); @@ -413,10 +421,11 @@ async fn handle_propfind( let folder_service = state.applications.folder_service.clone(); let file_retrieval_service = state.applications.file_retrieval_service.clone(); - let base_href = if path.is_empty() || path == "/" { + // 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(&path)) + format!("/webdav/{}/", encode_uri_path(&client_path)) }; // ── 5. Determine target resource ───────────────────────────── @@ -452,6 +461,8 @@ async fn handle_propfind( folder_service, file_retrieval_service, user.id, + state.webdav_dead_props.clone(), + path.clone(), ) .await; } @@ -470,20 +481,29 @@ async fn handle_propfind( 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( + WebDavAdapter::write_file_entry_with_dead_props( &mut xml_writer, &file, &propfind_request, - &base_href, + &file_href, + &dead_props, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; WebDavAdapter::write_multistatus_end(&mut xml_writer) @@ -514,6 +534,8 @@ async fn handle_propfind( folder_service, file_retrieval_service, user.id, + state.webdav_dead_props.clone(), + path.clone(), ) .await; } @@ -522,16 +544,23 @@ async fn handle_propfind( .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( + WebDavAdapter::write_file_entry_with_dead_props( &mut xml_writer, &file, &propfind_request, - &base_href, + &file_href, + &dead_props, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; WebDavAdapter::write_multistatus_end(&mut xml_writer) @@ -564,6 +593,10 @@ async fn build_streaming_propfind_response( 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(); @@ -574,9 +607,11 @@ async fn build_streaming_propfind_response( 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(&mut w, &folder, &propfind_request, &base_href) + 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); @@ -610,7 +645,10 @@ async fn build_streaming_propfind_response( let mut w = Writer::new(&mut chunk); for subfolder in &result.items { let href = format!("{}{}/", base_href, encode_path_segment(&subfolder.name)); - WebDavAdapter::write_folder_entry(&mut w, subfolder, &propfind_request, &href) + 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()))?; } } @@ -641,7 +679,10 @@ async fn build_streaming_propfind_response( let mut w = Writer::new(&mut chunk); for file in &batch { let href = format!("{}{}", base_href, encode_path_segment(&file.name)); - WebDavAdapter::write_file_entry(&mut w, file, &propfind_request, &href) + 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()))?; } } @@ -693,6 +734,8 @@ async fn handle_proppatch( 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 @@ -739,30 +782,37 @@ async fn handle_proppatch( .map_err(|e| { AppError::payload_too_large(format!("PROPPATCH body too large or unreadable: {}", e)) })?; - let (props_to_set, props_to_remove) = WebDavAdapter::parse_proppatch(body_bytes.reader()) + let ops = WebDavAdapter::parse_proppatch(body_bytes.reader()) .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?; - // For now, we don't actually persist custom properties, but we respond as if we did - // In a full implementation, we would store these properties in a database - - // Generate response - we'll pretend all operations succeeded - let mut results = Vec::new(); - - // For each property to set, indicate success - for prop in &props_to_set { - results.push((&prop.name, true)); + // 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)); + } + } } - // For each property to remove, indicate success - for prop in &props_to_remove { - results.push((prop, true)); - } - - // Generate response — collection vs file href chosen above. + // Generate response — use client-facing path so href matches the request URL. let href = if is_collection { - webdav_collection_href(&path) + webdav_collection_href(&client_path) } else { - webdav_href(&path) + webdav_href(&client_path) }; let mut response_body = Vec::new(); WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( @@ -1073,10 +1123,10 @@ fn enforce_native_lock( if p.is_empty() { return None; } - if let Some(e) = lock_store.get_by_path(p) { - if e.info.depth.eq_ignore_ascii_case("infinity") { - return Some(e); - } + if let Some(e) = lock_store.get_by_path(p) + && e.info.depth.eq_ignore_ascii_case("infinity") + { + return Some(e); } } }); @@ -1216,10 +1266,7 @@ async fn handle_put( .resolve_path_for_user(parent_path, user.id) .await .map_err(|_| { - AppError::conflict(format!( - "Parent folder not found: {}", - parent_path - )) + AppError::conflict(format!("Parent folder not found: {}", parent_path)) })?; } } @@ -1410,7 +1457,11 @@ async fn handle_mkcol( "AlreadyExists", )); } - } else if folder_service.get_folder_by_path(&path, drive_id).await.is_ok() { + } 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", @@ -1446,7 +1497,10 @@ async fn handle_mkcol( } } } else { - match folder_service.get_folder_by_path(&parent_path, drive_id).await { + match folder_service + .get_folder_by_path(&parent_path, drive_id) + .await + { Ok(f) => Some(f.id), Err(_) => { return Err(AppError::conflict(format!( @@ -1789,6 +1843,13 @@ async fn handle_move( } } + // 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