Files
Oxicloud/src/interfaces/api/handlers/carddav_handler.rs
T
Edouard Vanbelle 0fcd617fd1 feat(calendar,addressbook): migrate share to authz engine
migrate DB entries to authz engine
    and wire authz engine to caldav and carddav
2026-07-08 01:03:25 +02:00

857 lines
30 KiB
Rust

/**
* CardDAV Handler Module
*
* This module implements the CardDAV protocol (RFC 6352) endpoints for OxiCloud.
* It provides contact/address book access and management through standard CardDAV
* methods, allowing clients like Thunderbird, Apple Contacts, GNOME Contacts,
* and DAVx⁵ to sync contacts.
*
* Supported methods:
* - OPTIONS: Advertise CardDAV capabilities
* - PROPFIND: List address books and their properties
* - REPORT: Query contacts (addressbook-query, addressbook-multiget)
* - MKCOL (ext): Create a new address book
* - PUT: Create/update contacts (.vcf)
* - GET: Retrieve contact vCard data
* - DELETE: Remove address books or contacts
* - PROPPATCH: Modify address book properties
*/
use axum::{
Router,
body::{self, Body},
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use bytes::Buf;
use std::sync::Arc;
use crate::application::adapters::carddav_adapter::{
CardDavAdapter, CardDavReportType, contact_to_vcard,
};
use crate::application::adapters::uid_from_multiget_href;
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
use crate::application::dtos::contact_dto::CreateContactVCardDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::contact_service::ContactService;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Maximum allowed request body size for CardDAV XML/vCard endpoints (1 MB).
/// Prevents OOM/DoS via unbounded body buffering.
const MAX_CARDDAV_BODY: usize = 1_048_576;
/// Creates CardDAV routes with full path prefixes.
///
/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
/// Registers `/carddav`, `/carddav/`, and `/carddav/{*path}` explicitly.
pub fn carddav_routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/carddav/{*path}",
axum::routing::any(handle_carddav_methods),
)
.route("/carddav/", axum::routing::any(handle_carddav_methods_root))
.route("/carddav", axum::routing::any(handle_carddav_methods_root))
}
/// Creates the RFC 6764 well-known discovery route for CardDAV.
/// Public (no auth) — simply redirects to the CardDAV root so clients that
/// bootstrap from `/.well-known/carddav` can locate the service.
pub fn well_known_routes() -> Router<Arc<AppState>> {
Router::new().route(
"/.well-known/carddav",
axum::routing::any(handle_well_known_carddav),
)
}
async fn handle_well_known_carddav() -> Response<Body> {
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, "/carddav/")
.body(Body::empty())
.unwrap()
}
async fn handle_carddav_methods_root(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
) -> Result<Response<Body>, AppError> {
handle_carddav_methods_inner(state, req, String::new()).await
}
async fn handle_carddav_methods(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
) -> Result<Response<Body>, AppError> {
let uri = req.uri().clone();
let path = extract_carddav_path(uri.path());
reject_path_traversal(&path)?;
handle_carddav_methods_inner(state, req, path).await
}
async fn handle_carddav_methods_inner(
state: Arc<AppState>,
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
match method.as_str() {
"OPTIONS" => handle_options().await,
"PROPFIND" => handle_propfind(state.clone(), req, &path).await,
"REPORT" => handle_report(state.clone(), req, &path).await,
"MKCOL" => handle_mkcol(state.clone(), req, &path).await,
"PUT" => handle_put(state.clone(), req, &path).await,
"GET" => handle_get(state.clone(), req, &path).await,
"DELETE" => handle_delete(state.clone(), req, &path).await,
"PROPPATCH" => handle_proppatch(state.clone(), req, &path).await,
_ => Err(AppError::method_not_allowed(format!(
"Method not allowed: {}",
method
))),
}
}
/// Extract the CardDAV path from the full URI path, percent-decoding the result.
fn extract_carddav_path(uri_path: &str) -> String {
let encoded = if let Some(pos) = uri_path.find("/carddav/") {
let after = &uri_path[pos + 9..];
after.trim_end_matches('/')
} else if uri_path.ends_with("/carddav") {
""
} else {
uri_path.trim_start_matches('/').trim_end_matches('/')
};
percent_encoding::percent_decode_str(encoded)
.decode_utf8_lossy()
.into_owned()
}
/// Reject paths that contain path-traversal segments (`.` or `..`).
fn reject_path_traversal(path: &str) -> Result<(), AppError> {
for segment in path.split('/') {
if segment == ".." || segment == "." {
return Err(AppError::bad_request(
"Path must not contain '.' or '..' segments",
));
}
}
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<Body>) -> Result<AuthUser, AppError> {
req.extensions()
.get::<Arc<CurrentUser>>()
.cloned()
.map(AuthUser)
.ok_or_else(|| AppError::unauthorized("Authentication required"))
}
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
state.addressbook_use_case.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::NOT_IMPLEMENTED,
"CardDAV address book service is not configured",
"NotImplemented",
)
})
}
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
state.contact_use_case.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::NOT_IMPLEMENTED,
"CardDAV contact service is not configured",
"NotImplemented",
)
})
}
// ─── OPTIONS ─────────────────────────────────────────────────────────
async fn handle_options() -> Result<Response<Body>, AppError> {
Ok(Response::builder()
.status(StatusCode::OK)
.header(HEADER_DAV, "1, 2, 3, addressbook")
.header(
header::ALLOW,
"OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCOL",
)
.body(Body::empty())
.unwrap())
}
// ─── PROPFIND ────────────────────────────────────────────────────────
async fn handle_propfind(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let depth = req
.headers()
.get("Depth")
.and_then(|v| v.to_str().ok())
.unwrap_or("1")
.to_string();
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let contact_svc = get_contact_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CARDDAV_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let propfind_request = if body_bytes.is_empty() {
PropFindRequest {
prop_find_type: PropFindType::AllProp,
}
} else {
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_propfind(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
};
// Discovery: the true root `/carddav/` advertises current-user-principal and
// addressbook-home-set so clients (DAVx5, Apple Contacts) can locate the
// address books. Depth 0 → only the root entry; Depth 1+ → also the books.
if path.is_empty() {
let address_books = if depth == "0" {
vec![]
} else {
addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?
};
let mut response_body = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut response_body,
&address_books,
&propfind_request,
"/carddav/",
&user.username,
)
.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());
}
// Discovery: principal resource `/carddav/principals/{username}/` returns the
// addressbook-home-set the client should enumerate next.
if path == "principals" || path.starts_with("principals/") {
let username = path
.strip_prefix("principals/")
.map(|s| s.trim_end_matches('/'))
.filter(|s| !s.is_empty())
.unwrap_or(&user.username);
let mut response_body = Vec::new();
CardDavAdapter::generate_principal_propfind_response(
&mut response_body,
&propfind_request,
username,
)
.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());
}
let effective_path = strip_username_prefix(path);
if effective_path.is_empty() {
// User address-book home `/carddav/{username}/` — list the user's books.
let address_books = addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?;
let user_part = path.split('/').next().unwrap_or(path);
let base_href = format!("/carddav/{}/", user_part);
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbooks_propfind_response(
&mut response_body,
&address_books,
&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 {
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
let address_book_id = parts[0];
if parts.len() == 1 {
// Address book collection
let address_book = addressbook_service
.get_address_book(address_book_id, user.id)
.await
.map_err(|e| AppError::not_found(format!("Address book not found: {}", e)))?;
let contacts = if depth != "0" {
contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
let base_href = &format!("/carddav/{}/", address_book_id);
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbook_collection_propfind(
&mut response_body,
&address_book,
&contacts,
&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 contact .vcf — indexed lookup by vCard UID.
let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf");
let contact = contact_svc
.get_contact_by_uid(address_book_id, contact_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
.ok_or_else(|| {
AppError::not_found(format!("Contact not found: {}", contact_uid))
})?;
// Build single-resource PROPFIND response
let base_href = &format!("/carddav/{}/", address_book_id);
let report = CardDavReportType::AddressbookMultiget {
hrefs: vec![format!("{}{}.vcf", base_href, contact_uid)],
props: vec![],
};
let mut response_body = Vec::new();
CardDavAdapter::generate_contacts_response(
&mut response_body,
std::slice::from_ref(&contact),
&[(contact.uid.clone(), contact_to_vcard(&contact))],
&report,
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())
}
}
}
// ─── REPORT ──────────────────────────────────────────────────────────
async fn handle_report(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CARDDAV_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let report = CardDavAdapter::parse_report(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?;
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"));
}
let contacts = match &report {
CardDavReportType::AddressbookQuery { .. } => contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?,
CardDavReportType::AddressbookMultiget { hrefs, .. } => {
// Indexed batch lookup (`uid = ANY(...)`) — a multiget for a
// handful of contacts must not pay for listing the whole
// address book and filtering client-side.
let uids: Vec<String> = hrefs
.iter()
.filter_map(|href| uid_from_multiget_href(href, ".vcf"))
.collect();
contact_svc
.get_contacts_by_uids(address_book_id, &uids, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to fetch contacts: {}", e)))?
}
CardDavReportType::SyncCollection { .. } => contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?,
};
// Generate vCards
let vcards: Vec<(String, String)> = contacts
.iter()
.map(|c| (c.uid.clone(), contact_to_vcard(c)))
.collect();
let base_href = &format!("/carddav/{}/", address_book_id);
let mut response_body = Vec::new();
CardDavAdapter::generate_contacts_response(
&mut response_body,
&contacts,
&vcards,
&report,
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())
}
// ─── MKCOL (create address book) ─────────────────────────────────────
async fn handle_mkcol(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CARDDAV_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (name, description, color) = if body_bytes.is_empty() {
let name = path
.split('/')
.next_back()
.unwrap_or("New Address Book")
.to_string();
(name, None, None)
} else {
CardDavAdapter::parse_mkaddressbook(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse MKCOL: {}", e)))?
};
let create_dto = CreateAddressBookDto {
name,
owner_id: user.id.to_string(),
description,
color,
is_public: Some(false),
};
addressbook_service
.create_address_book(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create address book: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
// ─── PUT (.vcf) ──────────────────────────────────────────────────────
async fn handle_put(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
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",
));
}
let address_book_id = parts[0];
let body_bytes = body::to_bytes(req.into_body(), MAX_CARDDAV_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let vcard_data = String::from_utf8(body_bytes.to_vec())
.map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in vCard data: {}", e)))?;
// Extract UID from vCard
let vcard_uid = extract_uid_from_vcard(&vcard_data);
// Check if contact already exists — indexed single-row lookup
// (listing the whole address book made imports O(N²)).
let existing = if let Some(ref uid) = vcard_uid {
contact_svc
.get_contact_by_uid(address_book_id, uid, user.id)
.await
.unwrap_or_default()
} else {
None
};
if let Some(existing_contact) = existing {
// Update: delete + recreate from vCard
contact_svc
.delete_contact(&existing_contact.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update contact: {}", e)))?;
let create_dto = CreateContactVCardDto {
address_book_id: address_book_id.to_string(),
vcard: vcard_data,
user_id: user.id.to_string(),
};
let contact = contact_svc
.create_contact_from_vcard(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to recreate contact: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", contact.etag))
.body(Body::empty())
.unwrap())
} else {
let create_dto = CreateContactVCardDto {
address_book_id: address_book_id.to_string(),
vcard: vcard_data,
user_id: user.id.to_string(),
};
let contact = contact_svc
.create_contact_from_vcard(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create contact: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", contact.etag))
.body(Body::empty())
.unwrap())
}
}
/// Extract UID from vCard data
fn extract_uid_from_vcard(vcard_data: &str) -> Option<String> {
for line in vcard_data.lines() {
let trimmed = line.trim();
if let Some(stripped) = trimmed.strip_prefix("UID:") {
return Some(stripped.trim().to_string());
}
}
None
}
// ─── GET (.vcf) ──────────────────────────────────────────────────────
async fn handle_get(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
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 {
// GET on address book collection — return all contacts as vcf
let contacts = contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
let mut vcf_data = String::new();
for contact in &contacts {
vcf_data.push_str(&contact_to_vcard(contact));
}
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/vcard; charset=utf-8")
.body(Body::from(vcf_data))
.unwrap())
} else {
// GET on individual contact — indexed lookup by vCard UID.
let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf");
let contact = contact_svc
.get_contact_by_uid(address_book_id, contact_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
let vcard = contact_to_vcard(&contact);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/vcard; charset=utf-8")
.header(header::ETAG, format!("\"{}\"", contact.etag))
.body(Body::from(vcard))
.unwrap())
}
}
// ─── DELETE ──────────────────────────────────────────────────────────
async fn handle_delete(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let contact_svc = get_contact_service(&state)?;
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() {
return Err(AppError::bad_request("Address book ID required"));
}
if parts.len() < 2 {
// Delete address book
addressbook_service
.delete_address_book(address_book_id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete address book: {}", e))
})?;
} else {
// Delete contact — indexed lookup by vCard UID.
let contact_file = parts[1];
let contact_uid = contact_file.trim_end_matches(".vcf");
let contact = contact_svc
.get_contact_by_uid(address_book_id, contact_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
contact_svc
.delete_contact(&contact.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete contact: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
}
// ─── PROPPATCH ───────────────────────────────────────────────────────
async fn handle_proppatch(
state: Arc<AppState>,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
let addressbook_service = get_addressbook_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CARDDAV_BODY)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let ops = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
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"));
}
let mut update = UpdateAddressBookDto {
name: None,
description: None,
color: None,
is_public: None,
user_id: user.id.to_string(),
};
for op in &ops {
if let crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) = op {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
"addressbook-description" => update.description = prop.value.clone(),
"calendar-color" | "addressbook-color" => update.color = prop.value.clone(),
_ => {}
}
}
}
if update.name.is_some() || update.description.is_some() || update.color.is_some() {
addressbook_service
.update_address_book(address_book_id, update)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to update address book: {}", e))
})?;
}
let mut results = Vec::new();
for op in &ops {
match op {
crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) => {
results.push((&prop.name, true));
}
crate::application::adapters::webdav_adapter::PropPatchOp::Remove(name) => {
results.push((name, true));
}
}
}
let href = format!("/carddav/{}", path);
let mut response_body = Vec::new();
crate::application::adapters::webdav_adapter::WebDavAdapter::generate_proppatch_response(
&mut response_body,
&href,
&results,
)
.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())
}
#[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_contact() {
let path = "ae8ae236-709f-4939-b766-37ad589ac7f2/contact.vcf";
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_contact() {
let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2/contact.vcf";
assert_eq!(
strip_username_prefix(path),
"ae8ae236-709f-4939-b766-37ad589ac7f2/contact.vcf"
);
}
#[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/contact.vcf";
assert_eq!(
strip_username_prefix(path),
"ae8ae236-709f-4939-b766-37ad589ac7f2/contact.vcf"
);
}
}