diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 9cd063b1..79071376 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -419,7 +419,12 @@ impl WebDavAdapter { /// /// Written AFTER the live-property propstats inside a ``. /// Only emitted when `dead_props` is non-empty. - fn write_dead_props_propstat( + /// + /// `pub(crate)` so the NextCloud-compatible handler + /// (`interfaces::nextcloud::webdav_handler`) can append the same + /// dead-property block to its own bespoke PROPFIND writers instead + /// of duplicating this XML shape. + pub(crate) fn write_dead_props_propstat( xml_writer: &mut Writer, dead_props: &[(QualifiedName, Option)], ) -> Result<()> { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index b612ffe0..62e82929 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -1092,7 +1092,11 @@ async fn resolve_or_legacy( /// the dead-prop lookup is broken; surfacing a 500 here would mask the /// resource entirely from sync clients. The legacy path-keyed lookup /// behaved the same way (`.unwrap_or_default()`); we preserve it. -async fn file_dead_props( +/// +/// `pub(crate)` — also reused by the NextCloud-compatible PROPFIND +/// handler (`interfaces::nextcloud::webdav_handler`), which needs the +/// same lenient fetch for its own response writers. +pub(crate) async fn file_dead_props( state: &Arc, file: &FileDto, ) -> Vec<(QualifiedName, Option)> { @@ -1107,8 +1111,9 @@ async fn file_dead_props( } /// Same shape as `file_dead_props` but for folder rows. Used by the -/// streaming PROPFIND walker. -async fn folder_dead_props( +/// streaming PROPFIND walker (and, via `pub(crate)`, by the NextCloud +/// handler's own streaming walker). +pub(crate) async fn folder_dead_props( store: &DeadPropertyStore, folder: &FolderDto, ) -> Vec<(QualifiedName, Option)> { @@ -1124,7 +1129,7 @@ async fn folder_dead_props( /// File-leaf variant for the streaming walker (takes a `&DeadPropertyStore` /// rather than the full `&Arc` so it can be called from inside /// the async-stream future without cloning state). -async fn streamed_file_dead_props( +pub(crate) async fn streamed_file_dead_props( store: &DeadPropertyStore, file: &FileDto, ) -> Vec<(QualifiedName, Option)> { diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index a380d6c9..39f22d1d 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -21,6 +21,7 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::domain::entities::file::File; +use crate::interfaces::api::handlers::webdav_handler::{file_dead_props, folder_dead_props}; use crate::interfaces::errors::AppError; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response, @@ -159,14 +160,15 @@ async fn handle_filter_files( let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = file_dead_props(&state, file).await; write_file_response( &mut xml, file, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -176,14 +178,15 @@ async fn handle_filter_files( let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = folder_dead_props(&state.webdav_dead_props, folder).await; write_folder_response( &mut xml, folder, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -270,14 +273,15 @@ async fn handle_search( let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = file_dead_props(&state, file).await; write_file_response( &mut xml, file, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -288,14 +292,15 @@ async fn handle_search( let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = folder_dead_props(&state.webdav_dead_props, folder).await; write_folder_response( &mut xml, folder, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 410f0ed1..964f26a0 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -13,7 +13,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use uuid::Uuid; -use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter}; +use crate::application::adapters::webdav_adapter::{ + PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, +}; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{ @@ -23,7 +25,10 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; -use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE; +use crate::infrastructure::services::webdav_dead_property_store::ResourceRef; +use crate::interfaces::api::handlers::webdav_handler::{ + PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props, +}; use crate::interfaces::errors::AppError; use crate::interfaces::range_requests::{not_modified_response, range_response}; use crate::interfaces::upload_ingest::ingest_body_to_cas; @@ -277,6 +282,7 @@ async fn handle_propfind( let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); + let dead_props = file_dead_props(&state, &file).await; let mut buf = Vec::new(); write_nc_file_multistatus( @@ -286,7 +292,7 @@ async fn handle_propfind( &user.username, subpath, file_id_svc, - &favorite_ids, + (&favorite_ids, &dead_props), ) .await .map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?; @@ -455,6 +461,12 @@ async fn handle_head( // ──────────────────── PROPPATCH ──────────────────── +/// The `oc:favorite` element is live server state routed through the +/// favorites service, not a dead property — every other +/// namespace/local-name pair PROPPATCH sends is stored verbatim via +/// `DeadPropertyStore`. +const OC_FAVORITE_NS: &str = "http://owncloud.org/ns"; + async fn handle_proppatch( state: Arc, req: Request, @@ -468,150 +480,120 @@ async fn handle_proppatch( .await .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; - let body_str = String::from_utf8_lossy(&body_bytes); - - // Resolve the target resource once — needed for two things: - // 1. Applying the oc:favorite mutation when the PROPPATCH body - // carries one (`item_type` distinguishes file vs folder rows - // in the favorites table). - // 2. Picking the right `` shape in the multi-status + // Resolve the target resource — needed for three things: + // 1. The dead-property store key is the resource id (folder_id + // XOR file_id), so we need a `ResourceRef`. + // 2. Applying the oc:favorite mutation (`item_type` distinguishes + // file vs folder rows in the favorites table). + // 3. Picking the right `` shape in the multi-status // response: collection (folder) hrefs MUST end in `/` per // RFC 4918 §5.2 — see `nc_collection_href` for the full - // reasoning. Without this distinction the NC desktop client - // parser aborted on PROPFIND; PROPPATCH would hit the same - // wall the moment the user favourited a folder. + // reasoning. // - // When the resource is missing we tolerate it for the no-op - // PROPPATCH path (no favorite directive in the body) — matches - // the prior behaviour. A PROPPATCH that *does* try to set - // favorite on a missing resource still returns NotFound. + // A missing resource is now always a 404: unlike the previous + // favorite-only implementation (which merely re-declared success + // without doing anything), this handler performs real writes, so + // silently no-opping on a nonexistent path would be a foot-gun — + // matches the native `/webdav/` handler's contract. let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; - let resource = if let Ok(file) = file_service + let (resource_ref, item_id, item_type, is_collection) = if let Ok(file) = file_service .get_file_by_path(&internal_path, chroot.drive_id) .await { - Some((file.id, "file")) + let id = Uuid::parse_str(&file.id) + .map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?; + (ResourceRef::File(id), file.id, "file", false) } else if let Ok(folder) = folder_service .get_folder_by_path(&internal_path, chroot.drive_id) .await { - Some((folder.id, "folder")) + let id = Uuid::parse_str(&folder.id) + .map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?; + (ResourceRef::Folder(id), folder.id, "folder", true) } else { - None + return Err(AppError::not_found("Resource not found")); }; - let is_collection = matches!(resource, Some((_, "folder"))); - // Parse oc:favorite value from PROPPATCH XML. - let favorite_value = parse_proppatch_favorite(&body_str); + let ops = WebDavAdapter::parse_proppatch(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?; - if let Some(value) = favorite_value { - let Some((item_id, item_type)) = resource else { - return Err(AppError::not_found("Resource not found")); - }; - - if let Some(fav_svc) = state.favorites_service.as_ref() { - if value == 1 { - fav_svc - .add_to_favorites(user.id, &item_id, item_type) + let dead_props = &state.webdav_dead_props; + let mut results: Vec<(&QualifiedName, bool)> = Vec::new(); + for op in &ops { + let is_favorite = + |name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite"; + match op { + PropPatchOp::Set(pv) if is_favorite(&pv.name) => { + if let Some(fav_svc) = state.favorites_service.as_ref() { + if pv.value.as_deref().map(str::trim) == Some("1") { + fav_svc + .add_to_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to add favorite: {e}")) + })?; + } else { + fav_svc + .remove_from_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to remove favorite: {e}")) + })?; + } + } + results.push((&pv.name, true)); + } + PropPatchOp::Remove(name) if is_favorite(name) => { + if let Some(fav_svc) = state.favorites_service.as_ref() { + fav_svc + .remove_from_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to remove favorite: {e}")) + })?; + } + results.push((name, true)); + } + PropPatchOp::Set(pv) => { + dead_props + .set(resource_ref, pv.name.clone(), pv.value.clone()) .await .map_err(|e| { - AppError::internal_error(format!("Failed to add favorite: {}", e)) - })?; - } else { - fav_svc - .remove_from_favorites(user.id, &item_id, item_type) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to remove favorite: {}", e)) + AppError::internal_error(format!("Failed to store dead property: {e}")) })?; + results.push((&pv.name, true)); + } + PropPatchOp::Remove(name) => { + dead_props.remove(resource_ref, name).await.map_err(|e| { + AppError::internal_error(format!("Failed to remove dead property: {e}")) + })?; + results.push((name, true)); } } } - // Return 207 Multi-Status with success response using quick_xml - // for safe escaping. Collection vs file href chosen by resource - // type to satisfy the RFC 4918 §5.2 trailing-slash invariant — - // see the comment block at the top of this function. + // Collection vs file href chosen by resource type to satisfy the + // RFC 4918 §5.2 trailing-slash invariant — see the comment block + // at the top of this function. let href = if is_collection { nc_collection_href(url_user, subpath) } else { nc_href(url_user, subpath) }; - let mut buf = Vec::new(); - { - let mut xml = Writer::new(&mut buf); - xml.write_event(Event::Text(BytesText::new( - "", - ))) - .map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?; - - let mut ms = BytesStart::new("d:multistatus"); - ms.push_attribute(("xmlns:d", "DAV:")); - ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns")); - xml.write_event(Event::Start(ms)) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - - xml.write_event(Event::Start(BytesStart::new("d:response"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - write_text_element(&mut xml, "d:href", &href) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Start(BytesStart::new("d:propstat"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Start(BytesStart::new("d:prop"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Empty(BytesStart::new("oc:favorite"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:prop"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK") - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:propstat"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:response"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - } + 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(buf)) + .body(Body::from(response_body)) .unwrap()) } -/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml. -fn parse_proppatch_favorite(body: &str) -> Option { - use quick_xml::Reader; - - let mut reader = Reader::from_str(body); - let mut inside_favorite = false; - - loop { - match reader.read_event() { - Ok(Event::Start(ref e)) => { - let local = e.local_name(); - if local.as_ref() == b"favorite" { - inside_favorite = true; - } - } - Ok(Event::Text(ref e)) if inside_favorite => { - let text = e.decode().ok()?; - return text.trim().parse::().ok(); - } - Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => { - inside_favorite = false; - } - Ok(Event::Eof) => break, - Err(_) => break, - _ => {} - } - } - None -} - // ──────────────────── PUT ──────────────────── /// Strip the optional `W/` weak prefix and surrounding double-quotes @@ -1225,6 +1207,10 @@ fn write_nc_multistatus_open(xml: &mut Writer) -> Result<( /// Generate the multistatus XML for a single-file PROPFIND. The folder /// case streams via [`build_nc_streaming_propfind`] instead. +/// +/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource +/// decorations fetched by the caller — to stay under clippy's +/// argument-count lint. async fn write_nc_file_multistatus( writer: W, file: &FileDto, @@ -1232,8 +1218,9 @@ async fn write_nc_file_multistatus( username: &str, subpath: &str, file_id_svc: Option<&Arc>, - favorite_ids: &HashSet, + extras: (&HashSet, &[(QualifiedName, Option)]), ) -> Result<(), String> { + let (favorite_ids, dead_props) = extras; let (file_id_map, _) = batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await; @@ -1252,10 +1239,10 @@ async fn write_nc_file_multistatus( &mut xml, file, &href, - file_id, - oc_id.as_deref(), + (file_id, oc_id.as_deref()), username, favorite_ids, + dead_props, )?; xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) @@ -1298,6 +1285,7 @@ fn build_nc_streaming_propfind( }; let (_, folder_id_map) = batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await; + let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await; let mut buf = Vec::with_capacity(4096); { @@ -1306,7 +1294,7 @@ fn build_nc_streaming_propfind( let href = nc_collection_href(&username, &subpath); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs) + write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, &folder_dead) .map_err(std::io::Error::other)?; } yield Bytes::from(buf); @@ -1335,11 +1323,15 @@ fn build_nc_streaming_propfind( }; let file_uuids: Vec = batch.iter().map(|f| f.id.clone()).collect(); let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await; + let mut file_deads = Vec::with_capacity(batch_len); + for file in &batch { + file_deads.push(streamed_file_dead_props(&state.webdav_dead_props, file).await); + } let mut chunk = Vec::with_capacity(batch_len * 1024); { let mut xml = Writer::new(&mut chunk); - for file in &batch { + for (file, dead) in batch.iter().zip(file_deads.iter()) { let child_sub = if subpath.is_empty() { file.name.clone() } else { @@ -1348,7 +1340,7 @@ fn build_nc_streaming_propfind( let href = nc_href(&username, &child_sub); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_file_response(&mut xml, file, &href, fid, oc_id.as_deref(), &username, &favs) + write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead) .map_err(std::io::Error::other)?; } } @@ -1384,11 +1376,15 @@ fn build_nc_streaming_propfind( }; let folder_uuids: Vec = result.items.iter().map(|sf| sf.id.clone()).collect(); let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await; + let mut sub_deads = Vec::with_capacity(result.items.len()); + for sf in &result.items { + sub_deads.push(folder_dead_props(&state.webdav_dead_props, sf).await); + } let mut chunk = Vec::with_capacity(result.items.len() * 1024); { let mut xml = Writer::new(&mut chunk); - for sf in &result.items { + for (sf, dead) in result.items.iter().zip(sub_deads.iter()) { let child_sub = if subpath.is_empty() { sf.name.clone() } else { @@ -1397,7 +1393,7 @@ fn build_nc_streaming_propfind( let href = nc_collection_href(&username, &child_sub); let fid = sub_id_map.get(&sf.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs) + write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, dead) .map_err(std::io::Error::other)?; } } @@ -1432,15 +1428,20 @@ fn build_nc_streaming_propfind( .unwrap() } +/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed +/// together (`oc_id` is derived from `file_id`) — to stay under +/// clippy's argument-count lint now that `dead_props` is also threaded +/// through. pub fn write_folder_response( xml: &mut Writer, folder: &FolderDto, href: &str, - file_id: Option, - oc_id: Option<&str>, + oc_ids: (Option, Option<&str>), owner: &str, favorite_ids: &HashSet, + dead_props: &[(QualifiedName, Option)], ) -> Result<(), String> { + let (file_id, oc_id) = oc_ids; xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1511,21 +1512,26 @@ pub fn write_folder_response( xml.write_event(Event::End(BytesEnd::new("d:propstat"))) .xml_err()?; + WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?; + xml.write_event(Event::End(BytesEnd::new("d:response"))) .xml_err()?; Ok(()) } +/// See `write_folder_response` for why `(file_id, oc_id)` are bundled +/// into `oc_ids`. pub fn write_file_response( xml: &mut Writer, file: &FileDto, href: &str, - file_id: Option, - oc_id: Option<&str>, + oc_ids: (Option, Option<&str>), owner: &str, favorite_ids: &HashSet, + dead_props: &[(QualifiedName, Option)], ) -> Result<(), String> { + let (file_id, oc_id) = oc_ids; xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1600,6 +1606,8 @@ pub fn write_file_response( xml.write_event(Event::End(BytesEnd::new("d:propstat"))) .xml_err()?; + WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?; + xml.write_event(Event::End(BytesEnd::new("d:response"))) .xml_err()?;