From d0086bb43315e4097b6b373b065069d66b741005 Mon Sep 17 00:00:00 2001 From: Timm Date: Thu, 30 Apr 2026 09:52:48 +0200 Subject: [PATCH] fix(dav): handle username prefix in all CalDAV and CardDAV route handlers The PROPFIND handlers (added in #153) handle the /caldav/{username}/ and /carddav/{username}/ prefixes returned by discovery, but do so via an expensive database call to distinguish usernames from UUIDs. The remaining handlers (PUT, GET, DELETE, REPORT, PROPPATCH) don't handle the prefix at all, causing 500 errors when standard clients (DAVx5, Apple Calendar, Thunderbird) follow the discovery URLs. This commit: 1. Adds a strip_username_prefix() helper that uses a UUID parse check to distinguish collection IDs from usernames (no DB call needed). 2. Applies it to PUT, GET, DELETE, REPORT, and PROPPATCH handlers in both caldav_handler.rs and carddav_handler.rs. 3. Refactors PROPFIND in both files to use the same UUID heuristic for consistency, avoiding unnecessary database queries. Includes 7 unit tests for the helper function. --- src/interfaces/api/handlers/caldav_handler.rs | 123 ++++++++++++++++-- .../api/handlers/carddav_handler.rs | 45 ++++++- 2 files changed, 151 insertions(+), 17 deletions(-) diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index ac18fac3..99179144 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -136,6 +136,38 @@ fn reject_path_traversal(path: &str) -> Result<(), AppError> { Ok(()) } +// ─── Helper: strip optional username prefix from CalDAV path ───────── +// +// The `calendar-home-set` discovery property returns `/caldav/{username}/`, +// so standard clients (DAVx5, Apple Calendar, Thunderbird) will prefix all +// subsequent requests with the username segment. The handlers below expect +// paths of the form `{calendar_id}` or `{calendar_id}/{event}.ics`, so we +// need to detect and strip the leading username when present. +// +// Heuristic: if the first path segment is a valid UUID it is already a +// calendar ID; otherwise treat it as a username and skip it. + +fn strip_username_prefix(path: &str) -> &str { + if let Some(pos) = path.find('/') { + let first = &path[..pos]; + if uuid::Uuid::parse_str(first).is_ok() { + // First segment is a UUID → no username prefix + path + } else { + // First segment is not a UUID → treat as username, return the rest + &path[pos + 1..] + } + } else { + // Single segment (no slash) + if uuid::Uuid::parse_str(path).is_ok() { + path + } else { + // Single non-UUID segment (bare username) → nothing useful after it + "" + } + } +} + // ─── Helper: extract user from request ─────────────────────────────── fn extract_user(req: &Request) -> Result { @@ -260,12 +292,24 @@ async fn handle_propfind( // {calendar_id}/{event_uid}.ics — individual event // {username}/{calendar_id} — calendar under user home // {username}/{calendar_id}/{uid}.ics — event under user home + // + // Use strip_username_prefix heuristic: if first segment is a UUID + // it's a calendar ID, otherwise it's a username prefix. let parts: Vec<&str> = path.splitn(2, '/').collect(); let first_segment = parts[0]; + let first_is_uuid = uuid::Uuid::parse_str(first_segment).is_ok(); if parts.len() == 1 { - // 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; + // Single path segment: UUID means calendar ID, otherwise user home + let calendar_result = if first_is_uuid { + calendar_service.get_calendar(first_segment, user.id).await + } else { + Err(crate::domain::errors::DomainError::new( + crate::domain::errors::ErrorKind::NotFound, + "Calendar", + "Not a UUID", + )) + }; if let Ok(calendar) = calendar_result { // Valid calendar ID — return calendar collection @@ -327,10 +371,8 @@ async fn handle_propfind( // 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() { + // Use UUID heuristic: if first segment is a UUID it's a calendar ID + let (calendar_id, event_path) = if first_is_uuid { // first_segment is a calendar ID, rest is event path (first_segment, rest) } else { @@ -434,7 +476,8 @@ async fn handle_report( let report = CalDavAdapter::parse_report(body_bytes.reader()) .map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?; - let calendar_id = path.split('/').next().unwrap_or(path); + let effective_path = strip_username_prefix(path); + let calendar_id = effective_path.split('/').next().unwrap_or(effective_path); if calendar_id.is_empty() { return Err(AppError::bad_request("Calendar ID required in path")); @@ -546,7 +589,8 @@ async fn handle_put( let user = extract_user(&req)?; let calendar_service = get_calendar_service(&state)?; - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); if parts.len() < 2 { return Err(AppError::bad_request( "Path must be {calendar_id}/{uid}.ics", @@ -635,7 +679,8 @@ async fn handle_get( let user = extract_user(&req)?; let calendar_service = get_calendar_service(&state)?; - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); let calendar_id = parts[0]; if parts.len() < 2 { @@ -751,7 +796,8 @@ async fn handle_delete( let user = extract_user(&req)?; let calendar_service = get_calendar_service(&state)?; - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); let calendar_id = parts[0]; if calendar_id.is_empty() { @@ -809,7 +855,8 @@ async fn handle_proppatch( ) .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?; - let calendar_id = path.split('/').next().unwrap_or(path); + let effective_path = strip_username_prefix(path); + let calendar_id = effective_path.split('/').next().unwrap_or(effective_path); if calendar_id.is_empty() { return Err(AppError::bad_request("Calendar ID required")); @@ -861,3 +908,57 @@ async fn handle_proppatch( .body(Body::from(response_body)) .unwrap()) } + +#[cfg(test)] +mod tests { + use super::strip_username_prefix; + + #[test] + fn test_strip_username_prefix_uuid_only() { + let uuid = "ae8ae236-709f-4939-b766-37ad589ac7f2"; + assert_eq!(strip_username_prefix(uuid), uuid); + } + + #[test] + fn test_strip_username_prefix_uuid_with_event() { + let path = "ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"; + assert_eq!(strip_username_prefix(path), path); + } + + #[test] + fn test_strip_username_prefix_username_and_uuid() { + let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2"; + assert_eq!( + strip_username_prefix(path), + "ae8ae236-709f-4939-b766-37ad589ac7f2" + ); + } + + #[test] + fn test_strip_username_prefix_username_uuid_and_event() { + let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"; + assert_eq!( + strip_username_prefix(path), + "ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics" + ); + } + + #[test] + fn test_strip_username_prefix_bare_username() { + assert_eq!(strip_username_prefix("timm"), ""); + } + + #[test] + fn test_strip_username_prefix_empty() { + assert_eq!(strip_username_prefix(""), ""); + } + + #[test] + fn test_strip_username_prefix_email_style_username() { + let path = "user@example.com/ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"; + assert_eq!( + strip_username_prefix(path), + "ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics" + ); + } +} diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 50dab95b..a50441e0 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -124,6 +124,33 @@ fn reject_path_traversal(path: &str) -> Result<(), AppError> { Ok(()) } +// ─── Helper: strip optional username prefix from CardDAV path ──────── +// +// The `addressbook-home-set` discovery property returns `/carddav/{username}/`, +// so standard clients will prefix all subsequent requests with the username segment. +// The handlers below expect paths of the form `{address_book_id}` or `{address_book_id}/{contact}.vcf`. +// +// Heuristic: if the first path segment is a valid UUID it is already an +// address book ID; otherwise treat it as a username and skip it. + +fn strip_username_prefix(path: &str) -> &str { + if let Some(pos) = path.find('/') { + let first = &path[..pos]; + if uuid::Uuid::parse_str(first).is_ok() { + path + } else { + &path[pos + 1..] + } + } else { + if uuid::Uuid::parse_str(path).is_ok() { + path + } else { + "" + } + } +} + + // ─── Helper: extract user from request ─────────────────────────────── fn extract_user(req: &Request) -> Result { @@ -226,7 +253,8 @@ async fn handle_propfind( .body(Body::from(response_body)) .unwrap()) } else { - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); let address_book_id = parts[0]; if parts.len() == 1 { @@ -324,7 +352,8 @@ async fn handle_report( let report = CardDavAdapter::parse_report(body_bytes.reader()) .map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?; - let address_book_id = path.split('/').next().unwrap_or(path); + let effective_path = strip_username_prefix(path); + let address_book_id = effective_path.split('/').next().unwrap_or(effective_path); if address_book_id.is_empty() { return Err(AppError::bad_request("Address book ID required in path")); @@ -431,7 +460,8 @@ async fn handle_put( let user = extract_user(&req)?; let contact_svc = get_contact_service(&state)?; - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); if parts.len() < 2 { return Err(AppError::bad_request( "Path must be {address_book_id}/{uid}.vcf", @@ -524,7 +554,8 @@ async fn handle_get( let user = extract_user(&req)?; let contact_svc = get_contact_service(&state)?; - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); let address_book_id = parts[0]; if parts.len() < 2 { @@ -581,7 +612,8 @@ async fn handle_delete( let addressbook_service = get_addressbook_service(&state)?; let contact_svc = get_contact_service(&state)?; - let parts: Vec<&str> = path.splitn(2, '/').collect(); + let effective_path = strip_username_prefix(path); + let parts: Vec<&str> = effective_path.splitn(2, '/').collect(); let address_book_id = parts[0]; if address_book_id.is_empty() { @@ -643,7 +675,8 @@ async fn handle_proppatch( ) .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?; - let address_book_id = path.split('/').next().unwrap_or(path); + let effective_path = strip_username_prefix(path); + let address_book_id = effective_path.split('/').next().unwrap_or(effective_path); if address_book_id.is_empty() { return Err(AppError::bad_request("Address book ID required"));