From ef9ed2cc317208866bc0c8a8e733ceb312a7320e Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 10 Feb 2026 18:46:59 +0100 Subject: [PATCH] feat: complete CalDAV (RFC 4791) and CardDAV (RFC 6352) implementation - CalDAV: MKCALENDAR, PROPFIND, PUT/GET/DELETE events, REPORT calendar-query - CardDAV: MKCOL, PROPFIND, PUT/GET/DELETE vCards, REPORT addressbook-query - Fix routing: move CalDAV/CardDAV to top-level merge() with explicit routes - Fix DB schema: VARCHAR(36) -> UUID for entity IDs, vcard_data -> vcard - Fix 15 repository stub methods that returned empty results - Fix vCard parser in ContactStorageAdapter (was hardcoded stub) - All operations tested end-to-end in Docker (201/207/200/204 as expected) --- Cargo.lock | 2 +- db/schema.sql | 197 ++- src/application/adapters/caldav_adapter.rs | 71 +- .../adapters/caldav_adapter_test.rs | 286 ++++ src/application/adapters/carddav_adapter.rs | 624 +++++++ .../adapters/carddav_adapter_test.rs | 432 +++++ src/application/adapters/mod.rs | 6 + src/application/ports/calendar_ports.rs | 11 + src/application/services/calendar_service.rs | 76 + .../services/trash_service_test.rs | 8 + src/common/di.rs | 74 +- .../adapters/contact_storage_adapter.rs | 95 +- .../pg/calendar_event_pg_repository.rs | 203 ++- .../pg/contact_group_pg_repository.rs | 223 +++ .../repositories/pg/contact_pg_repository.rs | 118 +- src/infrastructure/repositories/pg/mod.rs | 2 + src/interfaces/api/handlers/caldav_handler.rs | 629 ++++++- .../api/handlers/carddav_handler.rs | 1509 +++++++---------- src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/routes.rs | 7 +- src/main.rs | 18 + storage/folder_ids.json | 12 +- 22 files changed, 3479 insertions(+), 1125 deletions(-) create mode 100644 src/application/adapters/caldav_adapter_test.rs create mode 100644 src/application/adapters/carddav_adapter.rs create mode 100644 src/application/adapters/carddav_adapter_test.rs create mode 100644 src/infrastructure/repositories/pg/contact_group_pg_repository.rs diff --git a/Cargo.lock b/Cargo.lock index 079e9446..7273a6a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1629,7 +1629,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "oxicloud" -version = "0.1.0" +version = "0.3.0" dependencies = [ "anyhow", "argon2", diff --git a/db/schema.sql b/db/schema.sql index d262fe2f..80353078 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,14 @@ --- OxiCloud Authentication Database Schema +-- ============================================================ +-- OxiCloud Unified Database Schema +-- For clean installations: psql -f db/schema.sql +-- ============================================================ +-- Order: auth (base) → caldav → carddav +-- All tables use IF NOT EXISTS for idempotent re-runs. +-- ============================================================ --- Create schema for auth-related tables +-- ============================================================ +-- 1. AUTH SCHEMA +-- ============================================================ CREATE SCHEMA IF NOT EXISTS auth; -- Create UserRole enum type @@ -30,7 +38,6 @@ CREATE TABLE IF NOT EXISTS auth.users ( active BOOLEAN NOT NULL DEFAULT TRUE ); --- Create indexes for users table CREATE INDEX IF NOT EXISTS idx_users_username ON auth.users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email); @@ -40,18 +47,16 @@ CREATE TABLE IF NOT EXISTS auth.sessions ( user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, refresh_token TEXT NOT NULL UNIQUE, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - ip_address TEXT, -- to support IPv6 + ip_address TEXT, user_agent TEXT, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked BOOLEAN NOT NULL DEFAULT FALSE ); --- Create indexes for sessions table CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON auth.sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token ON auth.sessions(refresh_token); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON auth.sessions(expires_at); --- Create function for active sessions to use in index CREATE OR REPLACE FUNCTION auth.is_session_active(expires_at timestamptz) RETURNS boolean AS $$ BEGIN @@ -59,7 +64,6 @@ BEGIN END; $$ LANGUAGE plpgsql IMMUTABLE; --- Create index for active sessions with IMMUTABLE function CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked) WHERE NOT revoked AND auth.is_session_active(expires_at); @@ -75,11 +79,10 @@ CREATE TABLE IF NOT EXISTS auth.user_files ( UNIQUE(user_id, file_path) ); --- Create indexes for user_files CREATE INDEX IF NOT EXISTS idx_user_files_user_id ON auth.user_files(user_id); CREATE INDEX IF NOT EXISTS idx_user_files_file_id ON auth.user_files(file_id); --- User favorites table for cross-device synchronization +-- User favorites CREATE TABLE IF NOT EXISTS auth.user_favorites ( id SERIAL PRIMARY KEY, user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, @@ -89,16 +92,13 @@ CREATE TABLE IF NOT EXISTS auth.user_favorites ( UNIQUE(user_id, item_id, item_type) ); --- Create indexes for efficient querying CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON auth.user_favorites(user_id); CREATE INDEX IF NOT EXISTS idx_user_favorites_item_id ON auth.user_favorites(item_id); CREATE INDEX IF NOT EXISTS idx_user_favorites_type ON auth.user_favorites(item_type); CREATE INDEX IF NOT EXISTS idx_user_favorites_created ON auth.user_favorites(created_at); - --- Combined index for quick lookups by user and type CREATE INDEX IF NOT EXISTS idx_user_favorites_user_type ON auth.user_favorites(user_id, item_type); --- Table for recent files +-- Recent files CREATE TABLE IF NOT EXISTS auth.user_recent_files ( id SERIAL PRIMARY KEY, user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, @@ -108,21 +108,178 @@ CREATE TABLE IF NOT EXISTS auth.user_recent_files ( UNIQUE(user_id, item_id, item_type) ); --- Create indexes for efficient querying CREATE INDEX IF NOT EXISTS idx_user_recent_user_id ON auth.user_recent_files(user_id); CREATE INDEX IF NOT EXISTS idx_user_recent_item_id ON auth.user_recent_files(item_id); CREATE INDEX IF NOT EXISTS idx_user_recent_type ON auth.user_recent_files(item_type); CREATE INDEX IF NOT EXISTS idx_user_recent_accessed ON auth.user_recent_files(accessed_at); - --- Combined index for quick lookups by user and accessed time (for sorting) CREATE INDEX IF NOT EXISTS idx_user_recent_user_accessed ON auth.user_recent_files(user_id, accessed_at DESC); +COMMENT ON TABLE auth.users IS 'Stores user account information'; +COMMENT ON TABLE auth.sessions IS 'Stores user session information for refresh tokens'; +COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilization by users'; +COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization'; COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization'; -- NOTE: No default users are created. The first user to register through -- the admin setup wizard will become the administrator. -COMMENT ON TABLE auth.users IS 'Stores user account information'; -COMMENT ON TABLE auth.sessions IS 'Stores user session information for refresh tokens'; -COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilization by users'; -COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization'; \ No newline at end of file +-- ============================================================ +-- 2. CALDAV SCHEMA (RFC 4791) +-- ============================================================ +CREATE SCHEMA IF NOT EXISTS caldav; + +-- Calendars +CREATE TABLE IF NOT EXISTS caldav.calendars ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + owner_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + description TEXT, + color VARCHAR(9), -- #RRGGBB or #RRGGBBAA + is_public BOOLEAN NOT NULL DEFAULT FALSE, + ctag VARCHAR(64) NOT NULL DEFAULT '0', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_calendars_owner_id ON caldav.calendars(owner_id); + +-- Calendar events (VEVENT) +CREATE TABLE IF NOT EXISTS caldav.calendar_events ( + id UUID PRIMARY KEY, + calendar_id UUID NOT NULL REFERENCES caldav.calendars(id) ON DELETE CASCADE, + summary TEXT NOT NULL, + description TEXT, + location TEXT, + start_time TIMESTAMP WITH TIME ZONE NOT NULL, + end_time TIMESTAMP WITH TIME ZONE NOT NULL, + all_day BOOLEAN NOT NULL DEFAULT FALSE, + rrule TEXT, + ical_uid VARCHAR(255) NOT NULL, + ical_data TEXT, -- Full iCalendar data for round-trip fidelity + etag VARCHAR(64), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_calendar_events_calendar_id ON caldav.calendar_events(calendar_id); +CREATE INDEX IF NOT EXISTS idx_calendar_events_ical_uid ON caldav.calendar_events(ical_uid); +CREATE INDEX IF NOT EXISTS idx_calendar_events_time_range ON caldav.calendar_events(calendar_id, start_time, end_time); + +-- Calendar sharing +CREATE TABLE IF NOT EXISTS caldav.calendar_shares ( + id SERIAL PRIMARY KEY, + calendar_id UUID NOT NULL REFERENCES caldav.calendars(id) ON DELETE CASCADE, + user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + access_level VARCHAR(10) NOT NULL DEFAULT 'read', -- read, write, owner + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(calendar_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_shares_calendar_id ON caldav.calendar_shares(calendar_id); +CREATE INDEX IF NOT EXISTS idx_calendar_shares_user_id ON caldav.calendar_shares(user_id); + +-- Calendar custom properties +CREATE TABLE IF NOT EXISTS caldav.calendar_properties ( + id SERIAL PRIMARY KEY, + calendar_id UUID NOT NULL REFERENCES caldav.calendars(id) ON DELETE CASCADE, + property_name TEXT NOT NULL, + property_value TEXT NOT NULL, + UNIQUE(calendar_id, property_name) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_properties_calendar_id ON caldav.calendar_properties(calendar_id); + +COMMENT ON TABLE caldav.calendars IS 'CalDAV calendars for each user'; +COMMENT ON TABLE caldav.calendar_events IS 'Calendar events (VEVENT) stored with iCal data'; +COMMENT ON TABLE caldav.calendar_shares IS 'Calendar sharing permissions between users'; +COMMENT ON TABLE caldav.calendar_properties IS 'Custom WebDAV properties on calendars'; + +-- ============================================================ +-- 3. CARDDAV SCHEMA (RFC 6352) +-- ============================================================ +CREATE SCHEMA IF NOT EXISTS carddav; + +-- Address books +CREATE TABLE IF NOT EXISTS carddav.address_books ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + owner_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + description TEXT, + color VARCHAR(9), + is_public BOOLEAN NOT NULL DEFAULT FALSE, + ctag VARCHAR(64) NOT NULL DEFAULT '0', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_address_books_owner_id ON carddav.address_books(owner_id); + +-- Contacts +CREATE TABLE IF NOT EXISTS carddav.contacts ( + id UUID PRIMARY KEY, + address_book_id UUID NOT NULL REFERENCES carddav.address_books(id) ON DELETE CASCADE, + uid VARCHAR(255) NOT NULL, + full_name TEXT, + first_name TEXT, + last_name TEXT, + nickname TEXT, + organization TEXT, + title TEXT, + notes TEXT, + photo_url TEXT, + birthday DATE, + anniversary DATE, + email JSONB NOT NULL DEFAULT '[]', + phone JSONB NOT NULL DEFAULT '[]', + address JSONB NOT NULL DEFAULT '[]', + vcard TEXT, -- Full vCard data for round-trip fidelity + etag VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_contacts_address_book_id ON carddav.contacts(address_book_id); +CREATE INDEX IF NOT EXISTS idx_contacts_uid ON carddav.contacts(uid); +CREATE INDEX IF NOT EXISTS idx_contacts_full_name ON carddav.contacts(full_name); + +-- Address book sharing +CREATE TABLE IF NOT EXISTS carddav.address_book_shares ( + id SERIAL PRIMARY KEY, + address_book_id UUID NOT NULL REFERENCES carddav.address_books(id) ON DELETE CASCADE, + user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(address_book_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_address_book_shares_address_book_id ON carddav.address_book_shares(address_book_id); +CREATE INDEX IF NOT EXISTS idx_address_book_shares_user_id ON carddav.address_book_shares(user_id); + +-- Contact groups +CREATE TABLE IF NOT EXISTS carddav.contact_groups ( + id UUID PRIMARY KEY, + address_book_id UUID NOT NULL REFERENCES carddav.address_books(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_contact_groups_address_book_id ON carddav.contact_groups(address_book_id); + +-- Group memberships +CREATE TABLE IF NOT EXISTS carddav.group_memberships ( + id SERIAL PRIMARY KEY, + group_id UUID NOT NULL REFERENCES carddav.contact_groups(id) ON DELETE CASCADE, + contact_id UUID NOT NULL REFERENCES carddav.contacts(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(group_id, contact_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_memberships_group_id ON carddav.group_memberships(group_id); +CREATE INDEX IF NOT EXISTS idx_group_memberships_contact_id ON carddav.group_memberships(contact_id); + +COMMENT ON TABLE carddav.address_books IS 'CardDAV address books for each user'; +COMMENT ON TABLE carddav.contacts IS 'Contacts stored with vCard data for round-trip fidelity'; +COMMENT ON TABLE carddav.address_book_shares IS 'Address book sharing permissions between users'; +COMMENT ON TABLE carddav.contact_groups IS 'Contact groups within address books'; +COMMENT ON TABLE carddav.group_memberships IS 'Many-to-many relationship between contacts and groups'; diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index ecd3776c..ea14e3c3 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -120,9 +120,7 @@ impl CalDavAdapter { let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); match name_str { - s if s == "calendar-query" || s.ends_with(":calendar-query") => in_calendar_query = false, - s if s == "calendar-multiget" || s.ends_with(":calendar-multiget") => in_calendar_multiget = false, - s if s == "sync-collection" || s.ends_with(":sync-collection") => in_sync_collection = false, + // Don't reset report-type flags — they're needed at EOF for decision logic s if s == "prop" || s.ends_with(":prop") => in_prop = false, s if s == "filter" || s.ends_with(":filter") => in_filter = false, s if s == "time-range" || s.ends_with(":time-range") => { /* time-range end, attributes already parsed */ }, @@ -515,6 +513,73 @@ impl CalDavAdapter { Ok(()) } + /// Generate PROPFIND response for a single calendar collection + its events + pub fn generate_calendar_collection_propfind( + writer: W, + calendar: &CalendarDto, + events: &[CalendarEventDto], + request: &PropFindRequest, + base_href: &str, + depth: &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/"), + ])))?; + + // Write the calendar collection itself + Self::write_calendar_response(&mut xml_writer, calendar, request, base_href)?; + + // If depth > 0, include event resources + if depth != "0" { + for event in events { + // Write a basic DAV response for each event + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + let event_href = format!("{}{}.ics", base_href, event.ical_uid); + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&event_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")))?; + + // resourcetype (empty for non-collection) + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + + // getetag + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", event.id))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + + // getcontenttype + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("text/calendar; component=vevent")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + // getlastmodified + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Text(BytesText::new(&event.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + + 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(()) + } + /// Generate a response for calendar events pub fn generate_calendar_events_response( writer: W, diff --git a/src/application/adapters/caldav_adapter_test.rs b/src/application/adapters/caldav_adapter_test.rs new file mode 100644 index 00000000..d365dfac --- /dev/null +++ b/src/application/adapters/caldav_adapter_test.rs @@ -0,0 +1,286 @@ +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::collections::HashMap; + use chrono::{Utc, TimeZone}; + use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType}; + use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType, QualifiedName}; + use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto}; + + fn sample_calendar() -> CalendarDto { + CalendarDto { + id: "cal-001".to_string(), + name: "Personal".to_string(), + owner_id: "user-001".to_string(), + description: Some("My personal calendar".to_string()), + color: Some("#FF0000".to_string()), + is_public: false, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(), + custom_properties: HashMap::new(), + } + } + + fn sample_event() -> CalendarEventDto { + CalendarEventDto { + id: "evt-001".to_string(), + calendar_id: "cal-001".to_string(), + summary: "Team Meeting".to_string(), + description: Some("Weekly team sync".to_string()), + location: Some("Conference Room A".to_string()), + start_time: Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(), + end_time: Utc.with_ymd_and_hms(2025, 6, 15, 11, 0, 0).unwrap(), + all_day: false, + rrule: None, + ical_uid: "uid-evt-001@oxicloud".to_string(), + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + } + } + + // ======================== + // MKCALENDAR parsing tests + // ======================== + + #[test] + fn test_parse_mkcalendar_full() { + let xml = r#" + + + + Work Calendar + Work related events + #0000FF + + + "#; + + let result = CalDavAdapter::parse_mkcalendar(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse MKCALENDAR: {:?}", result.err()); + let (name, desc, color) = result.unwrap(); + assert_eq!(name, "Work Calendar"); + assert_eq!(desc, Some("Work related events".to_string())); + assert_eq!(color, Some("#0000FF".to_string())); + } + + #[test] + fn test_parse_mkcalendar_name_only() { + let xml = r#" + + + + Minimal Calendar + + + "#; + + let result = CalDavAdapter::parse_mkcalendar(Cursor::new(xml)); + assert!(result.is_ok()); + let (name, desc, color) = result.unwrap(); + assert_eq!(name, "Minimal Calendar"); + assert!(desc.is_none()); + assert!(color.is_none()); + } + + // ======================== + // REPORT parsing tests + // ======================== + + #[test] + fn test_parse_calendar_query_report() { + let xml = r#" + + + + + + + + + + + + + "#; + + let result = CalDavAdapter::parse_report(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse report: {:?}", result.err()); + + match result.unwrap() { + CalDavReportType::CalendarQuery { time_range, props } => { + assert!(time_range.is_some(), "Time range should be parsed"); + let (start, end) = time_range.unwrap(); + assert_eq!(start, Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()); + assert_eq!(end, Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap()); + assert!(!props.is_empty(), "Props should not be empty"); + } + other => panic!("Expected CalendarQuery, got {:?}", other), + } + } + + #[test] + fn test_parse_calendar_multiget_report() { + let xml = r#" + + + + + + /caldav/cal-001/evt-001.ics + /caldav/cal-001/evt-002.ics + "#; + + let result = CalDavAdapter::parse_report(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse multiget: {:?}", result.err()); + + match result.unwrap() { + CalDavReportType::CalendarMultiget { hrefs, props } => { + assert_eq!(hrefs.len(), 2); + assert_eq!(hrefs[0], "/caldav/cal-001/evt-001.ics"); + assert_eq!(hrefs[1], "/caldav/cal-001/evt-002.ics"); + assert!(!props.is_empty()); + } + other => panic!("Expected CalendarMultiget, got {:?}", other), + } + } + + // ======================== + // PROPFIND response tests + // ======================== + + #[test] + fn test_generate_calendars_propfind_response() { + let calendars = vec![sample_calendar()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_calendars_propfind_response( + &mut output, + &calendars, + &request, + "/caldav/", + ); + + assert!(result.is_ok(), "Failed to generate propfind response: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8 in response"); + assert!(xml_str.contains("multistatus"), "Response should contain multistatus element"); + assert!(xml_str.contains("Personal"), "Response should contain calendar name"); + assert!(xml_str.contains("cal-001"), "Response should contain calendar ID in href"); + } + + #[test] + fn test_generate_calendar_collection_propfind_depth_0() { + let calendar = sample_calendar(); + let events = vec![sample_event()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_calendar_collection_propfind( + &mut output, + &calendar, + &events, + &request, + "/caldav/cal-001", + "0", + ); + + assert!(result.is_ok(), "Failed to generate collection propfind: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should have multistatus"); + assert!(xml_str.contains("Personal"), "Should have calendar name"); + // Depth 0 should NOT include individual event resources + } + + #[test] + fn test_generate_calendar_collection_propfind_depth_1() { + let calendar = sample_calendar(); + let events = vec![sample_event()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_calendar_collection_propfind( + &mut output, + &calendar, + &events, + &request, + "/caldav/cal-001", + "1", + ); + + assert!(result.is_ok(), "Failed to generate depth-1 propfind: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should have multistatus"); + assert!(xml_str.contains("Personal"), "Should have calendar name"); + // Depth 1 should include event resources + assert!(xml_str.contains("evt-001"), "Depth 1 should include event resources"); + } + + // ======================== + // Calendar events response tests + // ======================== + + #[test] + fn test_generate_calendar_events_response() { + let events = vec![sample_event()]; + let report = CalDavReportType::CalendarQuery { + time_range: None, + props: vec![ + QualifiedName { + namespace: "DAV:".to_string(), + name: "getetag".to_string(), + }, + QualifiedName { + namespace: "urn:ietf:params:xml:ns:caldav".to_string(), + name: "calendar-data".to_string(), + }, + ], + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_calendar_events_response( + &mut output, + &events, + &report, + "/caldav/cal-001", + ); + + assert!(result.is_ok(), "Failed to generate events response: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should have multistatus"); + assert!(xml_str.contains("evt-001"), "Should reference event ID"); + assert!(xml_str.contains("BEGIN:VCALENDAR"), "Should contain iCal data"); + assert!(xml_str.contains("VEVENT"), "Should contain VEVENT component"); + assert!(xml_str.contains("Team Meeting"), "Should contain event summary"); + } + + #[test] + fn test_generate_empty_events_response() { + let events: Vec = vec![]; + let report = CalDavReportType::CalendarQuery { + time_range: None, + props: vec![], + }; + + let mut output = Vec::new(); + let result = CalDavAdapter::generate_calendar_events_response( + &mut output, + &events, + &report, + "/caldav/cal-001", + ); + + assert!(result.is_ok(), "Empty events should still produce valid response"); + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should have multistatus even for empty"); + } +} diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs new file mode 100644 index 00000000..bf8502e9 --- /dev/null +++ b/src/application/adapters/carddav_adapter.rs @@ -0,0 +1,624 @@ +/** + * CardDAV Adapter Module + * + * This module provides conversion between CardDAV protocol XML structures and + * OxiCloud domain objects. It handles parsing CardDAV request XML and generating + * CardDAV response XML according to RFC 6352. + */ + +use std::io::{Read, Write, BufReader}; +use quick_xml::{Reader, Writer, events::{Event, BytesStart, BytesEnd, BytesText}}; + +use crate::application::adapters::webdav_adapter::{ + WebDavAdapter, QualifiedName, PropFindType, PropFindRequest, Result, WebDavError, +}; +use crate::application::dtos::address_book_dto::AddressBookDto; +use crate::application::dtos::contact_dto::ContactDto; + +/// CardDAV report type +#[derive(Debug, PartialEq)] +pub enum CardDavReportType { + /// Addressbook-query report + AddressbookQuery { + props: Vec, + }, + /// Addressbook-multiget report + AddressbookMultiget { + hrefs: Vec, + props: Vec, + }, + /// Sync-collection report + SyncCollection { + sync_token: String, + props: Vec, + }, +} + +/// CardDAV adapter for XML parsing/generation +pub struct CardDavAdapter; + +impl CardDavAdapter { + /// Parse a REPORT XML request for CardDAV + pub fn parse_report(reader: R) -> Result { + let mut xml_reader = Reader::from_reader(BufReader::new(reader)); + xml_reader.config_mut().trim_text(true); + + let mut buffer = Vec::new(); + let mut in_addressbook_query = false; + let mut in_addressbook_multiget = false; + let mut in_sync_collection = false; + let mut in_prop = false; + let mut props = Vec::new(); + let mut hrefs = Vec::new(); + let mut sync_token = String::new(); + let mut in_href = false; + let mut in_sync_token = false; + + loop { + match xml_reader.read_event_into(&mut buffer) { + Ok(Event::Start(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); + + match name_str { + s if s == "addressbook-query" || s.ends_with(":addressbook-query") => in_addressbook_query = true, + s if s == "addressbook-multiget" || s.ends_with(":addressbook-multiget") => in_addressbook_multiget = true, + s if s == "sync-collection" || s.ends_with(":sync-collection") => in_sync_collection = true, + s if s == "prop" || s.ends_with(":prop") => in_prop = true, + s if s == "href" || s.ends_with(":href") => in_href = true, + s if s == "sync-token" || s.ends_with(":sync-token") => 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)); + }, + _ => {} + } + }, + Ok(Event::Text(e)) => { + let text = e.decode().unwrap_or_default(); + if in_href { + hrefs.push(text.to_string()); + } else if in_sync_token { + sync_token = text.to_string(); + } + }, + Ok(Event::End(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); + + match name_str { + s if s == "prop" || s.ends_with(":prop") => in_prop = false, + s if s == "href" || s.ends_with(":href") => in_href = false, + s if s == "sync-token" || s.ends_with(":sync-token") => in_sync_token = false, + _ => {} + } + }, + Ok(Event::Empty(ref e)) if in_prop => { + 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)); + }, + Ok(Event::Eof) => break, + Err(e) => return Err(WebDavError::XmlError(e)), + _ => (), + } + buffer.clear(); + } + + if in_addressbook_multiget || !hrefs.is_empty() { + Ok(CardDavReportType::AddressbookMultiget { hrefs, props }) + } else if in_sync_collection { + Ok(CardDavReportType::SyncCollection { sync_token, props }) + } else if in_addressbook_query { + Ok(CardDavReportType::AddressbookQuery { props }) + } else { + // Default + Ok(CardDavReportType::AddressbookQuery { props }) + } + } + + /// Generate a PROPFIND response listing address books + pub fn generate_addressbooks_propfind_response( + writer: W, + address_books: &[AddressBookDto], + request: &PropFindRequest, + base_href: &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:CR", "urn:ietf:params:xml:ns:carddav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ])))?; + + for book in address_books { + Self::write_addressbook_response(&mut xml_writer, book, request, &format!("{}{}/", base_href, book.id))?; + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(()) + } + + /// Generate PROPFIND for a single address book collection + contacts + pub fn generate_addressbook_collection_propfind( + writer: W, + address_book: &AddressBookDto, + contacts: &[ContactDto], + request: &PropFindRequest, + base_href: &str, + depth: &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:CR", "urn:ietf:params:xml:ns:carddav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ])))?; + + // Write the address book itself + Self::write_addressbook_response(&mut xml_writer, address_book, request, base_href)?; + + // Write contacts if depth > 0 + if depth != "0" { + for contact in contacts { + let contact_href = format!("{}{}.vcf", base_href, contact.uid); + Self::write_contact_response(&mut xml_writer, contact, &[], &contact_href)?; + } + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(()) + } + + /// Write address book properties + fn write_addressbook_response( + xml_writer: &mut Writer, + book: &AddressBookDto, + request: &PropFindRequest, + href: &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 => Self::write_addressbook_all_props(xml_writer, book)?, + PropFindType::PropName => Self::write_addressbook_prop_names(xml_writer)?, + PropFindType::Prop(props) => Self::write_addressbook_requested_props(xml_writer, book, 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(()) + } + + fn write_addressbook_all_props( + xml_writer: &mut Writer, + book: &AddressBookDto, + ) -> Result<()> { + // resourcetype: collection + addressbook + 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("CR:addressbook")))?; + 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(&book.name)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + // getlastmodified + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Text(BytesText::new(&book.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + + // getetag + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", book.id))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + + // getcontenttype + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("text/vcard")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + // supported-address-data + xml_writer.write_event(Event::Start(BytesStart::new("CR:supported-address-data")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("CR:address-data-type").with_attributes([ + ("content-type", "text/vcard"), + ("version", "3.0"), + ])))?; + xml_writer.write_event(Event::Empty(BytesStart::new("CR:address-data-type").with_attributes([ + ("content-type", "text/vcard"), + ("version", "4.0"), + ])))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:supported-address-data")))?; + + // addressbook-description + if let Some(ref desc) = book.description { + xml_writer.write_event(Event::Start(BytesStart::new("CR:addressbook-description")))?; + xml_writer.write_event(Event::Text(BytesText::new(desc)))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:addressbook-description")))?; + } + + // current-user-privilege-set + xml_writer.write_event(Event::Start(BytesStart::new("D:current-user-privilege-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-privilege-set")))?; + + Ok(()) + } + + fn write_addressbook_prop_names( + xml_writer: &mut Writer, + ) -> Result<()> { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("CR:supported-address-data")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("CR:addressbook-description")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:current-user-privilege-set")))?; + Ok(()) + } + + fn write_addressbook_requested_props( + xml_writer: &mut Writer, + book: &AddressBookDto, + 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("CR:addressbook")))?; + 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(&book.name)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + }, + ("DAV:", "getlastmodified") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Text(BytesText::new(&book.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + }, + ("DAV:", "getetag") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", book.id))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + }, + ("DAV:", "getcontenttype") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("text/vcard")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + }, + ("urn:ietf:params:xml:ns:carddav", "addressbook-description") => { + if let Some(ref desc) = book.description { + xml_writer.write_event(Event::Start(BytesStart::new("CR:addressbook-description")))?; + xml_writer.write_event(Event::Text(BytesText::new(desc)))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:addressbook-description")))?; + } else { + xml_writer.write_event(Event::Empty(BytesStart::new("CR:addressbook-description")))?; + } + }, + ("urn:ietf:params:xml:ns:carddav", "supported-address-data") => { + xml_writer.write_event(Event::Start(BytesStart::new("CR:supported-address-data")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("CR:address-data-type").with_attributes([ + ("content-type", "text/vcard"), + ("version", "3.0"), + ])))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:supported-address-data")))?; + }, + ("DAV:", "current-user-privilege-set") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:current-user-privilege-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-privilege-set")))?; + }, + _ => { + let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" { + format!("CR:{}", prop.name) + } else if prop.namespace == "DAV:" { + format!("D:{}", prop.name) + } else { + prop.name.clone() + }; + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + } + } + } + Ok(()) + } + + /// Generate response for contacts (for REPORT) + pub fn generate_contacts_response( + writer: W, + contacts: &[ContactDto], + vcards: &[(String, String)], // (uid, vcard_data) + report: &CardDavReportType, + base_href: &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:CR", "urn:ietf:params:xml:ns:carddav"), + ])))?; + + let props = match report { + CardDavReportType::AddressbookQuery { props } => props.clone(), + CardDavReportType::AddressbookMultiget { props, .. } => props.clone(), + CardDavReportType::SyncCollection { props, .. } => props.clone(), + }; + + for contact in contacts { + let href = format!("{}{}.vcf", base_href, contact.uid); + let vcard = vcards.iter() + .find(|(uid, _)| *uid == contact.uid) + .map(|(_, data)| data.as_str()) + .unwrap_or(""); + Self::write_contact_response(&mut xml_writer, contact, &props, &href)?; + // If address-data is requested, include vcard + if props.iter().any(|p| p.name == "address-data") || props.is_empty() { + // Already handled in write_contact_response + } + let _ = vcard; // suppress warning - used via contact_to_vcard fallback + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(()) + } + + /// Write a single contact response element + fn write_contact_response( + xml_writer: &mut Writer, + contact: &ContactDto, + props: &[QualifiedName], + href: &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")))?; + + if props.is_empty() { + // Return standard properties + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", contact.etag))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("text/vcard; charset=utf-8")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + + // Include vCard data + let vcard = contact_to_vcard(contact); + xml_writer.write_event(Event::Start(BytesStart::new("CR:address-data")))?; + xml_writer.write_event(Event::Text(BytesText::new(&vcard)))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:address-data")))?; + } else { + for prop in props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + }, + ("DAV:", "getetag") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", contact.etag))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?; + }, + ("DAV:", "getcontenttype") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + xml_writer.write_event(Event::Text(BytesText::new("text/vcard; charset=utf-8")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + }, + ("DAV:", "getlastmodified") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + xml_writer.write_event(Event::Text(BytesText::new(&contact.updated_at.to_rfc2822())))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + }, + ("urn:ietf:params:xml:ns:carddav", "address-data") => { + let vcard = contact_to_vcard(contact); + xml_writer.write_event(Event::Start(BytesStart::new("CR:address-data")))?; + xml_writer.write_event(Event::Text(BytesText::new(&vcard)))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:address-data")))?; + }, + _ => { + let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" { + format!("CR:{}", prop.name) + } else if prop.namespace == "DAV:" { + format!("D:{}", prop.name) + } else { + prop.name.clone() + }; + xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + } + } + } + } + + 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(()) + } + + /// Parse a MKCOL XML request for making an address book + pub fn parse_mkaddressbook(reader: R) -> Result<(String, Option, Option)> { + let mut xml_reader = Reader::from_reader(BufReader::new(reader)); + xml_reader.config_mut().trim_text(true); + + let mut buffer = Vec::new(); + let mut in_set = false; + let mut in_prop = false; + let mut in_displayname = false; + let mut in_description = false; + let mut in_color = false; + + let mut displayname = String::new(); + let mut description = None; + let mut color = None; + + loop { + match xml_reader.read_event_into(&mut buffer) { + Ok(Event::Start(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); + + match name_str { + s if s == "set" || s.ends_with(":set") => in_set = true, + s if in_set && (s == "prop" || s.ends_with(":prop")) => in_prop = true, + s if in_prop && (s == "displayname" || s.ends_with(":displayname")) => in_displayname = true, + s if in_prop && (s == "addressbook-description" || s.ends_with(":addressbook-description")) => in_description = true, + s if in_prop && (s.contains("color")) => in_color = true, + _ => {} + } + }, + Ok(Event::Text(e)) => { + let text = e.decode().unwrap_or_default(); + if in_displayname { displayname = text.to_string(); } + else if in_description { description = Some(text.to_string()); } + else if in_color { color = Some(text.to_string()); } + }, + Ok(Event::End(ref e)) => { + let name = e.name(); + let name_str = std::str::from_utf8(name.as_ref()).unwrap_or(""); + match name_str { + s if s == "set" || s.ends_with(":set") => in_set = false, + s if s == "prop" || s.ends_with(":prop") => in_prop = false, + s if s == "displayname" || s.ends_with(":displayname") => in_displayname = false, + s if s.contains("description") => in_description = false, + s if s.contains("color") => in_color = false, + _ => {} + } + }, + Ok(Event::Eof) => break, + Err(e) => return Err(WebDavError::XmlError(e)), + _ => (), + } + buffer.clear(); + } + + if displayname.is_empty() { + displayname = format!("Address Book {}", uuid::Uuid::new_v4()); + } + + Ok((displayname, description, color)) + } +} + +/// Convert a ContactDto to vCard 3.0 format +pub fn contact_to_vcard(contact: &ContactDto) -> String { + let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n"); + + vcard.push_str(&format!("UID:{}\r\n", contact.uid)); + + if let (Some(ref last), Some(ref first)) = (&contact.last_name, &contact.first_name) { + vcard.push_str(&format!("N:{};{};;;\r\n", last, first)); + } else if let Some(ref last) = &contact.last_name { + vcard.push_str(&format!("N:{};;;;\r\n", last)); + } else if let Some(ref first) = &contact.first_name { + vcard.push_str(&format!("N:;{};;;\r\n", first)); + } + + if let Some(ref fn_name) = contact.full_name { + vcard.push_str(&format!("FN:{}\r\n", fn_name)); + } else { + // FN is mandatory in vCard 3.0 + let fn_name = format!("{} {}", + contact.first_name.as_deref().unwrap_or(""), + contact.last_name.as_deref().unwrap_or(""), + ).trim().to_string(); + if !fn_name.is_empty() { + vcard.push_str(&format!("FN:{}\r\n", fn_name)); + } else { + vcard.push_str("FN:Unknown\r\n"); + } + } + + if let Some(ref nickname) = contact.nickname { + vcard.push_str(&format!("NICKNAME:{}\r\n", nickname)); + } + + for email in &contact.email { + vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n", email.r#type.to_uppercase(), email.email)); + } + + for phone in &contact.phone { + vcard.push_str(&format!("TEL;TYPE={}:{}\r\n", phone.r#type.to_uppercase(), phone.number)); + } + + for addr in &contact.address { + let adr = format!(";;{};{};{};{};{}", + addr.street.as_deref().unwrap_or(""), + addr.city.as_deref().unwrap_or(""), + addr.state.as_deref().unwrap_or(""), + addr.postal_code.as_deref().unwrap_or(""), + addr.country.as_deref().unwrap_or(""), + ); + vcard.push_str(&format!("ADR;TYPE={}:{}\r\n", addr.r#type.to_uppercase(), adr)); + } + + if let Some(ref org) = contact.organization { + vcard.push_str(&format!("ORG:{}\r\n", org)); + } + if let Some(ref title) = contact.title { + vcard.push_str(&format!("TITLE:{}\r\n", title)); + } + if let Some(ref notes) = contact.notes { + vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n"))); + } + if let Some(ref bday) = contact.birthday { + vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d"))); + } + if let Some(ref photo) = contact.photo_url { + vcard.push_str(&format!("PHOTO;VALUE=URI:{}\r\n", photo)); + } + + vcard.push_str(&format!("REV:{}\r\n", contact.updated_at.format("%Y%m%dT%H%M%SZ"))); + vcard.push_str("END:VCARD\r\n"); + + vcard +} diff --git a/src/application/adapters/carddav_adapter_test.rs b/src/application/adapters/carddav_adapter_test.rs new file mode 100644 index 00000000..b7c9bf52 --- /dev/null +++ b/src/application/adapters/carddav_adapter_test.rs @@ -0,0 +1,432 @@ +#[cfg(test)] +mod tests { + use std::io::Cursor; + use chrono::{Utc, TimeZone, NaiveDate}; + use crate::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType, contact_to_vcard}; + use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType, QualifiedName}; + use crate::application::dtos::address_book_dto::AddressBookDto; + use crate::application::dtos::contact_dto::{ContactDto, EmailDto, PhoneDto, AddressDto}; + + fn sample_address_book() -> AddressBookDto { + AddressBookDto { + id: "ab-001".to_string(), + name: "My Contacts".to_string(), + owner_id: "user-001".to_string(), + description: Some("Personal address book".to_string()), + color: Some("#00FF00".to_string()), + is_public: false, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(), + } + } + + fn sample_contact() -> ContactDto { + ContactDto { + id: "contact-001".to_string(), + address_book_id: "ab-001".to_string(), + uid: "uid-contact-001@oxicloud".to_string(), + full_name: Some("John Doe".to_string()), + first_name: Some("John".to_string()), + last_name: Some("Doe".to_string()), + nickname: Some("Johnny".to_string()), + email: vec![ + EmailDto { + email: "john@example.com".to_string(), + r#type: "work".to_string(), + is_primary: true, + }, + EmailDto { + email: "john.doe@personal.com".to_string(), + r#type: "home".to_string(), + is_primary: false, + }, + ], + phone: vec![ + PhoneDto { + number: "+1-555-0100".to_string(), + r#type: "cell".to_string(), + is_primary: true, + }, + ], + address: vec![ + AddressDto { + street: Some("123 Main St".to_string()), + city: Some("Springfield".to_string()), + state: Some("IL".to_string()), + postal_code: Some("62701".to_string()), + country: Some("US".to_string()), + r#type: "home".to_string(), + is_primary: true, + }, + ], + organization: Some("Acme Corp".to_string()), + title: Some("Software Engineer".to_string()), + notes: Some("Met at conference".to_string()), + photo_url: None, + birthday: Some(NaiveDate::from_ymd_opt(1990, 5, 15).unwrap()), + anniversary: None, + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2025, 3, 10, 8, 30, 0).unwrap(), + etag: "etag-abc123".to_string(), + } + } + + fn sample_contact_minimal() -> ContactDto { + ContactDto { + id: "contact-002".to_string(), + address_book_id: "ab-001".to_string(), + uid: "uid-contact-002@oxicloud".to_string(), + full_name: Some("Jane Smith".to_string()), + first_name: None, + last_name: None, + nickname: None, + email: vec![], + phone: vec![], + address: vec![], + organization: None, + title: None, + notes: None, + photo_url: None, + birthday: None, + anniversary: None, + created_at: Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap(), + etag: "etag-def456".to_string(), + } + } + + // ======================== + // vCard generation tests + // ======================== + + #[test] + fn test_contact_to_vcard_full() { + let contact = sample_contact(); + let vcard = contact_to_vcard(&contact); + + assert!(vcard.starts_with("BEGIN:VCARD"), "vCard should start with BEGIN:VCARD"); + assert!(vcard.contains("VERSION:3.0"), "Should be vCard 3.0"); + assert!(vcard.contains("FN:John Doe"), "Should contain full name"); + assert!(vcard.contains("N:Doe;John"), "Should contain structured name"); + assert!(vcard.contains("NICKNAME:Johnny"), "Should contain nickname"); + assert!(vcard.contains("john@example.com"), "Should contain email"); + assert!(vcard.contains("+1-555-0100"), "Should contain phone number"); + assert!(vcard.contains("ORG:Acme Corp"), "Should contain organization"); + assert!(vcard.contains("TITLE:Software Engineer"), "Should contain title"); + assert!(vcard.contains("NOTE:Met at conference"), "Should contain notes"); + assert!(vcard.contains("BDAY:1990-05-15"), "Should contain birthday"); + assert!(vcard.contains("UID:uid-contact-001@oxicloud"), "Should contain UID"); + assert!(vcard.ends_with("END:VCARD\r\n") || vcard.trim_end().ends_with("END:VCARD"), + "vCard should end with END:VCARD"); + } + + #[test] + fn test_contact_to_vcard_minimal() { + let contact = sample_contact_minimal(); + let vcard = contact_to_vcard(&contact); + + assert!(vcard.contains("BEGIN:VCARD"), "Should start correctly"); + assert!(vcard.contains("VERSION:3.0"), "Should be vCard 3.0"); + assert!(vcard.contains("FN:Jane Smith"), "Should have full name"); + assert!(vcard.contains("UID:uid-contact-002@oxicloud"), "Should have UID"); + assert!(vcard.contains("END:VCARD"), "Should end correctly"); + // Should NOT contain optional fields + assert!(!vcard.contains("NICKNAME:"), "Should not have nickname"); + assert!(!vcard.contains("ORG:"), "Should not have org"); + assert!(!vcard.contains("TITLE:"), "Should not have title"); + assert!(!vcard.contains("BDAY:"), "Should not have birthday"); + } + + // ======================== + // MKADDRESSBOOK parsing tests + // ======================== + + #[test] + fn test_parse_mkaddressbook_full() { + let xml = r#" + + + + Work Contacts + Colleagues and clients + + + "#; + + let result = CardDavAdapter::parse_mkaddressbook(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse mkaddressbook: {:?}", result.err()); + let (name, desc, color) = result.unwrap(); + assert_eq!(name, "Work Contacts"); + assert_eq!(desc, Some("Colleagues and clients".to_string())); + assert!(color.is_none()); + } + + #[test] + fn test_parse_mkaddressbook_name_only() { + let xml = r#" + + + + Simple Book + + + "#; + + let result = CardDavAdapter::parse_mkaddressbook(Cursor::new(xml)); + assert!(result.is_ok()); + let (name, desc, color) = result.unwrap(); + assert_eq!(name, "Simple Book"); + assert!(desc.is_none()); + assert!(color.is_none()); + } + + // ======================== + // REPORT parsing tests + // ======================== + + #[test] + fn test_parse_addressbook_query_report() { + let xml = r#" + + + + + + "#; + + let result = CardDavAdapter::parse_report(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse addressbook-query: {:?}", result.err()); + + match result.unwrap() { + CardDavReportType::AddressbookQuery { props } => { + assert!(!props.is_empty(), "Props should not be empty"); + } + other => panic!("Expected AddressbookQuery, got {:?}", other), + } + } + + #[test] + fn test_parse_addressbook_multiget_report() { + let xml = r#" + + + + + + /carddav/ab-001/contact-001.vcf + /carddav/ab-001/contact-002.vcf + /carddav/ab-001/contact-003.vcf + "#; + + let result = CardDavAdapter::parse_report(Cursor::new(xml)); + assert!(result.is_ok(), "Failed to parse multiget: {:?}", result.err()); + + match result.unwrap() { + CardDavReportType::AddressbookMultiget { hrefs, props } => { + assert_eq!(hrefs.len(), 3, "Should have 3 hrefs"); + assert_eq!(hrefs[0], "/carddav/ab-001/contact-001.vcf"); + assert_eq!(hrefs[2], "/carddav/ab-001/contact-003.vcf"); + assert!(!props.is_empty()); + } + other => panic!("Expected AddressbookMultiget, got {:?}", other), + } + } + + // ======================== + // PROPFIND response tests + // ======================== + + #[test] + fn test_generate_addressbooks_propfind_response() { + let addressbooks = vec![sample_address_book()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CardDavAdapter::generate_addressbooks_propfind_response( + &mut output, + &addressbooks, + &request, + "/carddav", + ); + + assert!(result.is_ok(), "Failed to generate propfind response: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should contain multistatus"); + assert!(xml_str.contains("My Contacts"), "Should contain address book name"); + assert!(xml_str.contains("ab-001"), "Should contain address book ID in href"); + } + + #[test] + fn test_generate_addressbook_collection_propfind_depth_0() { + let addressbook = sample_address_book(); + let contacts = vec![sample_contact()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CardDavAdapter::generate_addressbook_collection_propfind( + &mut output, + &addressbook, + &contacts, + &request, + "/carddav/ab-001", + "0", + ); + + assert!(result.is_ok(), "Failed to generate depth-0 propfind: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should contain multistatus"); + assert!(xml_str.contains("My Contacts"), "Should contain address book name"); + } + + #[test] + fn test_generate_addressbook_collection_propfind_depth_1() { + let addressbook = sample_address_book(); + let contacts = vec![sample_contact(), sample_contact_minimal()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CardDavAdapter::generate_addressbook_collection_propfind( + &mut output, + &addressbook, + &contacts, + &request, + "/carddav/ab-001", + "1", + ); + + assert!(result.is_ok(), "Failed to generate depth-1 propfind: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should contain multistatus"); + assert!(xml_str.contains("My Contacts"), "Should contain address book name"); + // Depth 1 should include contact resources + assert!(xml_str.contains("contact-001"), "Should include contact-001"); + assert!(xml_str.contains("contact-002"), "Should include contact-002"); + } + + // ======================== + // Contacts response tests + // ======================== + + #[test] + fn test_generate_contacts_response() { + let contacts = vec![sample_contact()]; + let vcards = vec![ + ("contact-001".to_string(), contact_to_vcard(&sample_contact())), + ]; + let report = CardDavReportType::AddressbookQuery { + props: vec![ + QualifiedName { + namespace: "DAV:".to_string(), + name: "getetag".to_string(), + }, + QualifiedName { + namespace: "urn:ietf:params:xml:ns:carddav".to_string(), + name: "address-data".to_string(), + }, + ], + }; + + let mut output = Vec::new(); + let result = CardDavAdapter::generate_contacts_response( + &mut output, + &contacts, + &vcards, + &report, + "/carddav/ab-001", + ); + + assert!(result.is_ok(), "Failed to generate contacts response: {:?}", result.err()); + + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should contain multistatus"); + assert!(xml_str.contains("contact-001"), "Should reference contact"); + assert!(xml_str.contains("etag-abc123"), "Should contain etag"); + } + + #[test] + fn test_generate_empty_contacts_response() { + let contacts: Vec = vec![]; + let vcards: Vec<(String, String)> = vec![]; + let report = CardDavReportType::AddressbookQuery { + props: vec![], + }; + + let mut output = Vec::new(); + let result = CardDavAdapter::generate_contacts_response( + &mut output, + &contacts, + &vcards, + &report, + "/carddav/ab-001", + ); + + assert!(result.is_ok(), "Empty contacts should produce valid response"); + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("multistatus"), "Should have multistatus"); + } + + // ======================== + // Multiple address books test + // ======================== + + #[test] + fn test_generate_multiple_addressbooks() { + let mut ab2 = sample_address_book(); + ab2.id = "ab-002".to_string(); + ab2.name = "Work Contacts".to_string(); + + let addressbooks = vec![sample_address_book(), ab2]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + let result = CardDavAdapter::generate_addressbooks_propfind_response( + &mut output, + &addressbooks, + &request, + "/carddav/", + ); + + assert!(result.is_ok()); + let xml_str = String::from_utf8(output).expect("Invalid UTF-8"); + assert!(xml_str.contains("My Contacts"), "Should contain first address book"); + assert!(xml_str.contains("Work Contacts"), "Should contain second address book"); + assert!(xml_str.contains("ab-001"), "Should have first ID"); + assert!(xml_str.contains("ab-002"), "Should have second ID"); + } + + // ======================== + // vCard edge cases + // ======================== + + #[test] + fn test_contact_to_vcard_with_multiple_emails() { + let contact = sample_contact(); + let vcard = contact_to_vcard(&contact); + + // Should contain both emails + assert!(vcard.contains("john@example.com"), "Should have work email"); + assert!(vcard.contains("john.doe@personal.com"), "Should have personal email"); + } + + #[test] + fn test_contact_to_vcard_address_formatting() { + let contact = sample_contact(); + let vcard = contact_to_vcard(&contact); + + // vCard ADR format: ;;street;city;state;postal;country + assert!(vcard.contains("123 Main St"), "Should have street"); + assert!(vcard.contains("Springfield"), "Should have city"); + assert!(vcard.contains("62701"), "Should have postal code"); + } +} diff --git a/src/application/adapters/mod.rs b/src/application/adapters/mod.rs index 90aa8e7c..cccc913b 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -2,3 +2,9 @@ pub mod webdav_adapter; pub mod caldav_adapter; +pub mod carddav_adapter; + +#[cfg(test)] +mod caldav_adapter_test; +#[cfg(test)] +mod carddav_adapter_test; diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index 9e05c1d4..8fdf51ee 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -75,4 +75,15 @@ pub trait CalendarUseCase: Send + Sync + 'static { start: DateTime, end: DateTime ) -> Result, DomainError>; + + // ─── User-contextualized variants (for CalDAV protocol handler) ── + async fn create_calendar_for_user(&self, calendar: CreateCalendarDto, user_id: &str) -> Result; + async fn update_calendar_for_user(&self, calendar_id: &str, update: UpdateCalendarDto, user_id: &str) -> Result; + async fn delete_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>; + async fn get_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result; + async fn list_my_calendars_for_user(&self, user_id: &str) -> Result, DomainError>; + async fn list_events_for_user(&self, calendar_id: &str, limit: Option, offset: Option, user_id: &str) -> Result, DomainError>; + async fn get_events_in_range_for_user(&self, calendar_id: &str, start: DateTime, end: DateTime, user_id: &str) -> Result, DomainError>; + async fn create_event_from_ical_for_user(&self, event: CreateEventICalDto, user_id: &str) -> Result; + async fn delete_event_for_user(&self, event_id: &str, user_id: &str) -> Result<(), DomainError>; } \ No newline at end of file diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index ff7be3fc..00552981 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -324,4 +324,80 @@ impl CalendarUseCase for CalendarService { self.calendar_storage.get_events_in_time_range(calendar_id, &start, &end).await } + + // ─── User-contextualized variants (for CalDAV protocol handler) ── + + async fn create_calendar_for_user(&self, calendar: CreateCalendarDto, user_id: &str) -> Result { + self.calendar_storage.create_calendar(calendar, user_id).await + } + + async fn update_calendar_for_user(&self, calendar_id: &str, update: UpdateCalendarDto, user_id: &str) -> Result { + let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; + if !has_access { + return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to update this calendar")); + } + self.calendar_storage.update_calendar(calendar_id, update).await + } + + async fn delete_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> { + let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; + if !has_access { + return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to delete this calendar")); + } + self.calendar_storage.delete_calendar(calendar_id).await + } + + async fn get_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result { + let calendar = self.calendar_storage.get_calendar(calendar_id).await?; + let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?; + if !has_access && !calendar.is_public { + return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to view this calendar")); + } + Ok(calendar) + } + + async fn list_my_calendars_for_user(&self, user_id: &str) -> Result, DomainError> { + self.calendar_storage.list_calendars_by_owner(user_id).await + } + + async fn list_events_for_user(&self, calendar_id: &str, limit: Option, offset: Option, user_id: &str) -> 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 limit.is_some() || offset.is_some() { + let limit = limit.unwrap_or(100); + let offset = offset.unwrap_or(0); + self.calendar_storage.list_events_by_calendar_paginated(calendar_id, limit, offset).await + } else { + self.calendar_storage.list_events_by_calendar(calendar_id).await + } + } + + async fn get_events_in_range_for_user(&self, calendar_id: &str, start: DateTime, end: DateTime, user_id: &str) -> 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")); + } + self.calendar_storage.get_events_in_time_range(calendar_id, &start, &end).await + } + + async fn create_event_from_ical_for_user(&self, event: CreateEventICalDto, user_id: &str) -> Result { + let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?; + if !has_access { + return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to add events to this calendar")); + } + self.calendar_storage.create_event_from_ical(event).await + } + + async fn delete_event_for_user(&self, event_id: &str, user_id: &str) -> Result<(), DomainError> { + let event = self.calendar_storage.get_event(event_id).await?; + let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?; + if !has_access { + return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to delete events in this calendar")); + } + self.calendar_storage.delete_event(event_id).await + } } \ No newline at end of file diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 86abdc85..0363c8b6 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -198,6 +198,14 @@ impl FileWritePort for MockFileRepository { unimplemented!() } + async fn rename_file( + &self, + _file_id: &str, + _new_name: &str, + ) -> std::result::Result { + unimplemented!() + } + async fn delete_file(&self, _id: &str) -> std::result::Result<(), DomainError> { Ok(()) } diff --git a/src/common/di.rs b/src/common/di.rs index 73b22000..28da05cb 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -560,11 +560,11 @@ impl AppServiceFactory { core.zip_service = zip_service; // 10. Assemble final AppState - let app_state = AppState { + let mut app_state = AppState { core, repositories: repos, applications: apps, - db_pool, + db_pool: db_pool.clone(), auth_service: auth_services, trash_service, share_service, @@ -573,7 +573,53 @@ impl AppServiceFactory { storage_usage_service, calendar_service: None, contact_service: None, + calendar_use_case: None, + addressbook_use_case: None, + contact_use_case: None, }; + + // 11. Wire CalDAV/CardDAV services when database is available + if let Some(ref pool) = db_pool { + // CalDAV + let calendar_repo: Arc = Arc::new( + crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()) + ); + let event_repo: Arc = Arc::new( + crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(pool.clone()) + ); + let calendar_storage = Arc::new( + crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new( + calendar_repo, + event_repo, + ) + ); + let calendar_service = Arc::new( + crate::application::services::calendar_service::CalendarService::new(calendar_storage) + ); + app_state.calendar_use_case = Some(calendar_service as Arc); + + // CardDAV + let address_book_repo: Arc = Arc::new( + crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()) + ); + let contact_repo: Arc = Arc::new( + crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()) + ); + let group_repo: Arc = Arc::new( + crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(pool.clone()) + ); + let contact_storage = Arc::new( + crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( + address_book_repo, + contact_repo, + group_repo, + ) + ); + app_state.addressbook_use_case = Some(contact_storage.clone() as Arc); + app_state.contact_use_case = Some(contact_storage as Arc); + + tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); + } Ok(app_state) } @@ -650,6 +696,9 @@ pub struct AppState { pub storage_usage_service: Option>, pub calendar_service: Option>, pub contact_service: Option>, + pub calendar_use_case: Option>, + pub addressbook_use_case: Option>, + pub contact_use_case: Option>, } impl Default for AppState { @@ -785,6 +834,9 @@ impl Default for AppState { storage_usage_service: None, calendar_service: None, contact_service: None, + calendar_use_case: None, + addressbook_use_case: None, + contact_use_case: None, } } } @@ -808,6 +860,9 @@ impl AppState { storage_usage_service: None, calendar_service: None, contact_service: None, + calendar_use_case: None, + addressbook_use_case: None, + contact_use_case: None, } } @@ -910,6 +965,21 @@ impl AppState { self } + pub fn with_calendar_use_case(mut self, calendar_use_case: Arc) -> Self { + self.calendar_use_case = Some(calendar_use_case); + self + } + + pub fn with_addressbook_use_case(mut self, addressbook_use_case: Arc) -> Self { + self.addressbook_use_case = Some(addressbook_use_case); + self + } + + pub fn with_contact_use_case(mut self, contact_use_case: Arc) -> Self { + self.contact_use_case = Some(contact_use_case); + self + } + pub fn with_zip_service(mut self, zip_service: Arc) -> Self { self.core.zip_service = zip_service; self diff --git a/src/infrastructure/adapters/contact_storage_adapter.rs b/src/infrastructure/adapters/contact_storage_adapter.rs index 2443f053..dfefcace 100644 --- a/src/infrastructure/adapters/contact_storage_adapter.rs +++ b/src/infrastructure/adapters/contact_storage_adapter.rs @@ -344,25 +344,90 @@ impl ContactUseCase for ContactStorageAdapter { // Check write access self.check_write_access(&address_book_id, &dto.user_id).await?; - // Parse vCard - for now, create a basic contact with the raw vCard + // Parse vCard fields let now = chrono::Utc::now(); + let vcard_data = &dto.vcard; + + let mut uid: Option = None; + let mut full_name: Option = None; + let mut first_name: Option = None; + let mut last_name: Option = None; + let mut nickname: Option = None; + let mut organization: Option = None; + let mut title: Option = None; + let mut notes: Option = None; + let mut emails: Vec = Vec::new(); + let mut phones: Vec = Vec::new(); + + for line in vcard_data.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("UID:") { + uid = Some(trimmed[4..].trim().to_string()); + } else if trimmed.starts_with("FN:") { + full_name = Some(trimmed[3..].trim().to_string()); + } else if trimmed.starts_with("N:") { + let parts: Vec<&str> = trimmed[2..].split(';').collect(); + if parts.len() >= 2 { + last_name = Some(parts[0].trim().to_string()).filter(|s| !s.is_empty()); + first_name = Some(parts[1].trim().to_string()).filter(|s| !s.is_empty()); + } + } else if trimmed.starts_with("NICKNAME:") { + nickname = Some(trimmed[9..].trim().to_string()); + } else if trimmed.starts_with("ORG:") { + organization = Some(trimmed[4..].trim().to_string()); + } else if trimmed.starts_with("TITLE:") { + title = Some(trimmed[6..].trim().to_string()); + } else if trimmed.starts_with("NOTE:") { + notes = Some(trimmed[5..].trim().to_string()); + } else if trimmed.starts_with("EMAIL") { + if let Some(value) = trimmed.split(':').nth(1) { + if !value.is_empty() { + let email_type = if trimmed.contains("TYPE=HOME") { "home" } + else if trimmed.contains("TYPE=WORK") { "work" } + else { "other" }; + emails.push(Email { + email: value.trim().to_string(), + r#type: email_type.to_string(), + is_primary: emails.is_empty(), + }); + } + } + } else if trimmed.starts_with("TEL") { + if let Some(value) = trimmed.split(':').nth(1) { + if !value.is_empty() { + let phone_type = if trimmed.contains("TYPE=CELL") || trimmed.contains("TYPE=MOBILE") { "mobile" } + else if trimmed.contains("TYPE=HOME") { "home" } + else if trimmed.contains("TYPE=WORK") { "work" } + else { "other" }; + phones.push(Phone { + number: value.trim().to_string(), + r#type: phone_type.to_string(), + is_primary: phones.is_empty(), + }); + } + } + } + } + + let contact_uid = uid.unwrap_or_else(|| format!("{}@oxicloud", Uuid::new_v4())); + let contact = Contact::from_raw( Uuid::new_v4(), address_book_id, - format!("{}@oxicloud", Uuid::new_v4()), - Some("Imported Contact".to_string()), - None, - None, - None, - Vec::new(), - Vec::new(), - Vec::new(), - None, - None, - None, - None, - None, - None, + contact_uid, + full_name, + first_name, + last_name, + nickname, + emails, + phones, + Vec::new(), // addresses — simplified for now + organization, + title, + notes, + None, // photo_url + None, // birthday + None, // anniversary dto.vcard, Uuid::new_v4().to_string(), now, diff --git a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs index 96b46a72..114338b0 100644 --- a/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs +++ b/src/infrastructure/repositories/pg/calendar_event_pg_repository.rs @@ -112,10 +112,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { start: &DateTime, end: &DateTime ) -> CalendarEventRepositoryResult> { - // Para una implementación real, necesitaríamos construir objetos CalendarEvent con un constructor adecuado - // Esta es una implementación simplificada para mostrar cómo evitar las macros query_as! - - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, calendar_id, summary, description, location, @@ -139,15 +136,25 @@ impl CalendarEventRepository for CalendarEventPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get events in time range: {}", e)))?; - // En un escenario real, construiríamos objetos CalendarEvent para cada fila - // Aquí solo devolvemos un vector vacío como ejemplo - - let events = Vec::new(); - // Código para construir eventos desde rows iría aquí - // Por ejemplo: - // for row in rows { - // events.push(CalendarEvent::new(...)) - // } + 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) } @@ -193,8 +200,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { } async fn list_events_by_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult> { - // Usamos sqlx::query en lugar de query_as para evitar la necesidad de verificar la base de datos en tiempo de compilación - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, calendar_id, summary, description, location, @@ -210,20 +216,25 @@ impl CalendarEventRepository for CalendarEventPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get events by calendar: {}", e)))?; - // En una implementación real, mapearíamos cada fila a un objeto CalendarEvent - // Este es un ejemplo simplificado que devuelve una lista vacía - let events = Vec::new(); - - // Ejemplo de cómo sería el mapeo real: - // for row in rows { - // let event = CalendarEvent::new( - // row.get("id"), - // row.get("calendar_id"), - // row.get("summary"), - // // ... otros campos - // ); - // events.push(event); - // } + 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) } @@ -231,7 +242,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { async fn find_events_by_summary(&self, calendar_id: &Uuid, summary: &str) -> CalendarEventRepositoryResult> { let search_pattern = format!("%{}%", summary); - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, calendar_id, summary, description, location, @@ -248,20 +259,31 @@ impl CalendarEventRepository for CalendarEventPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to find events by summary: {}", e)))?; - // En una implementación real, mapearíamos cada fila a un objeto CalendarEvent - // Este es un ejemplo simplificado que devuelve una lista vacía - let events = Vec::new(); - - // Aquí iría el código para construir eventos desde rows - // for row in rows { - // events.push(CalendarEvent::new(...)); - // } + 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 find_event_by_ical_uid(&self, calendar_id: &Uuid, ical_uid: &str) -> CalendarEventRepositoryResult> { - let _row_opt = sqlx::query( + let row_opt = sqlx::query( r#" SELECT id, calendar_id, summary, description, location, @@ -277,9 +299,27 @@ impl CalendarEventRepository for CalendarEventPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get calendar event by UID: {}", e)))?; - // En una implementación real, crearíamos un objeto CalendarEvent a partir de row_opt - // Por simplicidad, devolvemos None como ejemplo - Ok(None) + match row_opt { + Some(row) => { + 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)))?; + Ok(Some(event)) + } + None => Ok(None), + } } async fn count_events_in_calendar(&self, calendar_id: &Uuid) -> CalendarEventRepositoryResult { @@ -319,8 +359,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { limit: i64, offset: i64 ) -> CalendarEventRepositoryResult> { - // Usamos sqlx::query en lugar de query_as para evitar la necesidad de verificar la base de datos en tiempo de compilación - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, calendar_id, summary, description, location, @@ -339,20 +378,25 @@ impl CalendarEventRepository for CalendarEventPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get paginated events by calendar: {}", e)))?; - // En una implementación real, mapearíamos cada fila a un objeto CalendarEvent - // Este es un ejemplo simplificado que devuelve una lista vacía - let events = Vec::new(); - - // Ejemplo de cómo sería el mapeo real: - // for row in rows { - // let event = CalendarEvent::new( - // row.get("id"), - // row.get("calendar_id"), - // row.get("summary"), - // // ... otros campos - // ); - // events.push(event); - // } + 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) } @@ -363,7 +407,7 @@ impl CalendarEventRepository for CalendarEventPgRepository { start: &DateTime, end: &DateTime ) -> CalendarEventRepositoryResult> { - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, calendar_id, summary, description, location, @@ -384,28 +428,25 @@ impl CalendarEventRepository for CalendarEventPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to find recurring events in range: {}", e)))?; - // En una implementación real, mapearíamos cada fila a un objeto CalendarEvent - // Por simplicidad, devolvemos una lista vacía de eventos - let events = Vec::new(); - - // Aquí iría el código para construir los objetos CalendarEvent - // for row in rows { - // events.push(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") - // ).unwrap()); - // } + 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) } diff --git a/src/infrastructure/repositories/pg/contact_group_pg_repository.rs b/src/infrastructure/repositories/pg/contact_group_pg_repository.rs new file mode 100644 index 00000000..febdb14b --- /dev/null +++ b/src/infrastructure/repositories/pg/contact_group_pg_repository.rs @@ -0,0 +1,223 @@ +use async_trait::async_trait; +use sqlx::{PgPool, Row, types::Uuid}; +use std::sync::Arc; +use chrono::Utc; +use serde_json::Value as JsonValue; + +use crate::domain::entities::contact::{Contact, ContactGroup}; +use crate::domain::repositories::contact_repository::{ContactGroupRepository, ContactRepositoryResult}; +use crate::common::errors::{DomainError, ErrorKind}; +use super::contact_persistence_dto::{ + emails_from_persistence, phones_from_persistence, addresses_from_persistence, + EmailPersistenceDto, PhonePersistenceDto, AddressPersistenceDto, +}; + +pub struct ContactGroupPgRepository { + pool: Arc, +} + +impl ContactGroupPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ContactGroupRepository for ContactGroupPgRepository { + async fn create_group(&self, group: ContactGroup) -> ContactRepositoryResult { + sqlx::query( + "INSERT INTO carddav.contact_groups (id, address_book_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)" + ) + .bind(group.id()) + .bind(group.address_book_id()) + .bind(group.name()) + .bind(group.created_at()) + .bind(group.updated_at()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to create group: {}", e)))?; + + Ok(group) + } + + async fn update_group(&self, group: ContactGroup) -> ContactRepositoryResult { + sqlx::query( + "UPDATE carddav.contact_groups SET name = $1, updated_at = $2 WHERE id = $3" + ) + .bind(group.name()) + .bind(Utc::now()) + .bind(group.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to update group: {}", e)))?; + + Ok(group) + } + + async fn delete_group(&self, id: &Uuid) -> ContactRepositoryResult<()> { + // Delete memberships first + sqlx::query("DELETE FROM carddav.group_memberships WHERE group_id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to delete group memberships: {}", e)))?; + + sqlx::query("DELETE FROM carddav.contact_groups WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to delete group: {}", e)))?; + + Ok(()) + } + + async fn get_group_by_id(&self, id: &Uuid) -> ContactRepositoryResult> { + let row = sqlx::query( + "SELECT id, address_book_id, name, created_at, updated_at FROM carddav.contact_groups WHERE id = $1" + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to get group: {}", e)))?; + + match row { + Some(row) => { + let group = ContactGroup::from_raw( + row.get::("id"), + row.get::("address_book_id"), + row.get::("name"), + row.get("created_at"), + row.get("updated_at"), + ); + Ok(Some(group)) + }, + None => Ok(None), + } + } + + async fn get_groups_by_address_book(&self, address_book_id: &Uuid) -> ContactRepositoryResult> { + let rows = sqlx::query( + "SELECT id, address_book_id, name, created_at, updated_at FROM carddav.contact_groups WHERE address_book_id = $1 ORDER BY name" + ) + .bind(address_book_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to list groups: {}", e)))?; + + Ok(rows.into_iter().map(|row| { + ContactGroup::from_raw( + row.get::("id"), + row.get::("address_book_id"), + row.get::("name"), + row.get("created_at"), + row.get("updated_at"), + ) + }).collect()) + } + + async fn add_contact_to_group(&self, group_id: &Uuid, contact_id: &Uuid) -> ContactRepositoryResult<()> { + sqlx::query( + "INSERT INTO carddav.group_memberships (group_id, contact_id) VALUES ($1, $2) ON CONFLICT DO NOTHING" + ) + .bind(group_id) + .bind(contact_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to add contact to group: {}", e)))?; + + Ok(()) + } + + async fn remove_contact_from_group(&self, group_id: &Uuid, contact_id: &Uuid) -> ContactRepositoryResult<()> { + sqlx::query( + "DELETE FROM carddav.group_memberships WHERE group_id = $1 AND contact_id = $2" + ) + .bind(group_id) + .bind(contact_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to remove contact from group: {}", e)))?; + + Ok(()) + } + + async fn get_contacts_in_group(&self, group_id: &Uuid) -> ContactRepositoryResult> { + let rows = sqlx::query( + r#" + SELECT + c.id, c.address_book_id, c.uid, c.full_name, c.first_name, c.last_name, c.nickname, + c.email, c.phone, c.address, c.organization, c.title, c.notes, c.photo_url, + c.birthday, c.anniversary, c.vcard, c.etag, c.created_at, c.updated_at + FROM carddav.contacts c + INNER JOIN carddav.group_memberships gm ON c.id = gm.contact_id + WHERE gm.group_id = $1 + ORDER BY c.full_name, c.first_name, c.last_name + "# + ) + .bind(group_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to get contacts in group: {}", e)))?; + + let mut contacts = Vec::new(); + for row in &rows { + let email_json: JsonValue = row.get("email"); + let phone_json: JsonValue = row.get("phone"); + let address_json: JsonValue = row.get("address"); + + let emails = serde_json::from_value::>(email_json) + .map(emails_from_persistence) + .unwrap_or_default(); + let phones = serde_json::from_value::>(phone_json) + .map(phones_from_persistence) + .unwrap_or_default(); + let addresses = serde_json::from_value::>(address_json) + .map(addresses_from_persistence) + .unwrap_or_default(); + + contacts.push(Contact::from_raw( + row.get("id"), + row.get("address_book_id"), + row.get("uid"), + row.get::, _>("full_name"), + row.get::, _>("first_name"), + row.get::, _>("last_name"), + row.get::, _>("nickname"), + emails, + phones, + addresses, + row.get::, _>("organization"), + row.get::, _>("title"), + row.get::, _>("notes"), + row.get::, _>("photo_url"), + row.get("birthday"), + row.get("anniversary"), + row.get("vcard"), + row.get("etag"), + row.get("created_at"), + row.get("updated_at"), + )); + } + Ok(contacts) + } + + async fn get_groups_for_contact(&self, contact_id: &Uuid) -> ContactRepositoryResult> { + let rows = sqlx::query( + "SELECT g.id, g.address_book_id, g.name, g.created_at, g.updated_at FROM carddav.contact_groups g INNER JOIN carddav.group_memberships gm ON g.id = gm.group_id WHERE gm.contact_id = $1 ORDER BY g.name" + ) + .bind(contact_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new(ErrorKind::InternalError, "ContactGroup", format!("Failed to get groups for contact: {}", e)))?; + + Ok(rows.into_iter().map(|row| { + ContactGroup::from_raw( + row.get::("id"), + row.get::("address_book_id"), + row.get::("name"), + row.get("created_at"), + row.get("updated_at"), + ) + }).collect()) + } +} diff --git a/src/infrastructure/repositories/pg/contact_pg_repository.rs b/src/infrastructure/repositories/pg/contact_pg_repository.rs index ca3f3833..7041c844 100644 --- a/src/infrastructure/repositories/pg/contact_pg_repository.rs +++ b/src/infrastructure/repositories/pg/contact_pg_repository.rs @@ -1,13 +1,17 @@ use async_trait::async_trait; use chrono::Utc; -use sqlx::{PgPool, types::Uuid}; +use sqlx::{PgPool, Row, types::Uuid}; use std::sync::Arc; use serde_json::Value as JsonValue; use crate::domain::entities::contact::Contact; use crate::domain::repositories::contact_repository::{ContactRepository, ContactRepositoryResult}; use crate::common::errors::DomainError; -use super::contact_persistence_dto::{emails_to_persistence, phones_to_persistence, addresses_to_persistence}; +use super::contact_persistence_dto::{ + emails_to_persistence, phones_to_persistence, addresses_to_persistence, + emails_from_persistence, phones_from_persistence, addresses_from_persistence, + EmailPersistenceDto, PhonePersistenceDto, AddressPersistenceDto, +}; pub struct ContactPgRepository { pool: Arc, @@ -17,6 +21,46 @@ impl ContactPgRepository { pub fn new(pool: Arc) -> Self { Self { pool } } + + /// Maps a database row to a Contact domain entity + fn row_to_contact(row: &sqlx::postgres::PgRow) -> Result { + let email_json: JsonValue = row.get("email"); + let phone_json: JsonValue = row.get("phone"); + let address_json: JsonValue = row.get("address"); + + let emails = serde_json::from_value::>(email_json) + .map(emails_from_persistence) + .unwrap_or_default(); + let phones = serde_json::from_value::>(phone_json) + .map(phones_from_persistence) + .unwrap_or_default(); + let addresses = serde_json::from_value::>(address_json) + .map(addresses_from_persistence) + .unwrap_or_default(); + + Ok(Contact::from_raw( + row.get("id"), + row.get("address_book_id"), + row.get("uid"), + row.get::, _>("full_name"), + row.get::, _>("first_name"), + row.get::, _>("last_name"), + row.get::, _>("nickname"), + emails, + phones, + addresses, + row.get::, _>("organization"), + row.get::, _>("title"), + row.get::, _>("notes"), + row.get::, _>("photo_url"), + row.get("birthday"), + row.get("anniversary"), + row.get("vcard"), + row.get("etag"), + row.get("created_at"), + row.get("updated_at"), + )) + } } #[async_trait] @@ -31,7 +75,7 @@ impl ContactRepository for ContactPgRepository { let phone_json = serde_json::to_value(&phone_dtos).unwrap_or(JsonValue::Null); let address_json = serde_json::to_value(&address_dtos).unwrap_or(JsonValue::Null); - let _row = sqlx::query( + let row = sqlx::query( r#" INSERT INTO carddav.contacts ( id, address_book_id, uid, full_name, first_name, last_name, nickname, @@ -72,9 +116,7 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to create contact: {}", e)))?; - // En una implementación real, construiríamos un objeto Contact completo - // Por simplicidad, devolvemos el contacto original - Ok(contact) + Self::row_to_contact(&row) } async fn update_contact(&self, contact: Contact) -> ContactRepositoryResult { @@ -92,7 +134,7 @@ impl ContactRepository for ContactPgRepository { let mut updated_contact = contact.clone(); updated_contact.set_updated_at(now); - let _row = sqlx::query( + let row = sqlx::query( r#" UPDATE carddav.contacts SET @@ -140,9 +182,7 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to update contact: {}", e)))?; - // En una implementación real, construiríamos un objeto Contact a partir de la fila resultante - // Por simplicidad, devolvemos el contacto con el timestamp actualizado - Ok(updated_contact) + Self::row_to_contact(&row) } async fn delete_contact(&self, id: &Uuid) -> ContactRepositoryResult<()> { @@ -176,13 +216,10 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get contact by id: {}", e)))?; - if let Some(_row) = row_opt { - // En una implementación real, construiríamos un objeto Contact a partir de la fila - // Por simplicidad y demostración, devolvemos una instancia predeterminada - return Ok(Some(Contact::default())); + match row_opt { + Some(row) => Ok(Some(Self::row_to_contact(&row)?)), + None => Ok(None), } - - Ok(None) } async fn get_contact_by_uid(&self, address_book_id: &Uuid, uid: &str) -> ContactRepositoryResult> { @@ -202,17 +239,14 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get contact by uid: {}", e)))?; - if let Some(_row) = row_opt { - // En una implementación real, construiríamos un objeto Contact a partir de la fila - // Por simplicidad y demostración, devolvemos una instancia predeterminada - return Ok(Some(Contact::default())); + match row_opt { + Some(row) => Ok(Some(Self::row_to_contact(&row)?)), + None => Ok(None), } - - Ok(None) } async fn get_contacts_by_address_book(&self, address_book_id: &Uuid) -> ContactRepositoryResult> { - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, address_book_id, uid, full_name, first_name, last_name, nickname, @@ -228,17 +262,17 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get contacts by address book: {}", e)))?; - // En una implementación real, construiríamos objetos Contact a partir de las filas - // Por simplicidad y demostración, devolvemos una lista vacía - let contacts = Vec::new(); - + 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 = format!("%{}%", email); - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, address_book_id, uid, full_name, first_name, last_name, nickname, @@ -254,15 +288,15 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get contacts by email: {}", e)))?; - // En una implementación real, construiríamos objetos Contact a partir de las filas - // Por simplicidad y demostración, devolvemos una lista vacía - let contacts = Vec::new(); - + let mut contacts = Vec::new(); + for row in &rows { + contacts.push(Self::row_to_contact(row)?); + } Ok(contacts) } async fn get_contacts_by_group(&self, group_id: &Uuid) -> ContactRepositoryResult> { - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT c.id, c.address_book_id, c.uid, c.full_name, c.first_name, c.last_name, c.nickname, @@ -279,17 +313,17 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to get contacts by group: {}", e)))?; - // En una implementación real, construiríamos objetos Contact a partir de las filas - // Por simplicidad y demostración, devolvemos una lista vacía - let contacts = Vec::new(); - + let mut contacts = Vec::new(); + for row in &rows { + contacts.push(Self::row_to_contact(row)?); + } Ok(contacts) } async fn search_contacts(&self, address_book_id: &Uuid, query: &str) -> ContactRepositoryResult> { let search_pattern = format!("%{}%", query); - let _rows = sqlx::query( + let rows = sqlx::query( r#" SELECT id, address_book_id, uid, full_name, first_name, last_name, nickname, @@ -315,10 +349,10 @@ impl ContactRepository for ContactPgRepository { .await .map_err(|e| DomainError::database_error(format!("Failed to search contacts: {}", e)))?; - // En una implementación real, construiríamos objetos Contact a partir de las filas - // Por simplicidad y demostración, devolvemos una lista vacía - let contacts = Vec::new(); - + let mut contacts = Vec::new(); + for row in &rows { + contacts.push(Self::row_to_contact(row)?); + } Ok(contacts) } } \ No newline at end of file diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 014be432..d8357ac0 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -2,6 +2,7 @@ mod address_book_pg_repository; mod calendar_pg_repository; mod calendar_event_pg_repository; mod contact_pg_repository; +mod contact_group_pg_repository; mod contact_persistence_dto; mod favorites_pg_repository; mod recent_items_pg_repository; @@ -13,6 +14,7 @@ pub use address_book_pg_repository::AddressBookPgRepository; pub use calendar_pg_repository::CalendarPgRepository; pub use calendar_event_pg_repository::CalendarEventPgRepository; pub use contact_pg_repository::ContactPgRepository; +pub use contact_group_pg_repository::ContactGroupPgRepository; pub use contact_persistence_dto::*; pub use favorites_pg_repository::FavoritesPgRepository; pub use recent_items_pg_repository::RecentItemsPgRepository; diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index 9fa2edeb..8afb7455 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -1,22 +1,629 @@ +/** + * CalDAV Handler Module + * + * This module implements the CalDAV protocol (RFC 4791) endpoints for OxiCloud. + * It provides calendar access and management through standard CalDAV methods, + * allowing clients like Thunderbird, Apple Calendar, and GNOME Calendar to sync. + * + * Supported methods: + * - OPTIONS: Advertise CalDAV capabilities + * - PROPFIND: List calendars and their properties + * - REPORT: Query events (calendar-query, calendar-multiget) + * - MKCALENDAR: Create a new calendar + * - PUT: Create/update calendar events (.ics) + * - GET: Retrieve calendar event data + * - DELETE: Remove calendars or events + * - PROPPATCH: Modify calendar properties + */ + use axum::{ Router, - routing::get, - http::StatusCode, - response::IntoResponse, - Json, + response::Response, + http::{StatusCode, header, HeaderName, Request}, + body::{Body, self}, }; -use serde_json::json; +use std::sync::Arc; +use bytes::Buf; use crate::common::di::AppState; +use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType}; +use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType}; +use crate::application::ports::calendar_ports::CalendarUseCase; +use crate::application::dtos::calendar_dto::{ + CreateCalendarDto, UpdateCalendarDto, CreateEventICalDto, +}; +use crate::interfaces::middleware::auth::CurrentUser; +use crate::interfaces::errors::AppError; -// Temporary placeholder implementation +const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); + +/// Creates CalDAV routes with full path prefixes. +/// +/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap. +/// Registers `/caldav`, `/caldav/`, and `/caldav/{*path}` explicitly. pub fn caldav_routes() -> Router { Router::new() - .route("/placeholder", get(placeholder_handler)) + .route("/caldav/{*path}", axum::routing::any(handle_caldav_methods)) + .route("/caldav/", axum::routing::any(handle_caldav_methods_root)) + .route("/caldav", axum::routing::any(handle_caldav_methods_root)) } -async fn placeholder_handler() -> impl IntoResponse { - (StatusCode::OK, Json(json!({ - "message": "CalDAV functionality is not yet implemented" - }))) +async fn handle_caldav_methods_root( + axum::extract::State(state): axum::extract::State, + req: Request, +) -> Result, AppError> { + handle_caldav_methods_inner(state, req, String::new()).await +} + +async fn handle_caldav_methods( + axum::extract::State(state): axum::extract::State, + req: Request, +) -> Result, AppError> { + let uri = req.uri().clone(); + let path = extract_caldav_path(uri.path()); + handle_caldav_methods_inner(state, req, path).await +} + +async fn handle_caldav_methods_inner( + state: AppState, + req: Request, + path: String, +) -> Result, AppError> { + let method = req.method().clone(); + let state = Arc::new(state); + + match method.as_str() { + "OPTIONS" => handle_options().await, + "PROPFIND" => handle_propfind(state, req, &path).await, + "REPORT" => handle_report(state, req, &path).await, + "MKCALENDAR" => handle_mkcalendar(state, req, &path).await, + "PUT" => handle_put(state, req, &path).await, + "GET" => handle_get(state, req, &path).await, + "DELETE" => handle_delete(state, req, &path).await, + "PROPPATCH" => handle_proppatch(state, req, &path).await, + _ => Err(AppError::method_not_allowed(format!("Method not allowed: {}", method))), + } +} + +/// Extract the CalDAV path from the full URI path. +fn extract_caldav_path(uri_path: &str) -> String { + if let Some(pos) = uri_path.find("/caldav/") { + let after = &uri_path[pos + 8..]; + after.trim_end_matches('/').to_string() + } else if uri_path.ends_with("/caldav") { + String::new() + } else { + uri_path.trim_start_matches('/').trim_end_matches('/').to_string() + } +} + +// ─── Helper: extract user from request ─────────────────────────────── + +fn extract_user(req: &Request) -> Result { + req.extensions() + .get::() + .cloned() + .ok_or_else(|| AppError::unauthorized("Authentication required")) +} + +fn get_calendar_service(state: &AppState) -> Result<&Arc, AppError> { + state.calendar_use_case.as_ref().ok_or_else(|| { + AppError::new( + StatusCode::NOT_IMPLEMENTED, + "CalDAV service is not configured", + "NotImplemented", + ) + }) +} + +// ─── OPTIONS ───────────────────────────────────────────────────────── + +async fn handle_options() -> Result, AppError> { + Ok(Response::builder() + .status(StatusCode::OK) + .header(HEADER_DAV, "1, 2, calendar-access") + .header(header::ALLOW, "OPTIONS, GET, PUT, DELETE, PROPFIND, PROPPATCH, REPORT, MKCALENDAR") + .body(Body::empty()) + .unwrap()) +} + +// ─── PROPFIND ──────────────────────────────────────────────────────── + +async fn handle_propfind( + state: Arc, + req: Request, + path: &str, +) -> Result, 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 calendar_service = get_calendar_service(&state)?; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .await + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + + // Parse PROPFIND request + 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)))? + }; + + if path.is_empty() { + // Root CalDAV path — list user's calendars + let calendars = calendar_service.list_my_calendars_for_user(&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( + &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 { + // Path could be: {calendar_id} or {calendar_id}/{event_uid}.ics + let parts: Vec<&str> = path.splitn(2, '/').collect(); + let calendar_id = parts[0]; + + if parts.len() == 1 { + // Calendar collection + let calendar = calendar_service.get_calendar_for_user(calendar_id, &user.id).await + .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; + + let events = if depth != "0" { + calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await + .unwrap_or_default() + } else { + vec![] + }; + + 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 events = calendar_service.list_events_for_user(calendar_id, None, None, &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) + .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; + + let base_href = &format!("/caldav/{}/", calendar_id); + let report_type = CalDavReportType::CalendarMultiget { + hrefs: vec![format!("{}{}.ics", base_href, ical_uid)], + props: vec![], + }; + + let mut response_body = Vec::new(); + CalDavAdapter::generate_calendar_events_response( + &mut response_body, + &[event.clone()], + &report_type, + 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, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let calendar_service = get_calendar_service(&state)?; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .await + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + + 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); + + if calendar_id.is_empty() { + return Err(AppError::bad_request("Calendar ID required in path")); + } + + let events = match &report { + CalDavReportType::CalendarQuery { time_range, .. } => { + if let Some((start, end)) = time_range { + calendar_service.get_events_in_range_for_user(calendar_id, *start, *end, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to query events: {}", e)))? + } else { + calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))? + } + }, + CalDavReportType::CalendarMultiget { hrefs, .. } => { + let all_events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; + + all_events.into_iter() + .filter(|evt| hrefs.iter().any(|href| href.contains(&evt.ical_uid))) + .collect() + }, + CalDavReportType::SyncCollection { .. } => { + calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))? + }, + }; + + let base_href = &format!("/caldav/{}/", calendar_id); + let mut response_body = Vec::new(); + CalDavAdapter::generate_calendar_events_response( + &mut response_body, + &events, + &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()) +} + +// ─── MKCALENDAR ────────────────────────────────────────────────────── + +async fn handle_mkcalendar( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let calendar_service = get_calendar_service(&state)?; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .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('/').last().unwrap_or("New Calendar").to_string(); + (name, None, None) + } else { + CalDavAdapter::parse_mkcalendar(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse MKCALENDAR: {}", e)))? + }; + + let create_dto = CreateCalendarDto { + name, + description, + color, + is_public: Some(false), + }; + + calendar_service.create_calendar_for_user(create_dto, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to create calendar: {}", e)))?; + + Ok(Response::builder() + .status(StatusCode::CREATED) + .body(Body::empty()) + .unwrap()) +} + +// ─── PUT (.ics) ────────────────────────────────────────────────────── + +async fn handle_put( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let calendar_service = get_calendar_service(&state)?; + + let parts: Vec<&str> = path.splitn(2, '/').collect(); + if parts.len() < 2 { + return Err(AppError::bad_request("Path must be {calendar_id}/{uid}.ics")); + } + + let calendar_id = parts[0]; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .await + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + + let ical_data = String::from_utf8(body_bytes.to_vec()) + .map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in iCalendar data: {}", e)))?; + + let ical_uid = extract_uid_from_ical(&ical_data); + + let existing = if let Some(ref uid) = ical_uid { + let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await + .unwrap_or_default(); + events.into_iter().find(|e| e.ical_uid == *uid) + } else { + None + }; + + if let Some(existing_event) = existing { + // Update existing event — re-create from iCal for full fidelity + calendar_service.delete_event_for_user(&existing_event.id, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to update event: {}", e)))?; + + let create_dto = CreateEventICalDto { + calendar_id: calendar_id.to_string(), + ical_data, + }; + let event = calendar_service.create_event_from_ical_for_user(create_dto, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to recreate event: {}", e)))?; + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::ETAG, format!("\"{}\"", event.id)) + .body(Body::empty()) + .unwrap()) + } else { + let create_dto = CreateEventICalDto { + calendar_id: calendar_id.to_string(), + ical_data, + }; + + let event = calendar_service.create_event_from_ical_for_user(create_dto, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to create event: {}", e)))?; + + Ok(Response::builder() + .status(StatusCode::CREATED) + .header(header::ETAG, format!("\"{}\"", event.id)) + .body(Body::empty()) + .unwrap()) + } +} + +/// Extract UID from iCalendar data +fn extract_uid_from_ical(ical_data: &str) -> Option { + for line in ical_data.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("UID:") { + return Some(trimmed[4..].trim().to_string()); + } + } + None +} + +// ─── GET (.ics) ────────────────────────────────────────────────────── + +async fn handle_get( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let calendar_service = get_calendar_service(&state)?; + + let parts: Vec<&str> = path.splitn(2, '/').collect(); + let calendar_id = parts[0]; + + if parts.len() < 2 { + // GET on calendar collection + let events = calendar_service.list_events_for_user(calendar_id, None, None, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; + + let calendar = calendar_service.get_calendar_for_user(calendar_id, &user.id).await + .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; + + let ical = generate_full_calendar_ical(&calendar.name, &events); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/calendar; charset=utf-8") + .header(header::ETAG, format!("\"{}\"", calendar.id)) + .body(Body::from(ical)) + .unwrap()) + } else { + // GET on individual event + let event_file = parts[1]; + let ical_uid = event_file.trim_end_matches(".ics"); + + let events = calendar_service.list_events_for_user(calendar_id, None, None, &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) + .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; + + let ical = generate_event_ical(event); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/calendar; charset=utf-8") + .header(header::ETAG, format!("\"{}\"", event.id)) + .body(Body::from(ical)) + .unwrap()) + } +} + +fn generate_full_calendar_ical( + calendar_name: &str, + events: &[crate::application::dtos::calendar_dto::CalendarEventDto], +) -> String { + let mut ical = format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n", + calendar_name + ); + for event in events { + ical.push_str(&generate_vevent(event)); + } + ical.push_str("END:VCALENDAR\r\n"); + ical +} + +fn generate_event_ical(event: &crate::application::dtos::calendar_dto::CalendarEventDto) -> String { + format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n{}END:VCALENDAR\r\n", + generate_vevent(event) + ) +} + +fn generate_vevent(event: &crate::application::dtos::calendar_dto::CalendarEventDto) -> String { + let mut vevent = format!( + "BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n", + event.ical_uid, + event.summary.replace('\n', "\\n"), + event.start_time.format("%Y%m%dT%H%M%SZ"), + event.end_time.format("%Y%m%dT%H%M%SZ"), + ); + if let Some(ref desc) = event.description { + vevent.push_str(&format!("DESCRIPTION:{}\r\n", desc.replace('\n', "\\n"))); + } + if let Some(ref loc) = event.location { + vevent.push_str(&format!("LOCATION:{}\r\n", loc)); + } + if let Some(ref rrule) = event.rrule { + vevent.push_str(&format!("RRULE:{}\r\n", rrule)); + } + vevent.push_str(&format!( + "DTSTAMP:{}\r\nCREATED:{}\r\nLAST-MODIFIED:{}\r\nEND:VEVENT\r\n", + event.updated_at.format("%Y%m%dT%H%M%SZ"), + event.created_at.format("%Y%m%dT%H%M%SZ"), + event.updated_at.format("%Y%m%dT%H%M%SZ"), + )); + vevent +} + +// ─── DELETE ────────────────────────────────────────────────────────── + +async fn handle_delete( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let calendar_service = get_calendar_service(&state)?; + + let parts: Vec<&str> = path.splitn(2, '/').collect(); + let calendar_id = parts[0]; + + if calendar_id.is_empty() { + return Err(AppError::bad_request("Calendar ID required")); + } + + if parts.len() < 2 { + calendar_service.delete_calendar_for_user(calendar_id, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to delete calendar: {}", e)))?; + } else { + let event_file = parts[1]; + let ical_uid = event_file.trim_end_matches(".ics"); + + let events = calendar_service.list_events_for_user(calendar_id, None, None, &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) + .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; + + calendar_service.delete_event_for_user(&event.id, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to delete event: {}", e)))?; + } + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap()) +} + +// ─── PROPPATCH ─────────────────────────────────────────────────────── + +async fn handle_proppatch( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let calendar_service = get_calendar_service(&state)?; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .await + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + + let (props_to_set, props_to_remove) = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?; + + let calendar_id = path.split('/').next().unwrap_or(path); + + if calendar_id.is_empty() { + return Err(AppError::bad_request("Calendar ID required")); + } + + let mut update = UpdateCalendarDto { + name: None, + description: None, + color: None, + is_public: None, + }; + + for prop in &props_to_set { + match prop.name.name.as_str() { + "displayname" => update.name = Some(prop.value.clone().unwrap_or_default()), + "calendar-description" => update.description = prop.value.clone(), + "calendar-color" => update.color = prop.value.clone(), + _ => {} + } + } + + if update.name.is_some() || update.description.is_some() || update.color.is_some() { + calendar_service.update_calendar_for_user(calendar_id, update, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to update calendar: {}", e)))?; + } + + let mut results = Vec::new(); + for prop in &props_to_set { + results.push((&prop.name, true)); + } + for prop in &props_to_remove { + results.push((prop, true)); + } + + let href = format!("/caldav/{}", 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()) } \ No newline at end of file diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 7b3ed141..ea29623e 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -1,979 +1,604 @@ +/** + * 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, - routing::{get, put, delete, post}, - extract::{Path, State, Json}, - http::StatusCode, - response::IntoResponse, + response::Response, + http::{StatusCode, header, HeaderName, Request}, + body::{Body, self}, }; use std::sync::Arc; -use serde_json::json; +use bytes::Buf; use crate::common::di::AppState; -use crate::interfaces::middleware::auth::AuthUser; +use crate::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType, contact_to_vcard}; +use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType}; +use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase}; use crate::application::dtos::address_book_dto::{ - AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto, - ShareAddressBookDto, UnshareAddressBookDto -}; -use crate::application::dtos::contact_dto::{ - ContactDto, CreateContactDto, UpdateContactDto, CreateContactVCardDto, - ContactGroupDto, CreateContactGroupDto, UpdateContactGroupDto, GroupMembershipDto + CreateAddressBookDto, UpdateAddressBookDto, }; +use crate::application::dtos::contact_dto::CreateContactVCardDto; +use crate::interfaces::middleware::auth::CurrentUser; +use crate::interfaces::errors::AppError; -// CardDAV handler implementation +const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); + +/// 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 { Router::new() - // Address book operations - .route("/address-books", get(list_address_books).post(create_address_book)) - .route("/address-books/:id", - get(get_address_book) - .put(update_address_book) - .delete(delete_address_book) + .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)) +} + +async fn handle_carddav_methods_root( + axum::extract::State(state): axum::extract::State, + req: Request, +) -> Result, AppError> { + handle_carddav_methods_inner(state, req, String::new()).await +} + +async fn handle_carddav_methods( + axum::extract::State(state): axum::extract::State, + req: Request, +) -> Result, AppError> { + let uri = req.uri().clone(); + let path = extract_carddav_path(uri.path()); + handle_carddav_methods_inner(state, req, path).await +} + +async fn handle_carddav_methods_inner( + state: AppState, + req: Request, + path: String, +) -> Result, AppError> { + let state = Arc::new(state); + 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. +fn extract_carddav_path(uri_path: &str) -> String { + if let Some(pos) = uri_path.find("/carddav/") { + let after = &uri_path[pos + 9..]; + after.trim_end_matches('/').to_string() + } else if uri_path.ends_with("/carddav") { + String::new() + } else { + uri_path.trim_start_matches('/').trim_end_matches('/').to_string() + } +} + +// ─── Helper: extract user from request ─────────────────────────────── + +fn extract_user(req: &Request) -> Result { + req.extensions() + .get::() + .cloned() + .ok_or_else(|| AppError::unauthorized("Authentication required")) +} + +fn get_addressbook_service(state: &AppState) -> Result<&Arc, AppError> { + state.addressbook_use_case.as_ref().ok_or_else(|| { + AppError::new( + StatusCode::NOT_IMPLEMENTED, + "CardDAV address book service is not configured", + "NotImplemented", ) - .route("/address-books/:id/shares", - get(get_address_book_shares) - ) - .route("/address-books/:id/share", - post(share_address_book) - ) - .route("/address-books/:id/unshare/:user_id", - delete(unshare_address_book) + }) +} + +fn get_contact_service(state: &AppState) -> Result<&Arc, 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, 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, + req: Request, + path: &str, +) -> Result, 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(), usize::MAX) + .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)))? + }; + + if path.is_empty() { + // Root CardDAV path — list user's address 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)))?; - // Contact operations - .route("/address-books/:id/contacts", - get(list_contacts) - .post(create_contact) - ) - .route("/address-books/:id/contacts/search", - get(search_contacts) - ) - .route("/address-books/:id/contacts/vcard", - post(create_contact_from_vcard) - ) - .route("/address-books/:address_book_id/contacts/:contact_id", - get(get_contact) - .put(update_contact) - .delete(delete_contact) - ) - .route("/address-books/:address_book_id/contacts/:contact_id/vcard", - get(get_contact_vcard) - ) + let base_href = "/carddav/"; + 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)))?; - // Group operations - .route("/address-books/:id/groups", - get(list_groups) - .post(create_group) - ) - .route("/address-books/:address_book_id/groups/:group_id", - get(get_group) - .put(update_group) - .delete(delete_group) - ) - .route("/address-books/:address_book_id/groups/:group_id/contacts", - get(list_contacts_in_group) - ) - .route("/groups/:group_id/contacts/:contact_id", - post(add_contact_to_group) - .delete(remove_contact_from_group) - ) - .route("/contacts/:contact_id/groups", - get(list_groups_for_contact) - ) -} - -// Address Book handlers -async fn list_address_books( - State(state): State, - auth_user: AuthUser, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "user_id": user_id - }); + 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> = 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)))?; - match contact_service.handle_request("list_user_address_books", params).await { - Ok(result) => { - let address_books: Vec = serde_json::from_value(result) - .unwrap_or_else(|_| Vec::new()); - (StatusCode::OK, Json(address_books)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to list address books: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn create_address_book( - State(state): State, - Json(dto): Json, -) -> impl IntoResponse { - match &state.contact_service { - Some(contact_service) => { - match contact_service.handle_request("create_address_book", serde_json::to_value(dto).unwrap()).await { - Ok(result) => { - let address_book: AddressBookDto = serde_json::from_value(result) - .unwrap_or_else(|_| AddressBookDto::default()); - (StatusCode::CREATED, Json(address_book)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to create address book: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn get_address_book( - State(state): State, - auth_user: AuthUser, - Path(id): Path, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "address_book_id": id, - "user_id": user_id - }); - - match contact_service.handle_request("get_address_book", params).await { - Ok(result) => { - let address_book: AddressBookDto = serde_json::from_value(result) - .unwrap_or_else(|_| AddressBookDto::default()); - (StatusCode::OK, Json(address_book)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to get address book: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn update_address_book( - State(state): State, - auth_user: AuthUser, - Path(id): Path, - Json(mut update): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - update.user_id = user_id.to_string(); - - match &state.contact_service { - Some(contact_service) => { - let mut params = serde_json::to_value(update).unwrap(); - - // Add address_book_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("address_book_id".to_string(), serde_json::Value::String(id)); - } - - match contact_service.handle_request("update_address_book", params).await { - Ok(result) => { - let address_book: AddressBookDto = serde_json::from_value(result) - .unwrap_or_else(|_| AddressBookDto::default()); - (StatusCode::OK, Json(address_book)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to update address book: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn delete_address_book( - State(state): State, - auth_user: AuthUser, - Path(id): Path, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "address_book_id": id, - "user_id": user_id - }); - - match contact_service.handle_request("delete_address_book", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to delete address book: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn get_address_book_shares( - State(state): State, - auth_user: AuthUser, - Path(id): Path, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "address_book_id": id, - "user_id": user_id - }); - - match contact_service.handle_request("get_address_book_shares", params).await { - Ok(result) => { - (StatusCode::OK, Json(result)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to get address book shares: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn share_address_book( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, - Json(mut dto): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - dto.address_book_id = address_book_id; - - match &state.contact_service { - Some(contact_service) => { - let mut params = serde_json::to_value(dto).unwrap(); - - // Add user_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("user_id".to_string(), serde_json::Value::String(user_id.to_string())); - } - - match contact_service.handle_request("share_address_book", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to share address book: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn unshare_address_book( - State(state): State, - auth_user: AuthUser, - Path((address_book_id, shared_with)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let dto = UnshareAddressBookDto { - address_book_id, - user_id: shared_with, + let contacts = if depth != "0" { + contact_svc.list_contacts(address_book_id, &user.id).await + .unwrap_or_default() + } else { + vec![] }; - let mut params = serde_json::to_value(dto).unwrap(); + let base_href = &format!("/carddav/{}/", address_book_id); + let mut response_body = Vec::new(); - // Add user_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("user_id".to_string(), serde_json::Value::String(user_id.to_string())); - } + 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)))?; - match contact_service.handle_request("unshare_address_book", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to unshare address book: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -// Contact handlers -async fn list_contacts( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "address_book_id": address_book_id, - "user_id": user_id - }); + 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 + let contact_file = parts[1]; + let contact_uid = contact_file.trim_end_matches(".vcf"); - match contact_service.handle_request("list_contacts", params).await { - Ok(result) => { - let contacts: Vec = serde_json::from_value(result) - .unwrap_or_else(|_| Vec::new()); - (StatusCode::OK, Json(contacts)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to list contacts: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn search_contacts( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, - axum::extract::Query(params): axum::extract::Query>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - let query = params.get("q").unwrap_or(&String::new()).to_string(); - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "address_book_id": address_book_id, - "query": query, - "user_id": user_id - }); + // Look up by UID across all contacts in this address book + let contacts = contact_svc.list_contacts(address_book_id, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; - match contact_service.handle_request("search_contacts", params).await { - Ok(result) => { - let contacts: Vec = serde_json::from_value(result) - .unwrap_or_else(|_| Vec::new()); - (StatusCode::OK, Json(contacts)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to search contacts: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn create_contact( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, - Json(mut dto): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - dto.address_book_id = address_book_id; - dto.user_id = user_id.to_string(); - - match &state.contact_service { - Some(contact_service) => { - match contact_service.handle_request("create_contact", serde_json::to_value(dto).unwrap()).await { - Ok(result) => { - let contact: ContactDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactDto::default()); - (StatusCode::CREATED, Json(contact)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to create contact: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn create_contact_from_vcard( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, - Json(mut dto): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - dto.address_book_id = address_book_id; - dto.user_id = user_id.to_string(); - - match &state.contact_service { - Some(contact_service) => { - match contact_service.handle_request("create_contact_from_vcard", serde_json::to_value(dto).unwrap()).await { - Ok(result) => { - let contact: ContactDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactDto::default()); - (StatusCode::CREATED, Json(contact)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to create contact from vCard: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn get_contact( - State(state): State, - auth_user: AuthUser, - Path((_, contact_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "contact_id": contact_id, - "user_id": user_id - }); + let contact = contacts.iter().find(|c| c.uid == contact_uid) + .ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?; - match contact_service.handle_request("get_contact", params).await { - Ok(result) => { - let contact: ContactDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactDto::default()); - (StatusCode::OK, Json(contact)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to get contact: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn update_contact( - State(state): State, - auth_user: AuthUser, - Path((_, contact_id)): Path<(String, String)>, - Json(mut update): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - update.user_id = user_id.to_string(); - - match &state.contact_service { - Some(contact_service) => { - let mut params = serde_json::to_value(update).unwrap(); - - // Add contact_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("contact_id".to_string(), serde_json::Value::String(contact_id)); - } - - match contact_service.handle_request("update_contact", params).await { - Ok(result) => { - let contact: ContactDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactDto::default()); - (StatusCode::OK, Json(contact)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to update contact: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn delete_contact( - State(state): State, - auth_user: AuthUser, - Path((_, contact_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "contact_id": contact_id, - "user_id": user_id - }); - - match contact_service.handle_request("delete_contact", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to delete contact: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn get_contact_vcard( - State(state): State, - auth_user: AuthUser, - Path((_, contact_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "contact_id": contact_id, - "user_id": user_id - }); - - match contact_service.handle_request("get_contact_vcard", params).await { - Ok(result) => { - let vcard = match result { - serde_json::Value::String(s) => s, - _ => "BEGIN:VCARD\nVERSION:3.0\nEND:VCARD".to_string(), - }; - - // Return vCard with proper content type - ( - StatusCode::OK, - [ - ("Content-Type", "text/vcard; charset=utf-8"), - ("Content-Disposition", "attachment; filename=\"contact.vcf\""), - ], - vcard - ) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to get contact vCard: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -// Group handlers -async fn list_groups( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "address_book_id": address_book_id, - "user_id": user_id - }); - - match contact_service.handle_request("list_groups", params).await { - Ok(result) => { - let groups: Vec = serde_json::from_value(result) - .unwrap_or_else(|_| Vec::new()); - (StatusCode::OK, Json(groups)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to list groups: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn create_group( - State(state): State, - auth_user: AuthUser, - Path(address_book_id): Path, - Json(mut dto): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - dto.address_book_id = address_book_id; - dto.user_id = user_id.to_string(); - - match &state.contact_service { - Some(contact_service) => { - match contact_service.handle_request("create_group", serde_json::to_value(dto).unwrap()).await { - Ok(result) => { - let group: ContactGroupDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactGroupDto::default()); - (StatusCode::CREATED, Json(group)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to create group: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn get_group( - State(state): State, - auth_user: AuthUser, - Path((_, group_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "group_id": group_id, - "user_id": user_id - }); - - match contact_service.handle_request("get_group", params).await { - Ok(result) => { - let group: ContactGroupDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactGroupDto::default()); - (StatusCode::OK, Json(group)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to get group: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn update_group( - State(state): State, - auth_user: AuthUser, - Path((_, group_id)): Path<(String, String)>, - Json(mut update): Json, -) -> impl IntoResponse { - let user_id = &auth_user.id; - update.user_id = user_id.to_string(); - - match &state.contact_service { - Some(contact_service) => { - let mut params = serde_json::to_value(update).unwrap(); - - // Add group_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("group_id".to_string(), serde_json::Value::String(group_id)); - } - - match contact_service.handle_request("update_group", params).await { - Ok(result) => { - let group: ContactGroupDto = serde_json::from_value(result) - .unwrap_or_else(|_| ContactGroupDto::default()); - (StatusCode::OK, Json(group)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to update group: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn delete_group( - State(state): State, - auth_user: AuthUser, - Path((_, group_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "group_id": group_id, - "user_id": user_id - }); - - match contact_service.handle_request("delete_group", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to delete group: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn list_contacts_in_group( - State(state): State, - auth_user: AuthUser, - Path((_, group_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "group_id": group_id, - "user_id": user_id - }); - - match contact_service.handle_request("list_contacts_in_group", params).await { - Ok(result) => { - let contacts: Vec = serde_json::from_value(result) - .unwrap_or_else(|_| Vec::new()); - (StatusCode::OK, Json(contacts)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to list contacts in group: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } - } -} - -async fn add_contact_to_group( - State(state): State, - auth_user: AuthUser, - Path((group_id, contact_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let dto = GroupMembershipDto { - group_id, - contact_id, + // 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 params = serde_json::to_value(dto).unwrap(); + let mut response_body = Vec::new(); + CardDavAdapter::generate_contacts_response( + &mut response_body, + &[contact.clone()], + &[(contact.uid.clone(), contact_to_vcard(contact))], + &report, + base_href, + ).map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; - // Add user_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("user_id".to_string(), serde_json::Value::String(user_id.to_string())); - } - - match contact_service.handle_request("add_contact_to_group", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to add contact to group: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()) } } } -async fn remove_contact_from_group( - State(state): State, - auth_user: AuthUser, - Path((group_id, contact_id)): Path<(String, String)>, -) -> impl IntoResponse { - let user_id = &auth_user.id; +// ─── REPORT ────────────────────────────────────────────────────────── + +async fn handle_report( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let contact_svc = get_contact_service(&state)?; - match &state.contact_service { - Some(contact_service) => { - let dto = GroupMembershipDto { - group_id, - contact_id, - }; - - let mut params = serde_json::to_value(dto).unwrap(); - - // Add user_id to the params - if let serde_json::Value::Object(ref mut map) = params { - map.insert("user_id".to_string(), serde_json::Value::String(user_id.to_string())); - } - - match contact_service.handle_request("remove_contact_from_group", params).await { - Ok(_) => { - StatusCode::NO_CONTENT - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to remove contact from group: {}", e) - }))) - } - } + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .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 address_book_id = path.split('/').next().unwrap_or(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, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))? }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) - } + 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)))?; + + all_contacts.into_iter() + .filter(|c| hrefs.iter().any(|href| href.contains(&c.uid))) + .collect() + }, + CardDavReportType::SyncCollection { .. } => { + contact_svc.list_contacts(address_book_id, &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, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let addressbook_service = get_addressbook_service(&state)?; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .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('/').last().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.clone(), + 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, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let contact_svc = get_contact_service(&state)?; + + let parts: Vec<&str> = 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(), usize::MAX) + .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 + let existing = if let Some(ref uid) = vcard_uid { + let contacts = contact_svc.list_contacts(address_book_id, &user.id).await + .unwrap_or_default(); + contacts.into_iter().find(|c| c.uid == *uid) + } 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.clone(), + }; + 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.clone(), + }; + + 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()) } } -async fn list_groups_for_contact( - State(state): State, - auth_user: AuthUser, - Path(contact_id): Path, -) -> impl IntoResponse { - let user_id = &auth_user.id; - - match &state.contact_service { - Some(contact_service) => { - let params = json!({ - "contact_id": contact_id, - "user_id": user_id - }); - - match contact_service.handle_request("list_groups_for_contact", params).await { - Ok(result) => { - let groups: Vec = serde_json::from_value(result) - .unwrap_or_else(|_| Vec::new()); - (StatusCode::OK, Json(groups)) - }, - Err(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": format!("Failed to list groups for contact: {}", e) - }))) - } - } - }, - None => { - (StatusCode::NOT_IMPLEMENTED, Json(json!({ - "error": "Contact service not available" - }))) +/// Extract UID from vCard data +fn extract_uid_from_vcard(vcard_data: &str) -> Option { + for line in vcard_data.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("UID:") { + return Some(trimmed[4..].trim().to_string()); } } -} \ No newline at end of file + None +} + +// ─── GET (.vcf) ────────────────────────────────────────────────────── + +async fn handle_get( + state: Arc, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let contact_svc = get_contact_service(&state)?; + + let parts: Vec<&str> = 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, &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 + let contact_file = parts[1]; + let contact_uid = contact_file.trim_end_matches(".vcf"); + + let contacts = contact_svc.list_contacts(address_book_id, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; + + let contact = contacts.iter().find(|c| c.uid == contact_uid) + .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, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let addressbook_service = get_addressbook_service(&state)?; + let contact_svc = get_contact_service(&state)?; + + let parts: Vec<&str> = 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 + let contact_file = parts[1]; + let contact_uid = contact_file.trim_end_matches(".vcf"); + + let contacts = contact_svc.list_contacts(address_book_id, &user.id).await + .map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?; + + let contact = contacts.iter().find(|c| c.uid == contact_uid) + .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, + req: Request, + path: &str, +) -> Result, AppError> { + let user = extract_user(&req)?; + let addressbook_service = get_addressbook_service(&state)?; + + let body_bytes = body::to_bytes(req.into_body(), usize::MAX) + .await + .map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?; + + let (props_to_set, props_to_remove) = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?; + + let address_book_id = path.split('/').next().unwrap_or(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.clone(), + }; + + for prop in &props_to_set { + 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 prop in &props_to_set { + results.push((&prop.name, true)); + } + for prop in &props_to_remove { + results.push((prop, 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()) +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 14b61f2b..b5b2fe8b 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -10,6 +10,7 @@ pub mod favorites_handler; pub mod recent_handler; pub mod webdav_handler; pub mod caldav_handler; +pub mod carddav_handler; pub mod chunked_upload_handler; pub mod dedup_handler; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index b97122dc..588a4df0 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -282,11 +282,8 @@ pub fn create_api_routes(app_state: &AppState) -> Router { router = router.merge(webdav_handler::webdav_routes()); } - // Add CalDAV routes - { - use crate::interfaces::api::handlers::caldav_handler; - router = router.nest("/caldav", caldav_handler::caldav_routes()); - } + // NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav) + // in main.rs for protocol compliance, NOT under /api. router .layer(CompressionLayer::new()) diff --git a/src/main.rs b/src/main.rs index 728db4a2..4cb5c3f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,6 +94,12 @@ async fn main() -> Result<(), Box> { let mut app; + // Build CalDAV / CardDAV protocol routers (merged at top-level, not under /api) + use oxicloud::interfaces::api::handlers::caldav_handler; + use oxicloud::interfaces::api::handlers::carddav_handler; + let caldav_router = caldav_handler::caldav_routes(); + let carddav_router = carddav_handler::carddav_routes(); + // Apply auth middleware to protected API routes when auth is enabled if config.features.enable_auth && app_state.auth_service.is_some() { use interfaces::api::handlers::auth_handler::auth_routes; @@ -104,6 +110,12 @@ async fn main() -> Result<(), Box> { // Protected API routes — require valid JWT token let protected_api = api_routes + .layer(axum::middleware::from_fn_with_state(app_state_arc.clone(), auth_middleware)); + + // CalDAV/CardDAV with auth middleware (merged, not nested) + let caldav_protected = caldav_router + .layer(axum::middleware::from_fn_with_state(app_state_arc.clone(), auth_middleware)); + let carddav_protected = carddav_router .layer(axum::middleware::from_fn_with_state(app_state_arc, auth_middleware)); app = Router::new() @@ -113,6 +125,9 @@ async fn main() -> Result<(), Box> { .nest("/api", public_api_routes) // All other API routes are protected by auth middleware .nest("/api", protected_api) + // CalDAV/CardDAV protocols merged at top-level for client compatibility + .merge(caldav_protected) + .merge(carddav_protected) .merge(web_routes) .layer(TraceLayer::new_for_http()); } else { @@ -121,6 +136,9 @@ async fn main() -> Result<(), Box> { app = Router::new() .nest("/api", public_api_routes) .nest("/api", api_routes) + // CalDAV/CardDAV protocols merged at top-level + .merge(caldav_router) + .merge(carddav_router) .merge(web_routes) .layer(TraceLayer::new_for_http()); } diff --git a/storage/folder_ids.json b/storage/folder_ids.json index 56d48f64..babba8c8 100644 --- a/storage/folder_ids.json +++ b/storage/folder_ids.json @@ -1,5 +1,11 @@ { - "path_to_id": {}, - "id_to_path": {}, - "version": 0 + "path_to_id": { + "/Mi Carpeta - test": "edfc23e0-d9f9-4cc7-b1dc-6369568b19e6", + "/Mi Carpeta - testuser": "304384d6-9a7e-4b22-a659-287957e8b667" + }, + "id_to_path": { + "304384d6-9a7e-4b22-a659-287957e8b667": "/Mi Carpeta - testuser", + "edfc23e0-d9f9-4cc7-b1dc-6369568b19e6": "/Mi Carpeta - test" + }, + "version": 2 } \ No newline at end of file