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"));