diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index 0c8d03a5..ef740fd9 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -57,10 +57,12 @@ impl CalDavAdapter { let mut props = Vec::new(); let mut hrefs = Vec::new(); let mut sync_token = String::new(); + let mut ns_map = std::collections::HashMap::::new(); loop { match xml_reader.read_event_into(&mut buffer) { Ok(Event::Start(ref e)) => { + WebDavAdapter::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -102,11 +104,8 @@ impl CalDavAdapter { // We'll capture the text in the Text event } _ if in_prop => { - // Add property to request - let namespace = WebDavAdapter::extract_namespace(name_str); - let prop_name = WebDavAdapter::extract_local_name(name_str); - - props.push(QualifiedName::new(namespace, prop_name)); + let qname = WebDavAdapter::resolve_name(name_str, &ns_map); + props.push(qname); } _ => { /* Ignore other elements */ } } @@ -138,15 +137,13 @@ impl CalDavAdapter { } } Ok(Event::Empty(ref e)) => { + WebDavAdapter::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); if in_prop { - // Add empty property element to request - let namespace = WebDavAdapter::extract_namespace(name_str); - let prop_name = WebDavAdapter::extract_local_name(name_str); - - props.push(QualifiedName::new(namespace, prop_name)); + let qname = WebDavAdapter::resolve_name(name_str, &ns_map); + props.push(qname); } else if name_str == "time-range" || name_str.ends_with(":time-range") { // Parse time-range attributes for attr in e.attributes().flatten() { @@ -199,7 +196,47 @@ impl CalDavAdapter { Ok(report_type) } - /// Generate a PROPFIND response for calendars + /// Generate a PROPFIND response for the root CalDAV resource. + /// Includes a response for /caldav/ itself with discovery properties + /// (current-user-principal, calendar-home-set) plus each calendar. + pub fn generate_root_propfind_response( + writer: W, + calendars: &[CalendarDto], + request: &PropFindRequest, + base_href: &str, + username: &str, + ) -> Result<()> { + let mut xml_writer = Writer::new(writer); + + // Start multistatus response + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:C", "urn:ietf:params:xml:ns:caldav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ]), + ))?; + + // Write the root /caldav/ response with discovery properties + Self::write_root_response(&mut xml_writer, request, base_href, username)?; + + // Add responses for calendars + for calendar in calendars { + Self::write_calendar_response( + &mut xml_writer, + calendar, + request, + &format!("{}{}/", base_href, calendar.id), + )?; + } + + // End multistatus + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + + Ok(()) + } + + /// Generate a PROPFIND response for calendars (without root discovery entry) pub fn generate_calendars_propfind_response( writer: W, calendars: &[CalendarDto], @@ -233,6 +270,301 @@ impl CalDavAdapter { Ok(()) } + /// Generate a PROPFIND response for a user principal resource. + pub fn generate_principal_propfind_response( + writer: W, + request: &PropFindRequest, + username: &str, + ) -> Result<()> { + let mut xml_writer = Writer::new(writer); + + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:C", "urn:ietf:params:xml:ns:caldav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ]), + ))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + // href + let href = format!("/caldav/principals/{}/", username); + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + match &request.prop_find_type { + PropFindType::AllProp | PropFindType::PropName => { + Self::write_principal_props(&mut xml_writer, username)?; + } + PropFindType::Prop(props) => { + Self::write_principal_requested_props(&mut xml_writer, username, props)?; + } + } + + 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")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + + Ok(()) + } + + /// Write the root /caldav/ response entry with discovery properties. + fn write_root_response( + xml_writer: &mut Writer, + request: &PropFindRequest, + href: &str, + username: &str, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + match &request.prop_find_type { + PropFindType::AllProp => { + // Resource type — collection + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + // current-user-principal + xml_writer + .write_event(Event::Start(BytesStart::new("D:current-user-principal")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/principals/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; + + // calendar-home-set + xml_writer + .write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + } + PropFindType::PropName => { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + xml_writer + .write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?; + xml_writer + .write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?; + } + PropFindType::Prop(props) => { + Self::write_root_requested_props(xml_writer, username, props)?; + } + } + + 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")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + + Ok(()) + } + + /// Write requested properties for the root /caldav/ resource. + fn write_root_requested_props( + xml_writer: &mut Writer, + username: &str, + props: &[QualifiedName], + ) -> Result<()> { + for prop in props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + } + ("DAV:", "current-user-principal") => { + xml_writer.write_event(Event::Start(BytesStart::new( + "D:current-user-principal", + )))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/principals/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; + } + ("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => { + xml_writer + .write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + } + ("DAV:", "displayname") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new("CalDAV Root")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + } + _ => { + // Unknown property — write empty + let prop_name = if prop.namespace == "http://calendarserver.org/ns/" { + format!("CS:{}", prop.name) + } else if prop.namespace == "urn:ietf:params:xml:ns:caldav" { + format!("C:{}", prop.name) + } else if prop.namespace == "DAV:" { + format!("D:{}", prop.name) + } else { + format!("{}:{}", prop.namespace, prop.name) + }; + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + } + } + } + Ok(()) + } + + /// Write standard properties for a principal resource. + fn write_principal_props( + xml_writer: &mut Writer, + username: &str, + ) -> Result<()> { + // resourcetype — principal + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + // displayname + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(username)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + // calendar-home-set + xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + + // current-user-principal (self-reference) + xml_writer.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/principals/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; + + Ok(()) + } + + /// Write requested properties for a principal resource. + fn write_principal_requested_props( + xml_writer: &mut Writer, + username: &str, + props: &[QualifiedName], + ) -> Result<()> { + for prop in props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + } + ("DAV:", "displayname") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(username)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + } + ("DAV:", "current-user-principal") => { + xml_writer.write_event(Event::Start(BytesStart::new( + "D:current-user-principal", + )))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/principals/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; + } + ("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => { + xml_writer + .write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer + .write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?; + } + ("urn:ietf:params:xml:ns:caldav", "calendar-user-address-set") => { + xml_writer.write_event(Event::Start(BytesStart::new( + "C:calendar-user-address-set", + )))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/caldav/principals/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer.write_event(Event::End(BytesEnd::new( + "C:calendar-user-address-set", + )))?; + } + _ => { + let prop_name = if prop.namespace == "http://calendarserver.org/ns/" { + format!("CS:{}", prop.name) + } else if prop.namespace == "urn:ietf:params:xml:ns:caldav" { + format!("C:{}", prop.name) + } else if prop.namespace == "DAV:" { + format!("D:{}", prop.name) + } else { + format!("{}:{}", prop.namespace, prop.name) + }; + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + } + } + } + Ok(()) + } + /// Write calendar properties as a response fn write_calendar_response( xml_writer: &mut Writer, diff --git a/src/application/adapters/caldav_adapter_test.rs b/src/application/adapters/caldav_adapter_test.rs index 59e1c62d..50ab323d 100644 --- a/src/application/adapters/caldav_adapter_test.rs +++ b/src/application/adapters/caldav_adapter_test.rs @@ -336,4 +336,284 @@ mod tests { "Should have multistatus even for empty" ); } + + // ============================================== + // Namespace resolution tests (issue #153 fixes) + // ============================================== + + #[test] + fn test_propfind_namespace_resolution_dav_and_caldav() { + use crate::application::adapters::webdav_adapter::WebDavAdapter; + + // Simulates a real CalDAV client PROPFIND request with namespace prefixes + let xml = r#" + + + + + + +"#; + + let result = WebDavAdapter::parse_propfind(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse PROPFIND: {:?}", result.err()); + + let request = result.unwrap(); + match request.prop_find_type { + PropFindType::Prop(ref props) => { + assert_eq!(props.len(), 3); + + // Verify namespace URIs are resolved, not prefix names + assert_eq!(props[0].namespace, "DAV:"); + assert_eq!(props[0].name, "current-user-principal"); + + assert_eq!(props[1].namespace, "urn:ietf:params:xml:ns:caldav"); + assert_eq!(props[1].name, "calendar-home-set"); + + assert_eq!(props[2].namespace, "DAV:"); + assert_eq!(props[2].name, "resourcetype"); + } + other => panic!("Expected Prop, got {:?}", other), + } + } + + #[test] + fn test_propfind_namespace_resolution_with_custom_prefixes() { + use crate::application::adapters::webdav_adapter::WebDavAdapter; + + // Some clients use different prefix names + let xml = r#" + + + + + +"#; + + let result = WebDavAdapter::parse_propfind(Cursor::new(xml)); + assert!(result.is_ok()); + + let request = result.unwrap(); + match request.prop_find_type { + PropFindType::Prop(ref props) => { + assert_eq!(props.len(), 2); + assert_eq!(props[0].namespace, "DAV:"); + assert_eq!(props[0].name, "current-user-principal"); + assert_eq!(props[1].namespace, "urn:ietf:params:xml:ns:caldav"); + assert_eq!(props[1].name, "calendar-home-set"); + } + other => panic!("Expected Prop, got {:?}", other), + } + } + + #[test] + fn test_root_propfind_response_has_discovery_properties() { + // Depth 0: only root entry, no calendars (simulates initial discovery) + let calendars: Vec = vec![]; + let request = PropFindRequest { + prop_find_type: PropFindType::Prop(vec![ + QualifiedName { + namespace: "DAV:".to_string(), + name: "current-user-principal".to_string(), + }, + QualifiedName { + namespace: "urn:ietf:params:xml:ns:caldav".to_string(), + name: "calendar-home-set".to_string(), + }, + ]), + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_root_propfind_response( + &mut output, + &calendars, + &request, + "/caldav/", + "testuser", + ); + assert!(result.is_ok(), "Failed to generate root propfind: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + + // Root response should contain populated current-user-principal + assert!( + xml_str.contains("/caldav/principals/testuser/"), + "Should contain principal href, got: {}", + xml_str + ); + + // Root response should contain populated calendar-home-set + assert!( + xml_str.contains("/caldav/testuser/"), + "Should contain calendar home href, got: {}", + xml_str + ); + + // Properties should NOT be empty self-closing elements + assert!( + !xml_str.contains(""), + "current-user-principal should not be empty" + ); + assert!( + !xml_str.contains(""), + "calendar-home-set should not be empty" + ); + } + + #[test] + fn test_root_propfind_response_depth1_includes_calendars() { + // Depth 1: root entry + calendars + let calendars = vec![sample_calendar()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_root_propfind_response( + &mut output, + &calendars, + &request, + "/caldav/", + "testuser", + ); + assert!(result.is_ok()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + + // Should contain root entry with discovery properties + assert!(xml_str.contains("/caldav/principals/testuser/")); + // Should contain calendar entry + assert!(xml_str.contains("cal-001")); + assert!(xml_str.contains("Personal")); + } + + #[test] + fn test_principal_propfind_response() { + let request = PropFindRequest { + prop_find_type: PropFindType::Prop(vec![ + QualifiedName { + namespace: "DAV:".to_string(), + name: "resourcetype".to_string(), + }, + QualifiedName { + namespace: "DAV:".to_string(), + name: "displayname".to_string(), + }, + QualifiedName { + namespace: "urn:ietf:params:xml:ns:caldav".to_string(), + name: "calendar-home-set".to_string(), + }, + QualifiedName { + namespace: "DAV:".to_string(), + name: "current-user-principal".to_string(), + }, + ]), + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_principal_propfind_response( + &mut output, + &request, + "testuser", + ); + assert!(result.is_ok(), "Failed: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + + // Should contain principal href + assert!(xml_str.contains("/caldav/principals/testuser/")); + // Should contain calendar-home-set + assert!(xml_str.contains("/caldav/testuser/")); + // Should contain principal in resourcetype + assert!(xml_str.contains("D:principal")); + // Should have displayname + assert!(xml_str.contains("testuser")); + } + + #[test] + fn test_calendar_propfind_with_resolved_namespaces() { + // Simulate what happens when a client asks for specific props + // After namespace resolution fix, these should have proper URIs + let calendar = sample_calendar(); + let request = PropFindRequest { + prop_find_type: PropFindType::Prop(vec![ + QualifiedName { + namespace: "DAV:".to_string(), + name: "resourcetype".to_string(), + }, + QualifiedName { + namespace: "DAV:".to_string(), + name: "displayname".to_string(), + }, + QualifiedName { + namespace: "urn:ietf:params:xml:ns:caldav".to_string(), + name: "supported-calendar-component-set".to_string(), + }, + ]), + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_calendar_collection_propfind( + &mut output, + &calendar, + &[], + &request, + "/caldav/cal-001/", + "0", + ); + assert!(result.is_ok(), "Failed: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + + // resourcetype should have collection + calendar children + assert!( + xml_str.contains("D:collection"), + "Should contain collection in resourcetype" + ); + assert!( + xml_str.contains("C:calendar"), + "Should contain calendar in resourcetype" + ); + + // displayname should be populated + assert!( + xml_str.contains("Personal"), + "Should contain calendar name" + ); + + // supported-calendar-component-set should have VEVENT + assert!( + xml_str.contains("VEVENT"), + "Should contain VEVENT component" + ); + } + + #[test] + fn test_report_namespace_resolution() { + // Verify that REPORT parsing also resolves namespaces correctly + let xml = r#" + + + + + + + + + "#; + + let result = CalDavAdapter::parse_report(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse: {:?}", result.err()); + + match result.unwrap() { + CalDavReportType::CalendarQuery { props, .. } => { + assert_eq!(props.len(), 2); + assert_eq!(props[0].namespace, "DAV:"); + assert_eq!(props[0].name, "getetag"); + assert_eq!(props[1].namespace, "urn:ietf:params:xml:ns:caldav"); + assert_eq!(props[1].name, "calendar-data"); + } + other => panic!("Expected CalendarQuery, got {:?}", other), + } + } } diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index 544999c2..2d0e16f2 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -53,10 +53,12 @@ impl CardDavAdapter { let mut sync_token = String::new(); let mut in_href = false; let mut in_sync_token = false; + let mut ns_map = std::collections::HashMap::::new(); loop { match xml_reader.read_event_into(&mut buffer) { Ok(Event::Start(ref e)) => { + WebDavAdapter::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -78,9 +80,8 @@ impl CardDavAdapter { in_sync_token = true } _ if in_prop => { - let namespace = WebDavAdapter::extract_namespace(name_str); - let prop_name = WebDavAdapter::extract_local_name(name_str); - props.push(QualifiedName::new(namespace, prop_name)); + let qname = WebDavAdapter::resolve_name(name_str, &ns_map); + props.push(qname); } _ => {} } @@ -107,11 +108,11 @@ impl CardDavAdapter { } } Ok(Event::Empty(ref e)) if in_prop => { + WebDavAdapter::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); - let namespace = WebDavAdapter::extract_namespace(name_str); - let prop_name = WebDavAdapter::extract_local_name(name_str); - props.push(QualifiedName::new(namespace, prop_name)); + let qname = WebDavAdapter::resolve_name(name_str, &ns_map); + props.push(qname); } Ok(Event::Eof) => break, Err(e) => return Err(WebDavError::XmlError(e)), diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 1e486997..3359e12e 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -124,6 +124,41 @@ pub enum LockType { pub struct WebDavAdapter; impl WebDavAdapter { + /// Collect namespace prefix → URI mappings from element attributes. + /// E.g. `xmlns:D="DAV:"` maps prefix `"D"` to `"DAV:"`. + pub fn collect_ns_decls( + e: &BytesStart, + ns_map: &mut std::collections::HashMap, + ) { + 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); + } + } + } + + /// Resolve a prefixed element name (e.g. `D:resourcetype`) to a + /// `QualifiedName` using the accumulated namespace declarations. + pub fn resolve_name( + name_str: &str, + ns_map: &std::collections::HashMap, + ) -> QualifiedName { + if let Some(idx) = name_str.find(':') { + let prefix = &name_str[..idx]; + let local = &name_str[idx + 1..]; + if let Some(uri) = ns_map.get(prefix) { + return QualifiedName::new(uri.clone(), local.to_string()); + } + } + // Fallback: no prefix or unknown prefix → use legacy extraction + QualifiedName::new( + Self::extract_namespace(name_str), + Self::extract_local_name(name_str), + ) + } + /// Parse a PROPFIND XML request pub fn parse_propfind(reader: R) -> Result { let mut xml_reader = Reader::from_reader(BufReader::new(reader)); @@ -135,10 +170,12 @@ impl WebDavAdapter { let mut in_allprop = false; let mut in_propname = false; let mut props = Vec::new(); + let mut ns_map = std::collections::HashMap::::new(); loop { match xml_reader.read_event_into(&mut buffer) { Ok(Event::Start(ref e)) => { + Self::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -155,11 +192,8 @@ impl WebDavAdapter { { in_propname = true; } else if in_prop { - // Add property to request - let namespace = Self::extract_namespace(name_str); - let prop_name = Self::extract_local_name(name_str); - - props.push(QualifiedName::new(namespace, prop_name)); + let qname = Self::resolve_name(name_str, &ns_map); + props.push(qname); } } Ok(Event::End(ref e)) => { @@ -177,6 +211,7 @@ impl WebDavAdapter { } } Ok(Event::Empty(ref e)) => { + Self::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -187,11 +222,8 @@ impl WebDavAdapter { { in_propname = true; } else if in_prop { - // Add property to request (empty element) - let namespace = Self::extract_namespace(name_str); - let prop_name = Self::extract_local_name(name_str); - - props.push(QualifiedName::new(namespace, prop_name)); + let qname = Self::resolve_name(name_str, &ns_map); + props.push(qname); } } Ok(Event::Eof) => break, @@ -635,10 +667,12 @@ impl WebDavAdapter { let mut props_to_set = Vec::new(); let mut props_to_remove = Vec::new(); let mut current_text = String::new(); + let mut ns_map = std::collections::HashMap::::new(); loop { match xml_reader.read_event_into(&mut buffer) { Ok(Event::Start(ref e)) => { + Self::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); @@ -656,11 +690,7 @@ impl WebDavAdapter { in_prop = true } _ if in_prop => { - // This is a property element - let namespace = Self::extract_namespace(name_str); - let prop_name = Self::extract_local_name(name_str); - - current_prop = Some(QualifiedName::new(namespace, prop_name)); + current_prop = Some(Self::resolve_name(name_str, &ns_map)); current_text.clear(); } _ => (), @@ -704,15 +734,12 @@ impl WebDavAdapter { } } Ok(Event::Empty(ref e)) => { + Self::collect_ns_decls(e, &mut ns_map); let name = e.name(); let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); if in_prop { - // Empty property element - let namespace = Self::extract_namespace(name_str); - let prop_name = Self::extract_local_name(name_str); - - let qname = QualifiedName::new(namespace, prop_name); + let qname = Self::resolve_name(name_str, &ns_map); if in_set { props_to_set.push(PropValue { diff --git a/src/infrastructure/services/image_transcode_service.rs b/src/infrastructure/services/image_transcode_service.rs index e781bc65..757b1ea0 100644 --- a/src/infrastructure/services/image_transcode_service.rs +++ b/src/infrastructure/services/image_transcode_service.rs @@ -498,6 +498,6 @@ mod tests { fn test_transcode_pool_initializes() { // Verify the pool can be created without panic let pool = transcode_pool(); - assert_eq!(pool.current_num_threads(), TRANSCODE_POOL_THREADS); + assert_eq!(pool.current_num_threads(), transcode_thread_count()); } } diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index 14653209..852173ab 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -48,6 +48,24 @@ pub fn caldav_routes() -> Router> { .route("/caldav", axum::routing::any(handle_caldav_methods_root)) } +/// Creates RFC 6764 well-known discovery routes. +/// These are public (no auth) and simply redirect to the CalDAV root. +pub fn well_known_routes() -> Router> { + Router::new() + .route( + "/.well-known/caldav", + axum::routing::any(handle_well_known_caldav), + ) +} + +async fn handle_well_known_caldav() -> Response { + Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .header(header::LOCATION, "/caldav/") + .body(Body::empty()) + .unwrap() +} + async fn handle_caldav_methods_root( axum::extract::State(state): axum::extract::State>, req: Request, @@ -171,19 +189,51 @@ async fn handle_propfind( }; if path.is_empty() { - // Root CalDAV path — list user's calendars - let calendars = calendar_service - .list_my_calendars(&user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?; + // Root CalDAV path — return discovery properties + list user's calendars + // At depth 0, only return root entry; at depth 1+, also include calendars + let calendars = if depth == "0" { + vec![] + } else { + calendar_service + .list_my_calendars(&user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to list calendars: {}", e)) + })? + }; let base_href = "/caldav/"; let mut response_body = Vec::new(); - CalDavAdapter::generate_calendars_propfind_response( + CalDavAdapter::generate_root_propfind_response( &mut response_body, &calendars, &propfind_request, base_href, + &user.username, + ) + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; + + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()) + } else if path.starts_with("principals/") || path == "principals" { + // Principal resource — return user principal properties + let username = path + .strip_prefix("principals/") + .unwrap_or(&user.username); + let username = if username.is_empty() { + &user.username + } else { + username + }; + + let mut response_body = Vec::new(); + CalDavAdapter::generate_principal_propfind_response( + &mut response_body, + &propfind_request, + username, ) .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; @@ -193,58 +243,159 @@ async fn handle_propfind( .body(Body::from(response_body)) .unwrap()) } else { - // Path could be: {calendar_id} or {calendar_id}/{event_uid}.ics + // Path could be: + // {username} — user calendar home (from calendar-home-set) + // {calendar_id} — calendar collection + // {calendar_id}/{event_uid}.ics — individual event + // {username}/{calendar_id} — calendar under user home + // {username}/{calendar_id}/{uid}.ics — event under user home let parts: Vec<&str> = path.splitn(2, '/').collect(); - let calendar_id = parts[0]; + let first_segment = parts[0]; if parts.len() == 1 { - // Calendar collection - let calendar = calendar_service - .get_calendar(calendar_id, &user.id) - .await - .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; + // Single path segment: try as calendar ID first, fall back to user home + let calendar_result = calendar_service + .get_calendar(first_segment, &user.id) + .await; - let events = if depth != "0" { - calendar_service - .list_events(calendar_id, None, None, &user.id) - .await - .unwrap_or_default() + if let Ok(calendar) = calendar_result { + // Valid calendar ID — return calendar collection + let events = if depth != "0" { + calendar_service + .list_events(first_segment, None, None, &user.id) + .await + .unwrap_or_default() + } else { + vec![] + }; + + let base_href = &format!("/caldav/{}/", first_segment); + let mut response_body = Vec::new(); + + CalDavAdapter::generate_calendar_collection_propfind( + &mut response_body, + &calendar, + &events, + &propfind_request, + base_href, + &depth, + ) + .map_err(|e| { + AppError::internal_error(format!("Failed to generate XML: {}", e)) + })?; + + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()) } else { - vec![] + // Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/) + // List all calendars for this user + let calendars = calendar_service + .list_my_calendars(&user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to list calendars: {}", e)) + })?; + + let base_href = &format!("/caldav/{}/", first_segment); + let mut response_body = Vec::new(); + + CalDavAdapter::generate_calendars_propfind_response( + &mut response_body, + &calendars, + &propfind_request, + base_href, + ) + .map_err(|e| { + AppError::internal_error(format!("Failed to generate XML: {}", e)) + })?; + + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()) + } + } else { + // Multi-segment path: {something}/{rest} + let rest = parts[1]; + + // Check if first_segment is a valid calendar ID + let calendar_result = calendar_service + .get_calendar(first_segment, &user.id) + .await; + + let (calendar_id, event_path) = if calendar_result.is_ok() { + // first_segment is a calendar ID, rest is event path + (first_segment, rest) + } else { + // first_segment may be a username, rest could be {calendar_id} or + // {calendar_id}/{event}.ics + let sub_parts: Vec<&str> = rest.splitn(2, '/').collect(); + if sub_parts.len() == 1 { + // /caldav/{username}/{calendar_id} + // Try to get this as a calendar collection + let cal = calendar_service + .get_calendar(sub_parts[0], &user.id) + .await + .map_err(|e| { + AppError::not_found(format!("Calendar not found: {}", e)) + })?; + + let events = if depth != "0" { + calendar_service + .list_events(sub_parts[0], None, None, &user.id) + .await + .unwrap_or_default() + } else { + vec![] + }; + + let base_href = + &format!("/caldav/{}/{}/", first_segment, sub_parts[0]); + let mut response_body = Vec::new(); + + CalDavAdapter::generate_calendar_collection_propfind( + &mut response_body, + &cal, + &events, + &propfind_request, + base_href, + &depth, + ) + .map_err(|e| { + AppError::internal_error(format!("Failed to generate XML: {}", e)) + })?; + + return Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()); + } else { + // /caldav/{username}/{calendar_id}/{event}.ics + (sub_parts[0], sub_parts[1]) + } }; - let base_href = &format!("/caldav/{}/", calendar_id); - let mut response_body = Vec::new(); - - CalDavAdapter::generate_calendar_collection_propfind( - &mut response_body, - &calendar, - &events, - &propfind_request, - base_href, - &depth, - ) - .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - - Ok(Response::builder() - .status(StatusCode::MULTI_STATUS) - .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(response_body)) - .unwrap()) - } else { // Individual event .ics - let event_file = parts[1]; - let ical_uid = event_file.trim_end_matches(".ics"); + let ical_uid = event_path.trim_end_matches(".ics"); let events = calendar_service .list_events(calendar_id, None, None, &user.id) .await - .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to list events: {}", e)) + })?; let event = events .iter() .find(|e| e.ical_uid == ical_uid) - .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; + .ok_or_else(|| { + AppError::not_found(format!("Event not found: {}", ical_uid)) + })?; let base_href = &format!("/caldav/{}/", calendar_id); let report_type = CalDavReportType::CalendarMultiget { @@ -259,7 +410,9 @@ async fn handle_propfind( &report_type, base_href, ) - .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; + .map_err(|e| { + AppError::internal_error(format!("Failed to generate XML: {}", e)) + })?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) diff --git a/src/main.rs b/src/main.rs index bc49c497..2709c606 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,6 +114,7 @@ async fn main() -> Result<(), Box> { use oxicloud::interfaces::api::handlers::carddav_handler; use oxicloud::interfaces::api::handlers::webdav_handler; let caldav_router = caldav_handler::caldav_routes(); + let well_known_router = caldav_handler::well_known_routes(); let carddav_router = carddav_handler::carddav_routes(); let webdav_router = webdav_handler::webdav_routes(); @@ -228,6 +229,8 @@ async fn main() -> Result<(), Box> { .nest("/api", public_api_routes) // All other API routes are protected by auth middleware .nest("/api", protected_api) + // RFC 6764 well-known discovery (public, no auth — just redirects) + .merge(well_known_router.clone()) // CalDAV/CardDAV/WebDAV protocols merged at top-level for client compatibility .merge(caldav_protected) .merge(carddav_protected) @@ -251,6 +254,8 @@ async fn main() -> Result<(), Box> { app = Router::new() .nest("/api", public_api_routes) .nest("/api", api_routes) + // RFC 6764 well-known discovery (just redirects) + .merge(well_known_router) // CalDAV/CardDAV/WebDAV protocols merged at top-level .merge(caldav_router) .merge(carddav_router)