From 6a27c742d4cd6b16acbab2e897dac6715a0637aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:21:35 +0000 Subject: [PATCH] Fix CalDAV/CardDAV multiget loading entire collections into memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REPORT multiget for a handful of resources previously listed the whole calendar/address book (every row incl. ical_data/vcard) and filtered by href in Rust with an O(N×M) substring scan. Large collections paid full-table latency, RAM and DB CPU on every sync. - Add find_events_by_ical_uids / get_contacts_by_uids through every layer (domain repo trait → PG repo → storage port/adapter → use case → handler) using one indexed `= ANY($2)` query, mirroring the existing single-UID lookups. - Extract UIDs from multiget hrefs with a shared, tested uid_from_multiget_href helper (percent-decoding, case-insensitive extension strip, collection hrefs rejected). Exact UID matching also removes the false positives the old substring filter allowed. - CalDAV PROPFIND single-event path now uses the existing indexed get_event_by_ical_uid instead of listing all events. - Honour the already-documented limit/offset query params on GET /api/address-books/{id}/contacts: optional ListQuery fields thread through ContactUseCase::list_contacts to a paginated repo query. Omitted params keep returning the full book (frontend and DAV listing/sync paths unchanged). https://claude.ai/code/session_0193Hff42gaA962wThxMGSd1 --- src/application/adapters/mod.rs | 88 +++++++++++++++++++ src/application/ports/calendar_ports.rs | 17 ++++ src/application/ports/carddav_ports.rs | 16 ++++ src/application/services/calendar_service.rs | 26 ++++++ src/application/services/contact_service.rs | 44 ++++++++-- .../repositories/calendar_event_repository.rs | 10 +++ src/domain/repositories/contact_repository.rs | 17 ++++ .../adapters/calendar_storage_adapter.rs | 20 +++++ .../adapters/contact_storage_adapter.rs | 35 +++++++- .../pg/calendar_event_pg_repository.rs | 50 +++++++++++ .../repositories/pg/contact_pg_repository.rs | 70 ++++++++++++++- src/interfaces/api/handlers/caldav_handler.rs | 34 +++---- .../api/handlers/carddav_handler.rs | 28 +++--- .../api/handlers/contacts_handler.rs | 25 +++--- 14 files changed, 431 insertions(+), 49 deletions(-) diff --git a/src/application/adapters/mod.rs b/src/application/adapters/mod.rs index 6fd285f1..08fbab7f 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -8,3 +8,91 @@ pub mod webdav_adapter; mod caldav_adapter_test; #[cfg(test)] mod carddav_adapter_test; + +/// Extract the resource UID from a DAV multiget `href`. +/// +/// CalDAV/CardDAV multiget REPORTs address object resources by full href +/// (e.g. `/caldav/{calendar_id}/{uid}.ics`, possibly with a username +/// segment). The UID is the last path segment with the protocol +/// `extension` (`.ics` / `.vcf`, matched case-insensitively) stripped, +/// then percent-decoded — hrefs arrive in the XML body, so they have not +/// gone through URL-path decoding, and clients may re-encode hrefs they +/// previously read from the server. +/// +/// Returns `None` for collection hrefs (empty last segment) or segments +/// that are not valid UTF-8 after decoding. +pub fn uid_from_multiget_href(href: &str, extension: &str) -> Option { + // A trailing slash denotes a collection, not an object resource. + if href.ends_with('/') { + return None; + } + let segment = href.rsplit('/').next()?; + + // Case-insensitive ASCII extension strip; the matched tail is ASCII, + // so the byte cut is guaranteed to land on a char boundary. + let bytes = segment.as_bytes(); + let ext = extension.as_bytes(); + let segment = + if bytes.len() >= ext.len() && bytes[bytes.len() - ext.len()..].eq_ignore_ascii_case(ext) { + &segment[..segment.len() - ext.len()] + } else { + segment + }; + + let decoded = percent_encoding::percent_decode_str(segment) + .decode_utf8() + .ok()?; + let uid = decoded.trim(); + (!uid.is_empty()).then(|| uid.to_string()) +} + +#[cfg(test)] +mod multiget_href_tests { + use super::uid_from_multiget_href; + + #[test] + fn plain_caldav_href() { + assert_eq!( + uid_from_multiget_href("/caldav/abc-123/event-uid.ics", ".ics"), + Some("event-uid".to_string()) + ); + } + + #[test] + fn href_with_username_prefix() { + assert_eq!( + uid_from_multiget_href("/carddav/alice/book-1/uid-42.vcf", ".vcf"), + Some("uid-42".to_string()) + ); + } + + #[test] + fn uppercase_extension() { + assert_eq!( + uid_from_multiget_href("/caldav/abc/EVENT.ICS", ".ics"), + Some("EVENT".to_string()) + ); + } + + #[test] + fn percent_encoded_uid() { + assert_eq!( + uid_from_multiget_href("/caldav/abc/uid%40example.com.ics", ".ics"), + Some("uid@example.com".to_string()) + ); + } + + #[test] + fn missing_extension_uses_whole_segment() { + assert_eq!( + uid_from_multiget_href("/caldav/abc/bare-uid", ".ics"), + Some("bare-uid".to_string()) + ); + } + + #[test] + fn collection_href_yields_none() { + assert_eq!(uid_from_multiget_href("/caldav/abc/", ".ics"), None); + assert_eq!(uid_from_multiget_href("", ".ics"), None); + } +} diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index 56799533..28728a88 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -95,6 +95,14 @@ pub trait CalendarStoragePort: Send + Sync + 'static { calendar_id: &str, ical_uid: &str, ) -> Result, DomainError>; + /// Indexed batch lookup by iCalendar UID (`ical_uid = ANY(...)`) — the + /// CalDAV multiget REPORT must use this instead of listing the whole + /// calendar (every row + its `ical_data`) and filtering client-side. + async fn find_events_by_ical_uids( + &self, + calendar_id: &str, + ical_uids: &[String], + ) -> Result, DomainError>; async fn list_events_by_calendar( &self, calendar_id: &str, @@ -197,6 +205,15 @@ pub trait CalendarUseCase: Send + Sync + 'static { ical_uid: &str, user_id: Uuid, ) -> Result, DomainError>; + /// Resolve a batch of events by their iCalendar UIDs with a single + /// indexed query. UIDs without a matching event are silently absent + /// from the result (CalDAV multiget semantics). + async fn get_events_by_ical_uids( + &self, + calendar_id: &str, + ical_uids: &[String], + user_id: Uuid, + ) -> Result, DomainError>; async fn list_events( &self, calendar_id: &str, diff --git a/src/application/ports/carddav_ports.rs b/src/application/ports/carddav_ports.rs index ee49dcec..cb842c14 100644 --- a/src/application/ports/carddav_ports.rs +++ b/src/application/ports/carddav_ports.rs @@ -82,9 +82,25 @@ pub trait ContactUseCase: Send + Sync + 'static { uid: &str, user_id: Uuid, ) -> Result, DomainError>; + /// Resolve a batch of contacts by their vCard UIDs with a single + /// indexed query (`uid = ANY(...)`) — the CardDAV multiget REPORT + /// must use this instead of listing the whole address book and + /// filtering client-side. UIDs without a matching contact are + /// silently absent from the result. + async fn get_contacts_by_uids( + &self, + address_book_id: &str, + uids: &[String], + user_id: Uuid, + ) -> Result, DomainError>; + /// List contacts in an address book. `limit`/`offset` bound the + /// result for paginated callers (REST API); `None` returns the full + /// book, which the CardDAV listing/sync paths rely on. async fn list_contacts( &self, address_book_id: &str, + limit: Option, + offset: Option, user_id: Uuid, ) -> Result, DomainError>; async fn search_contacts( diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index 6b51cb8a..762a38d9 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -300,6 +300,32 @@ impl CalendarUseCase for CalendarService { .await } + async fn get_events_by_ical_uids( + &self, + calendar_id: &str, + ical_uids: &[String], + user_id: Uuid, + ) -> Result, DomainError> { + let has_access = self + .calendar_storage + .check_calendar_access(calendar_id, user_id) + .await?; + let calendar = self.calendar_storage.get_calendar(calendar_id).await?; + if !has_access && !calendar.is_public { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Calendar", + "You don't have permission to view events in this calendar", + )); + } + if ical_uids.is_empty() { + return Ok(Vec::new()); + } + self.calendar_storage + .find_events_by_ical_uids(calendar_id, ical_uids) + .await + } + async fn list_events( &self, calendar_id: &str, diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index 9d44b306..b87642eb 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -796,9 +796,34 @@ impl ContactUseCase for ContactService { Ok(contact.map(ContactDto::from)) } + async fn get_contacts_by_uids( + &self, + address_book_id: &str, + uids: &[String], + user_id: Uuid, + ) -> Result, DomainError> { + let id = Uuid::parse_str(address_book_id) + .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; + + // Check if user has access to the address book + self.check_address_book_access(&id, &user_id).await?; + + if uids.is_empty() { + return Ok(Vec::new()); + } + + let contacts = self + .contact_repository + .get_contacts_by_uids(&id, uids) + .await?; + Ok(contacts.into_iter().map(ContactDto::from).collect()) + } + async fn list_contacts( &self, address_book_id: &str, + limit: Option, + offset: Option, user_id: Uuid, ) -> Result, DomainError> { let id = Uuid::parse_str(address_book_id) @@ -808,10 +833,17 @@ impl ContactUseCase for ContactService { self.check_address_book_access(&id, &user_id).await?; // Get contacts - let contacts = self - .contact_repository - .get_contacts_by_address_book(&id) - .await?; + let contacts = if limit.is_some() || offset.is_some() { + let limit = limit.unwrap_or(100); + let offset = offset.unwrap_or(0); + self.contact_repository + .get_contacts_by_address_book_paginated(&id, limit, offset) + .await? + } else { + self.contact_repository + .get_contacts_by_address_book(&id) + .await? + }; let dtos = contacts.into_iter().map(ContactDto::from).collect(); Ok(dtos) @@ -1324,7 +1356,9 @@ impl StorageUseCase for ContactService { let user_id = Uuid::parse_str(user_id) .map_err(|_| DomainError::validation_error("Invalid user_id format"))?; - let result = self.list_contacts(address_book_id, user_id).await?; + let result = self + .list_contacts(address_book_id, None, None, user_id) + .await?; Ok(serde_json::to_value(result).unwrap()) } "search_contacts" => { diff --git a/src/domain/repositories/calendar_event_repository.rs b/src/domain/repositories/calendar_event_repository.rs index dde25fac..d08cc561 100644 --- a/src/domain/repositories/calendar_event_repository.rs +++ b/src/domain/repositories/calendar_event_repository.rs @@ -53,6 +53,16 @@ pub trait CalendarEventRepository: Send + Sync + 'static { ical_uid: &str, ) -> CalendarEventRepositoryResult>; + /// Finds the events matching any of the given iCalendar UIDs in one + /// indexed query (`ical_uid = ANY(...)`). Used by CalDAV multiget so a + /// request for a handful of events never pays for the whole calendar. + /// UIDs with no matching event are silently absent from the result. + async fn find_events_by_ical_uids( + &self, + calendar_id: &Uuid, + ical_uids: &[String], + ) -> CalendarEventRepositoryResult>; + /// Counts events in a calendar async fn count_events_in_calendar( &self, diff --git a/src/domain/repositories/contact_repository.rs b/src/domain/repositories/contact_repository.rs index 76309ac2..01aae004 100644 --- a/src/domain/repositories/contact_repository.rs +++ b/src/domain/repositories/contact_repository.rs @@ -16,10 +16,27 @@ pub trait ContactRepository: Send + Sync + 'static { address_book_id: &Uuid, uid: &str, ) -> ContactRepositoryResult>; + /// Fetches the contacts matching any of the given vCard UIDs in one + /// indexed query (`uid = ANY(...)`). Used by CardDAV multiget so a + /// request for a handful of contacts never pays for the whole book. + /// UIDs with no matching contact are silently absent from the result. + async fn get_contacts_by_uids( + &self, + address_book_id: &Uuid, + uids: &[String], + ) -> ContactRepositoryResult>; async fn get_contacts_by_address_book( &self, address_book_id: &Uuid, ) -> ContactRepositoryResult>; + /// Same as [`Self::get_contacts_by_address_book`] but bounded by + /// `LIMIT`/`OFFSET` for paginated listings. + async fn get_contacts_by_address_book_paginated( + &self, + address_book_id: &Uuid, + limit: i64, + offset: i64, + ) -> ContactRepositoryResult>; async fn get_contacts_by_email(&self, email: &str) -> ContactRepositoryResult>; async fn get_contacts_by_group(&self, group_id: &Uuid) -> ContactRepositoryResult>; diff --git a/src/infrastructure/adapters/calendar_storage_adapter.rs b/src/infrastructure/adapters/calendar_storage_adapter.rs index e37c3ac5..346ba487 100644 --- a/src/infrastructure/adapters/calendar_storage_adapter.rs +++ b/src/infrastructure/adapters/calendar_storage_adapter.rs @@ -422,6 +422,26 @@ impl CalendarStoragePort for CalendarStorageAdapter { Ok(event.map(CalendarEventDto::from)) } + async fn find_events_by_ical_uids( + &self, + calendar_id: &str, + ical_uids: &[String], + ) -> Result, DomainError> { + let uuid = Uuid::parse_str(calendar_id).map_err(|_| { + DomainError::new( + ErrorKind::InvalidInput, + "Calendar", + "Invalid calendar ID format", + ) + })?; + + let events = self + .event_repository + .find_events_by_ical_uids(&uuid, ical_uids) + .await?; + Ok(events.into_iter().map(CalendarEventDto::from).collect()) + } + async fn list_events_by_calendar( &self, calendar_id: &str, diff --git a/src/infrastructure/adapters/contact_storage_adapter.rs b/src/infrastructure/adapters/contact_storage_adapter.rs index 1f8a06d6..1beece32 100644 --- a/src/infrastructure/adapters/contact_storage_adapter.rs +++ b/src/infrastructure/adapters/contact_storage_adapter.rs @@ -730,9 +730,10 @@ impl ContactUseCase for ContactStorageAdapter { Ok(contact.map(ContactDto::from)) } - async fn list_contacts( + async fn get_contacts_by_uids( &self, address_book_id: &str, + uids: &[String], user_id: Uuid, ) -> Result, DomainError> { let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; @@ -740,13 +741,43 @@ impl ContactUseCase for ContactStorageAdapter { // Check read access self.check_address_book_access(&uuid, user_id).await?; + if uids.is_empty() { + return Ok(Vec::new()); + } + let contacts = self .contact_repository - .get_contacts_by_address_book(&uuid) + .get_contacts_by_uids(&uuid, uids) .await?; Ok(contacts.into_iter().map(ContactDto::from).collect()) } + async fn list_contacts( + &self, + address_book_id: &str, + limit: Option, + offset: Option, + user_id: Uuid, + ) -> Result, DomainError> { + let uuid = Self::parse_uuid(address_book_id, "AddressBook")?; + + // Check read access + self.check_address_book_access(&uuid, user_id).await?; + + let contacts = if limit.is_some() || offset.is_some() { + let limit = limit.unwrap_or(100); + let offset = offset.unwrap_or(0); + self.contact_repository + .get_contacts_by_address_book_paginated(&uuid, limit, offset) + .await? + } else { + self.contact_repository + .get_contacts_by_address_book(&uuid) + .await? + }; + Ok(contacts.into_iter().map(ContactDto::from).collect()) + } + async fn search_contacts( &self, address_book_id: &str, diff --git a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs index 8af1a991..8d5cc81e 100644 --- a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs @@ -370,6 +370,56 @@ impl CalendarEventRepository for CalendarEventPgRepository { } } + async fn find_events_by_ical_uids( + &self, + calendar_id: &Uuid, + ical_uids: &[String], + ) -> CalendarEventRepositoryResult> { + let rows = sqlx::query( + r#" + SELECT + id, calendar_id, summary, description, location, + start_time, end_time, all_day, rrule, + created_at, updated_at, ical_uid, ical_data + FROM caldav.calendar_events + WHERE calendar_id = $1 AND ical_uid = ANY($2) + ORDER BY start_time + "#, + ) + .bind(calendar_id) + .bind(ical_uids) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get calendar events by UIDs: {}", e)) + })?; + + let mut events = Vec::new(); + for row in rows { + let event = CalendarEvent::with_id( + row.get("id"), + row.get("calendar_id"), + row.get("summary"), + row.get::, _>("description"), + row.get::, _>("location"), + row.get("start_time"), + row.get("end_time"), + row.get("all_day"), + row.get::, _>("rrule"), + row.get("ical_uid"), + row.get("ical_data"), + row.get("created_at"), + row.get("updated_at"), + ) + .map_err(|e| { + DomainError::database_error(format!("Error creating calendar event: {}", e)) + })?; + events.push(event); + } + + Ok(events) + } + async fn count_events_in_calendar( &self, calendar_id: &Uuid, diff --git a/src/infrastructure/repositories/pg/contact_pg_repository.rs b/src/infrastructure/repositories/pg/contact_pg_repository.rs index da145b87..02ea9008 100644 --- a/src/infrastructure/repositories/pg/contact_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_pg_repository.rs @@ -247,13 +247,44 @@ impl ContactRepository for ContactPgRepository { } } + async fn get_contacts_by_uids( + &self, + address_book_id: &Uuid, + uids: &[String], + ) -> ContactRepositoryResult> { + let rows = sqlx::query( + r#" + SELECT + id, address_book_id, uid, full_name, first_name, last_name, nickname, + email, phone, address, organization, title, notes, photo_url, + birthday, anniversary, vcard, etag, created_at, updated_at + FROM carddav.contacts + WHERE address_book_id = $1 AND uid = ANY($2) + ORDER BY full_name, first_name, last_name + "#, + ) + .bind(address_book_id) + .bind(uids) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!("Failed to get contacts by uids: {}", e)) + })?; + + let mut contacts = Vec::new(); + for row in &rows { + contacts.push(Self::row_to_contact(row)?); + } + Ok(contacts) + } + async fn get_contacts_by_address_book( &self, address_book_id: &Uuid, ) -> ContactRepositoryResult> { let rows = sqlx::query( r#" - SELECT + SELECT id, address_book_id, uid, full_name, first_name, last_name, nickname, email, phone, address, organization, title, notes, photo_url, birthday, anniversary, vcard, etag, created_at, updated_at @@ -276,6 +307,43 @@ impl ContactRepository for ContactPgRepository { Ok(contacts) } + async fn get_contacts_by_address_book_paginated( + &self, + address_book_id: &Uuid, + limit: i64, + offset: i64, + ) -> ContactRepositoryResult> { + let rows = sqlx::query( + r#" + SELECT + id, address_book_id, uid, full_name, first_name, last_name, nickname, + email, phone, address, organization, title, notes, photo_url, + birthday, anniversary, vcard, etag, created_at, updated_at + FROM carddav.contacts + WHERE address_book_id = $1 + ORDER BY full_name, first_name, last_name + LIMIT $2 OFFSET $3 + "#, + ) + .bind(address_book_id) + .bind(limit) + .bind(offset) + .fetch_all(&*self.pool) + .await + .map_err(|e| { + DomainError::database_error(format!( + "Failed to get contacts by address book (paginated): {}", + e + )) + })?; + + let mut contacts = Vec::new(); + for row in &rows { + contacts.push(Self::row_to_contact(row)?); + } + Ok(contacts) + } + async fn get_contacts_by_email(&self, email: &str) -> ContactRepositoryResult> { let search_pattern = super::like_escape(email); diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index 98fded62..b6d6eb88 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -27,6 +27,7 @@ use std::fmt::Write; use std::sync::Arc; use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType}; +use crate::application::adapters::uid_from_multiget_href; use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType}; use crate::application::dtos::calendar_dto::{ CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto, @@ -422,17 +423,13 @@ async fn handle_propfind( } }; - // Individual event .ics + // Individual event .ics — indexed lookup by iCalendar UID. let ical_uid = event_path.trim_end_matches(".ics"); - let events = calendar_service - .list_events(calendar_id, None, None, user.id) + let event = calendar_service + .get_event_by_ical_uid(calendar_id, ical_uid, user.id) .await - .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; - - let event = events - .iter() - .find(|e| e.ical_uid == ical_uid) + .map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))? .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; let base_href = &format!("/caldav/{}/", calendar_id); @@ -444,7 +441,7 @@ async fn handle_propfind( let mut response_body = Vec::new(); CalDavAdapter::generate_calendar_events_response( &mut response_body, - std::slice::from_ref(event), + std::slice::from_ref(&event), &report_type, base_href, ) @@ -502,15 +499,18 @@ async fn handle_report( } } CalDavReportType::CalendarMultiget { hrefs, .. } => { - let all_events = calendar_service - .list_events(calendar_id, None, None, user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; + // Indexed batch lookup (`ical_uid = ANY(...)`) — a multiget for + // a handful of events must not pay for listing the whole + // calendar and filtering client-side. + let uids: Vec = hrefs + .iter() + .filter_map(|href| uid_from_multiget_href(href, ".ics")) + .collect(); - all_events - .into_iter() - .filter(|evt| hrefs.iter().any(|href| href.contains(&evt.ical_uid))) - .collect() + calendar_service + .get_events_by_ical_uids(calendar_id, &uids, user.id) + .await + .map_err(|e| AppError::internal_error(format!("Failed to fetch events: {}", e)))? } CalDavReportType::SyncCollection { .. } => calendar_service .list_events(calendar_id, None, None, user.id) diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 5b58c1fd..1f45b017 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -28,6 +28,7 @@ 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; @@ -269,7 +270,7 @@ async fn handle_propfind( let contacts = if depth != "0" { contact_svc - .list_contacts(address_book_id, user.id) + .list_contacts(address_book_id, None, None, user.id) .await .unwrap_or_default() } else { @@ -359,22 +360,25 @@ async fn handle_report( let contacts = match &report { CardDavReportType::AddressbookQuery { .. } => contact_svc - .list_contacts(address_book_id, user.id) + .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, .. } => { - let all_contacts = contact_svc - .list_contacts(address_book_id, user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; + // 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 = hrefs + .iter() + .filter_map(|href| uid_from_multiget_href(href, ".vcf")) + .collect(); - all_contacts - .into_iter() - .filter(|c| hrefs.iter().any(|href| href.contains(&c.uid))) - .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, user.id) + .list_contacts(address_book_id, None, None, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?, }; @@ -559,7 +563,7 @@ async fn handle_get( if parts.len() < 2 { // GET on address book collection — return all contacts as vcf let contacts = contact_svc - .list_contacts(address_book_id, user.id) + .list_contacts(address_book_id, None, None, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; diff --git a/src/interfaces/api/handlers/contacts_handler.rs b/src/interfaces/api/handlers/contacts_handler.rs index e7f68641..daa63b55 100644 --- a/src/interfaces/api/handlers/contacts_handler.rs +++ b/src/interfaces/api/handlers/contacts_handler.rs @@ -119,16 +119,14 @@ pub struct AddMemberRequest { } /// Query parameters for paginated listing. +/// +/// Both fields are optional: when omitted, regular address books return +/// the full contact list (the frontend relies on this), while the system +/// book falls back to its own defaults (limit 100, offset 0). #[derive(Deserialize)] pub struct ListQuery { - #[serde(default = "default_limit")] - limit: i64, - #[serde(default)] - offset: i64, -} - -fn default_limit() -> i64 { - 100 + limit: Option, + offset: Option, } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -455,8 +453,8 @@ pub async fn delete_address_book( path = "/api/address-books/{book_id}/contacts", params( ("book_id" = String, Path, description = "Address book UUID or \"system\""), - ("limit" = Option, Query, description = "Max results (default 100)"), - ("offset" = Option, Query, description = "Pagination offset (default 0)"), + ("limit" = Option, Query, description = "Max results (omit for the full book; system book defaults to 100)"), + ("offset" = Option, Query, description = "Pagination offset (omit for none; system book defaults to 0)"), ), responses( (status = 200, description = "List of contacts"), @@ -488,7 +486,10 @@ pub async fn list_contacts( return e.into_response(); } let caller_id = auth_user.id.to_string(); - match auth_service.list_users(params.limit, params.offset).await { + match auth_service + .list_users(params.limit.unwrap_or(100), params.offset.unwrap_or(0)) + .await + { Ok(users) => { let contacts: Vec = users .into_iter() @@ -505,7 +506,7 @@ pub async fn list_contacts( } else { match state .contact_service - .list_contacts(&book_id, auth_user.id) + .list_contacts(&book_id, params.limit, params.offset, auth_user.id) .await { Ok(contacts) => (StatusCode::OK, Json(contacts)).into_response(),